feat: secure Zebra scanning and unify mobile Back routing

Use the optional device-provided EMDK service for normal scanner input,
with exact compile-only SDK provenance and no shipped vendor classes.
Keep synthetic scan broadcasts confined to the disposable E2E variant.
Pause input behind overlays and lost focus, and share live drawer-first
Back routing across every process/application handler and toolbar arrow.

Replace inherited Auth0/Coil prereleases with reviewed stable versions.
Invalidate standard generated notices on dependency graph changes and
retain strict runtime/license checks. Pin hosted acceptance to the
source-qualified API 1.9 core/server tuple with runtime provenance.

Validation: 127 JVM tests, 34 TC22 shared native cases, eight-overlay
native regression, and ordinary-UID/distinct-signer injection rejection
pass. Eight forged broadcasts cause no process/record mutation; a real
manual positive control saves through the API. All package/lint gates,
four-APK notice/DEX/native/manifest inspection, focused independent
source review, workflow syntax and staged secret checks pass.

Actual hardware triggers, configured provider, optional files deployment
and final signing/distribution remain separate documented release gates.
No normal operator installation or data was changed by these tests.

Co-Authored-By: OpenAI GPT-6.1 (Codex) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandOpenAI GPT-6.1 committed 2026-10-31 20:18:41 -05:00
1 parent df532ff514
commit d316567fdc
47 files changed
+2201 -113

No files matched your search

+36 -5
View File
@@ -76,6 +76,10 @@ jobs:
needs: verify
runs-on: ubuntu-24.04
timeout-minutes: 40
env:
QQQ_CORE_SHA: ecf0f550a0357cec6387c2b54efca128a405a629
SCANNER_SERVER_SHA: b14d925b741e815d1cfa4f62301f19207f25b03f
MAVEN_OPTS: -XX:ActiveProcessorCount=2 -Xmx768m
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
@@ -85,18 +89,39 @@ jobs:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
- name: Check out the pinned QQQ core source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: QRun-IO/qqq
ref: ${{ env.QQQ_CORE_SHA }}
path: .e2e-core
persist-credentials: false
- name: Check out the pinned real QQQ demo
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: QRun-IO/qbit-mobile-scanners
ref: 1df7bbd18047c6c3a20cf1c2b990d4bcde9860e8
ref: ${{ env.SCANNER_SERVER_SHA }}
path: .e2e-server
persist-credentials: false
- name: Build the isolated server fixture
working-directory: .e2e-server
- name: Verify and build the isolated source-pinned server fixture
env:
JAVA_TOOL_OPTIONS: -XX:ActiveProcessorCount=2 -Xmx768m
run: |
./mvnw --batch-mode --no-transfer-progress verify install
./mvnw --batch-mode --no-transfer-progress -f demo/pom.xml verify dependency:build-classpath -Dmdep.outputFile=target/e2e-classpath.txt
test "$(git -C .e2e-core rev-parse HEAD)" = "$QQQ_CORE_SHA"
test "$(git -C .e2e-server rev-parse HEAD)" = "$SCANNER_SERVER_SHA"
fixture_m2=$(mktemp -d "$RUNNER_TEMP/scanner-fixture-m2.XXXXXX")
core_revision="4.1.0-ci.$QQQ_CORE_SHA"
(cd .e2e-core && ./mvnw --batch-mode --no-transfer-progress \
-Dmaven.repo.local="$fixture_m2" -Drevision="$core_revision" \
-pl qqq-bom,qqq-middleware-api -am clean install)
(cd .e2e-server && ./mvnw --batch-mode --no-transfer-progress \
-Dmaven.repo.local="$fixture_m2" -Dqqq.version="$core_revision" clean install)
(cd .e2e-server && ./mvnw --batch-mode --no-transfer-progress -f demo/pom.xml \
-Dmaven.repo.local="$fixture_m2" -Dqqq.version="$core_revision" clean verify \
dependency:build-classpath -DincludeScope=runtime -Dmdep.outputFile=target/e2e-classpath.txt \
dependency:tree -Dscope=runtime -DoutputFile=target/fixture-dependencies.txt \
help:effective-pom -Doutput=target/fixture-effective-pom.xml)
node scripts/ci/verify-fixture-provenance.mjs .e2e-server "$fixture_m2" "$QQQ_CORE_SHA" "$SCANNER_SERVER_SHA"
- name: Enable hosted-runner hardware acceleration
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
@@ -117,8 +142,14 @@ jobs:
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: scanner-emulator-${{ github.sha }}
include-hidden-files: true
path: |
qqqsampleandroidmobileapp/build/reports/androidTests/
qqqsampleandroidmobileapp/build/outputs/androidTest-results/
scanner-host/build/native-e2e/
.e2e-server/demo/target/fixture-provenance.json
.e2e-server/demo/target/fixture-dependencies.txt
.e2e-server/demo/target/fixture-effective-pom.xml
.e2e-core/**/target/surefire-reports/TEST-*.xml
.e2e-server/**/target/surefire-reports/TEST-*.xml
retention-days: 14
+1
View File
@@ -14,5 +14,6 @@
.cxx
local.properties
/.e2e-server/
/.e2e-core/
*.jks
*.keystore
+1 -1
View File
@@ -10,7 +10,7 @@ Use the included Gradle wrapper, Java21, and Android SDK35. `local.properties`,
./gradlew :qqq:android:core:testDebugUnitTest :qqq:android:mobileapp:testDebugUnitTest :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleDebug
```
Run `:qqqsampleandroidmobileapp:connectedDebugAndroidTest` on an emulator for changes to native authentication/navigation/process rendering. `scripts/ci/emulator-smoke.sh` deliberately refuses physical devices and clears only the disposable emulator's scanner application data. Physical TC22 tests remain necessary for DataWedge integration.
Run `:qqqsampleandroidmobileapp:connectedDebugAndroidTest` on an emulator for changes to native authentication/navigation/process rendering. `scripts/ci/emulator-smoke.sh` deliberately refuses physical devices and clears only the disposable emulator's scanner application data. Physical TC22 tests remain necessary for EMDK trigger capture, lifecycle and scanner-ownership release. Use the separate production-path QA variant and separately signed ordinary-UID probe documented in `docs/ZEBRA-SCANNING.md`; E2E synthetic broadcasts do not qualify production scanner security or hardware behavior. Never replace the operator's application or persistently modify DataWedge profiles for these tests.
All mobile processes require end-to-end coverage. For scanner flow changes, build `:scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest` and run `node scripts/ci/scanner-e2e.mjs ../qbit-mobile-scanners` against an emulator after building the companion demo/classpath as documented in `docs/EMULATOR.md`. This drives the real host and real server in the separate `io.qrun.mobile.scanner.e2e` application with a temporary database; never clear the operator's normal scanner application to run it. Server record actions must also be covered through both single-record and selected-bulk UI paths in the companion qbit's browser suite.
+6 -12
View File
@@ -8,7 +8,7 @@ Native Kotlin/Compose clients for QQQ. The new `scanner-host` application runs s
- `qqq/android/core`: metadata and process HTTP client (`QQQRepository` / `NetworkQQQRepository`).
- `qqq/android/mobileapp`: native app picker, authentication, process runner, and widget renderer.
- `scanner-host`: server configuration, device registration/approval gate, and DataWedge barcode capture.
- `scanner-host`: server configuration, device registration/approval gate, and Zebra EMDK barcode capture.
- `qqqsampleandroidmobileapp`: historical example application.
The client supports metadata and process execution. It does not implement the record CRUD/fluent API previously described by this README.
@@ -28,17 +28,11 @@ Use the companion `qbit-mobile-scanners` server/demo with QQQ `4.1.0-RC.20`. The
The server defines the app, native process steps, and `SCANNER` widget; the APK does not hard-code a scan process name. Existing native widget types continue to work.
## Zebra DataWedge profile
## Zebra hardware scanning
Create a profile associated with `io.qrun.mobile.scanner`, activity `*`:
The normal debug and release APKs use the device-provided Zebra EMDK service. They do not accept DataWedge scan broadcasts. No DataWedge profile setup is required. The scanner is acquired only while a scanner widget is visible and the activity is resumed; leaving or backgrounding releases it. EMDK temporarily takes scanner ownership from DataWedge and restores it on release. The app does not persistently modify DataWedge settings.
- Enable Barcode input and single-barcode decoding.
- Disable Keystroke output.
- Enable Intent output with action `io.qrun.mobile.scanner.SCAN`.
- Set category `android.intent.category.DEFAULT` and delivery to Broadcast.
- Target package `io.qrun.mobile.scanner` when package targeting is available.
The receiver is active only while a scanner widget is visible and its activity is resumed. It reads `com.symbol.datawedge.data_string`, `label_type`, and `source`. Multi-decode payloads are rejected. Symbology/source are client-reported metadata, not proof of scanner hardware identity. See [Zebra Intent output](https://techdocs.zebra.com/datawedge/latest/guide/output/intent/).
The same APK runs on other Android devices with explicit manual entry. When the required system library/service is missing or unavailable, the screen explains that hardware scanning is unavailable. [Scanner integration and SDK provenance](docs/ZEBRA-SCANNING.md) describes the lifecycle, compile-only SDK and verification gates. Synthetic broadcasts are accepted only by the separate disposable E2E variant.
## Customer branding and diagnostics
@@ -50,7 +44,7 @@ The persistent footer shows **Checking**, **Connected**, or **Disconnected** wit
QQQ administrators can inspect session activity, end sessions, and request live viewing of the QQQ app from a session record. Viewing displays an indicator with **Stop viewing** on the handheld. Password/login screens and background apps cannot be mirrored. Optional foreground location requires the operator's Android permission; scanning works without it. See [session behavior and limits](docs/UI-API.md#session-activity-and-screen-viewing).
There is no offline queue. One submitted scan is retained in memory until a complete receipt returns the matching `acknowledgedEventId`, `scanSaved: true`, and a nonblank `scanId`. If a response is uncertain, **Retry same request** preserves its event ID and original timestamp; the server deduplicates it. Further scans are blocked until the pending request is resolved. Sign-out/reset clears the pending request; app termination loses in-memory state. Repeated physical scans deliberately receive distinct event IDs.
There is no offline queue. One submitted scan is retained in memory until a complete receipt returns the matching `acknowledgedEventId`, `scanSaved: true`, and a nonblank `scanId`. If a response is uncertain, **Retry same request** preserves its event ID and original timestamp; the server deduplicates it. Further scans wait for confirmation or explicit abandonment. Leaving after an uncertain response warns that the scan may already be saved and attempts server cancellation; if cancellation fails, **Leave anyway** lets the operator exit explicitly. Active requests and pending photos remain protected. Sign-out/reset clears the pending request; app termination loses in-memory state. Repeated physical scans deliberately receive distinct event IDs.
Enrollment credentials are encrypted with Android Keystore and stored under `noBackupFilesDir`, scoped to the configured server. The app disables backup. Device headers accompany native QQQ requests, and the server enforces approval on every scan. Release builds reject HTTP; only the development build permits it for local testing. The host disables HTTP redirects to avoid forwarding credentials to another origin.
@@ -64,7 +58,7 @@ Requirements: Java 17 or compatible newer runtime, Android SDK platform35, build
APK: `scanner-host/build/outputs/apk/debug/scanner-host-debug.apk`.
The existing libraries also have unit and emulator tests. `testDebugUnitTest` runs JVM tests; `connectedDebugAndroidTest` needs a connected device/emulator. Physical TC22 trigger/intent behavior must still be verified on hardware.
The existing libraries also have unit and emulator tests. `testDebugUnitTest` runs JVM tests; `connectedDebugAndroidTest` needs a connected device/emulator. Physical TC22 trigger, lifecycle and scanner-ownership behavior must still be verified on hardware.
See [emulator testing](docs/EMULATOR.md) for a local interactive demo, synthetic scanner broadcasts, and the disposable-emulator smoke check.
+2
View File
@@ -2,6 +2,8 @@
The server selects the login provider through the versioned QQQ UI API. `TABLE_BASED` uses the native username/password form. `OAUTH2` uses the organization's external browser only when the server explicitly advertises the supported native session flow. This supports Authentik without embedding an Authentik URL or client secret in a customer-specific APK. Unknown providers and unsupported flows show a configuration message; OAuth cancellation never falls back to a local password form.
The shared scanner APK accepts only `TABLE_BASED` and the API 1.9 `OAUTH2` flow described here. Legacy `AUTH0` support belongs to the shared library and sample host; the scanner rejects that metadata before opening a browser. Its unused Auth0 manifest placeholders do not configure a customer login. Legacy callback lifecycle tests do not establish a live Auth0 provider round trip. This matches the [README's scanner support scope](../README.md).
## Native browser contract
Before opening a browser, the client requires UI API `>=1.9.0 <2.0.0`, the `nativeSessionLogin` feature, and this typed descriptor in `/qqq/v1/metaData/authentication`:
+15
View File
@@ -0,0 +1,15 @@
# Hosted scanner fixture
The source pins and complete Maven commands live in [Scanner Android](../.github/workflows/scanner.yml). The fixture uses core commit `ecf0f550a0357cec6387c2b54efca128a405a629` and scanner server commit `b14d925b741e815d1cfa4f62301f19207f25b03f`. The core includes the UI API 1.9 native authentication contract, credential log redaction, explicit JSON null handling, the Guava 33.7.2-jre security update, portable SPA filesystem paths, and automatically allocated spec-test ports. The scanner includes the canonical UTC H2 migration and the demo BOM import that aligns all transitive QQQ dependencies with the selected version.
Each job creates a fresh Maven repository under the runner's temporary directory. Core is built as `4.1.0-ci.<full-core-SHA>`; the scanner and demo receive that exact version through `qqq.version`. This 49-character value retains the full commit identity and fits the core's 50-character help-bundle version limit. Nothing installs into the runner's shared Maven repository, substitutes a published artifact, or restores a cached QQQ snapshot. The scanner's own fixture snapshot is built from its checked source in that same fresh repository. This is an isolated CI source qualification, not publication of a production release or adoption by an operator server. The optional Record Files integration has its own dependency qualification.
The core reactor includes the BOM and middleware API with their required upstream modules. It runs every test in that dependency closure, Checkstyle, JaCoCo coverage checks, SpotBugs and PMD through the full `clean install` lifecycle. The scanner and demo then run their full verification suites before Maven writes the demo's runtime classpath, dependency tree and effective POM. The standard `jar` tool and Node SHA-256 implementation inspect that resolved classpath: every runtime JAR must belong to the private repository, every backend QQQ module must use the unique core version, required modules must exist, exactly H2 2.2.224 and Guava 33.7.2-jre must occur, and QQQ-owned JARs must not duplicate backend classes. The pinned frontend distribution keeps its independent version.
`fixture-provenance.json` records all resolved runtime JAR hashes, both source commits, the core revision and the unique QQQ class count. The dependency tree, effective POM and test evidence accompany the emulator artifacts. These checks inspect the selected runtime closure; they do not promise reproducible byte-for-byte Java builds or validate a published release.
For a local reproduction, check out both exact commits into disposable source directories and run the workflow's Maven commands with a new empty private repository path. Use Java 21, the checked-in wrappers, two active processors and a 768 MiB heap. Windows PowerShell uses `mvnw.cmd` and quoted `-D` arguments. Never point `maven.repo.local` at the shared `.m2` repository or reuse an operator database. Run `verify-fixture-provenance.mjs` with the server directory, private repository path, core SHA and server SHA, then run the emulator entry point as described in [EMULATOR.md](EMULATOR.md).
The local Windows qualification adds `-Dexec.skip=true` only to the core command because Maven's inline `sh -c` coverage-summary formatting fails Windows argument quoting. The inspected dependency closure has only that cosmetic exec-plugin execution. This exception retains all tests, coverage checks and static analyses; the hosted Ubuntu command keeps the summary enabled. Do not use a focused test selector with the full lifecycle: its coverage thresholds apply to the complete module suites.
Preserve the full native runner. In particular, `admin-forms` submits an actual optional null reason through the native session-ending form and guards the prior HTTP 500 failure. `session-monitor` separately verifies screen viewing, remote termination and fresh login; its administrative helper alone does not exercise that nullable form. TABLE_BASED fixture metadata must not advertise OAuth2-only native login support. Actual configured identity-provider acceptance remains a separate gate.
+32
View File
@@ -0,0 +1,32 @@
# Stable authentication and image dependencies
Reviewed October 31, 2026. This decision replaces Auth0 Android `3.0.0-beta.0` with `3.0.0` and all three Coil `3.0.0-rc01` version aliases with `3.0.4`. It preserves the current Kotlin, Compose compiler, AGP, Java and Android SDK configuration. These are bounded stable replacements, not a claim that every dependency is the latest release or has ongoing maintenance guaranteed by its publisher.
## Auth0 Android
Auth0 `3.0.0` is the last stable 3.x release before passkey dependencies were introduced in `3.1.0`. An Auth0 maintainer explicitly recommends it for applications avoiding the added Play Services dependency in [upstream issue 871](https://github.com/auth0/Auth0.Android/issues/871). Its [published POM](https://repo.maven.apache.org/maven2/com/auth0/android/auth0/3.0.0/auth0-3.0.0.pom) declares the same direct dependency coordinates and versions as the current beta. It retains Kotlin stdlib 1.8.22, OkHttp 4.12.0 and Gson 2.8.9. Its source targets Java 8 and supports Android API 21; this application uses API 26 or later.
The current host uses `Auth0.getInstance`, `WebAuthProvider.login/logout`, audience/scheme configuration, and browser callbacks. Native semantic OAuth login uses AppAuth separately. The application does not use Auth0's credential managers, passkey provider, DPoP, PAR or native-to-web credentials.
The stable release adds AuthenticationActivity null handling and changes the fallback for an empty authentication error description. Later 3.x changes include browser process-death restoration in 3.4, configurable authorization/logout URLs, optional DPoP and its replay/process-restoration fixes, partial browser tabs and PAR. Later RSA-padding, biometric and session-expiry fixes concern Auth0 credential managers that this client does not use. See the [tagged changelog](https://github.com/auth0/Auth0.Android/blob/3.21.3/CHANGELOG.md) and [3.0.0 to 3.21.3 source comparison](https://github.com/auth0/Auth0.Android/compare/3.0.0...3.21.3).
The bounded comparison found no later security fix applicable to the current WebAuth usage; the public repository advisory endpoint returned no published advisories at review time. Neither observation is a complete security audit or a maintenance guarantee. Full process-death recovery through Auth0's later application callback APIs is not provided by choosing 3.0.0. Existing late-callback, rotation, logout and origin-isolation protections still require regression testing.
Auth0 3.21.3 is a stable alternative, but its [POM](https://repo.maven.apache.org/maven2/com/auth0/android/auth0/3.21.3/auth0-3.21.3.pom) adds Credential Manager and its Play Services provider. The [provider POM](https://dl.google.com/dl/android/maven2/androidx/credentials/credentials-play-services-auth/1.3.0/credentials-play-services-auth-1.3.0.pom) introduces Google authentication, FIDO and Google ID artifacts. Upstream publishes them as regular dependencies; no supported exclusion recipe was found. This change does not exclude required classes or waive notice checks. Version 3.9.0 is not a dependency-free alternative: its missing transitive declarations were a publication defect repaired in 3.9.1.
## Coil
Coil 3.0.4 is a stable patch in the existing major/minor line. Its [release notes](https://coil-kt.github.io/coil/changelog/#304---november-25-2024) include image rendering and cache corrections. The intervening RC removed the `AsyncImage` equality-delegate parameter, which this repository does not use. Both image consumers use supported `ImageLoader`, `SvgDecoder.Factory`, `ImageRequest` and `AsyncImage` APIs.
Published POM comparison predicts Kotlin runtime 2.0.20 to 2.0.21, lifecycle runtime 2.8.6 to 2.8.7 and annotations 1.8.2 to 1.9.1. Compose foundation 1.7.0, coroutines 1.9.0, OkHttp 4.12.0, Okio 3.9.1 and AndroidSVG 1.4 remain unchanged. These are requested dependency versions, not a substitute for inspecting Gradle's final selected graph. See [Compose](https://repo.maven.apache.org/maven2/io/coil-kt/coil3/coil-compose-core-android/3.0.4/coil-compose-core-android-3.0.4.pom), [core](https://repo.maven.apache.org/maven2/io/coil-kt/coil3/coil-core-android/3.0.4/coil-core-android-3.0.4.pom), and [SVG](https://repo.maven.apache.org/maven2/io/coil-kt/coil3/coil-svg-android/3.0.4/coil-svg-android-3.0.4.pom) metadata. No toolchain upgrade or forced transitive downgrade is included.
## Qualification required after these edits
- Compare debug, release, E2E and QA runtime graphs; verify the stable versions, aligned Coil modules, Kotlin/Compose selections, absence of newly introduced Google Play dependencies and continued compile-only EMDK isolation.
- Run core/mobile/scanner JVM suites, lint and scanner/sample APK and instrumentation compilation. Inspect release manifests and bundled native-library hashes again if their resolved dependencies change.
- Regenerate and validate each variant's complete AboutLibraries report and packaged texts. New graph size must be measured; preserve exact coverage and license gates.
- Repeat native authentication/navigation regressions, including legacy Auth0 callbacks and provider-failure logout. Configured-provider browser acceptance remains a separate deployment check.
- Verify raster and SVG branding/widget rendering, loading/error states, image-loader disposal, and offline license viewing. Existing upload and screen-mirror tests alone do not prove Coil image rendering.
- Requalify the TC22 scanner flow on the resulting APK before delivery. Do not treat earlier APK evidence as validation of these new pins.
The candidate now passes127 JVM tests, all34 selected TC22 shared native cases (including actual PNG/SVG/error rendering), the scanner overlay regression, package/lint gates and four-APK inspection. Actual packaged reports contain131 entries/nine complete texts and the expected stable Auth0/Coil versions with no new Google identity dependencies. Selected Kotlin stdlib is2.0.21 and reflect2.0.20; historical JDK7/8 compatibility wrappers remain1.9.0. These observed selections are not a claim of a broader toolchain upgrade. See [exact package evidence and remaining gates](TRUSTED-SCANNER-ACCEPTANCE.md). No production release has been signed or published.
+7 -11
View File
@@ -1,6 +1,6 @@
# Emulator testing
An Android emulator verifies login, enrollment, native navigation, manual barcode submission, and intent handling. It does not reproduce Zebra's scan engine, physical trigger, or DataWedge profile management.
An Android emulator verifies login, enrollment, native navigation and manual barcode submission. The separate E2E variant also supports synthetic scan injection. An emulator does not reproduce Zebra's EMDK scan engine or physical trigger.
## Prepared Windows workstation
@@ -35,17 +35,17 @@ Keep the Android server URL `http://127.0.0.1:8080/`. The browser administration
6. Select the mobile app, open its scan process, and submit a synthetic barcode manually. Verify **Saved scan**, and verify the server's stored record.
7. Revoke the device on the server and verify subsequent submissions are denied. Sign out and verify a new login is required.
To exercise the same intent adapter used by DataWedge while the scanner screen is visible, send a synthetic development broadcast:
Only the disposable E2E APK accepts synthetic broadcasts while its scanner screen is visible. Normal debug/release builds ignore this ingress and use EMDK or manual entry:
```text
adb shell am broadcast -a io.qrun.mobile.scanner.SCAN -c android.intent.category.DEFAULT -p io.qrun.mobile.scanner --es com.symbol.datawedge.data_string DEMO-001 --es com.symbol.datawedge.label_type LABEL-TYPE-CODE128 --es com.symbol.datawedge.source scanner
adb shell am broadcast -a io.qrun.mobile.scanner.SCAN -c android.intent.category.DEFAULT -p io.qrun.mobile.scanner.e2e --es com.symbol.datawedge.data_string DEMO-001 --es com.symbol.datawedge.label_type LABEL-TYPE-CODE128
```
This validates intent parsing only; the server treats source/symbology as client-reported metadata.
## Automated checks
The GitHub **Scanner Android** workflow runs JVM tests, lint, APK assembly, the six shared native UI tests, and the real-server scanner E2E on pushes and pull requests. The emulator job checks out a pinned companion qbit revision, builds it, and starts a fresh isolated demo database. Sanitized instrumentation results are uploaded as workflow artifacts. Hosted execution is verified separately from a local run.
The GitHub **Scanner Android** workflow runs JVM tests, lint, APK assembly, the shared native UI suite, and the real-server scanner E2E on pushes and pull requests. The emulator job checks out exact core and companion qbit commits, verifies them in a fresh private Maven repository, and starts a fresh isolated demo database. [Fixture provenance](CI-FIXTURE.md) describes the source pins, runtime checks and distinction from published release dependencies. Sanitized instrumentation results are uploaded as workflow artifacts. Hosted execution is verified separately from a local run.
The scanner E2E installs **QQQ Mobile E2E**, a separate application (`io.qrun.mobile.scanner.e2e`). It drives server selection, username/password login, pending enrollment, approval, app selection, Scan Capture, manual barcode submission, server acknowledgment, independent persisted-record verification, and revocation of an already-open scan process. Administrative setup uses the real QQQ process endpoints; the companion qbit's browser suite covers their single-record and selected-bulk UI entry points. No repository or server mocks are used. Random test credentials travel through standard input into the test application's private files, are removed after reading, and are redacted from reports. The runner preserves the normal scanner package and its port forwarding.
@@ -53,20 +53,16 @@ The runner uses a test-only forwarding proxy that checks observed requests again
The default suite also runs bundled-license rendering and local orientation policy checks. Set `E2E_SCENARIO=licenses` or `E2E_SCENARIO=orientation` to run either independently; these two scenarios need only the scanner E2E APKs and do not start a server or establish USB forwarding.
To reproduce with Node 22+ and Java 21, first prepare the sibling server:
To reproduce with Node 22+ and Java 21, first prepare the [source-pinned fixture](CI-FIXTURE.md). Use its qualified server directory below (the example assumes `.e2e-server`); an ordinary server build against the default RC.20 core does not include the nullable process fix:
```powershell
Push-Location ../qbit-mobile-scanners
./mvnw.cmd -B verify install
./mvnw.cmd -B -f demo/pom.xml verify dependency:build-classpath '-Dmdep.outputFile=target/e2e-classpath.txt'
Pop-Location
./gradlew.bat :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest
$env:ANDROID_SERIAL = 'emulator-5554'
node scripts/ci/scanner-e2e.mjs ../qbit-mobile-scanners
node scripts/ci/scanner-e2e.mjs .e2e-server
```
Set `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) if `adb` is not on PATH. Linux/macOS use `./mvnw`, `./gradlew`, and their shell's environment assignment syntax. The runner refuses physical devices, uses a free local port and temporary database, and cleans up its server, forwarding and both isolated test APKs. The older setup-only `scripts/ci/emulator-smoke.sh` is retained for disposable-emulator troubleshooting; it clears the normal scanner package and should not be used on your interactive demo emulator.
## Physical acceptance still required
Test the TC22 trigger and DataWedge profile, scan during rotation/backgrounding, retry after a lost response, revoked approval, leading-zero barcodes, and repeated physical scans. Confirm that an uncertain submission retains its event ID and a repeated physical scan receives a new event ID. Never use sensitive production barcodes in test screenshots or CI artifacts.
Test the TC22 EMDK trigger, rotation/backgrounding, retry after a lost response, revoked approval, leading-zero barcodes and repeated physical scans. Confirm DataWedge scanning works again after QQQ releases ownership. An uncertain submission must retain its event ID; a repeated physical scan receives a new event ID. See [trusted scanner acceptance](ZEBRA-SCANNING.md). Never use sensitive production barcodes in test screenshots or CI artifacts.
+1 -1
View File
@@ -36,6 +36,6 @@ Without signing variables, local release assembly produces an unsigned APK for i
Android accepts an in-place application update only with a compatible signing identity and version code. The existing development-signed TC22 installation cannot simply be replaced by an unrelated production certificate. Preserve its test evidence and explicitly plan that migration: reinstalling removes private enrollment/session state and requires enrollment/approval again. Do not silently uninstall the operator's application to make a release install work.
For Zebra devices, verify the DataWedge profile's explicit package and signature association against the final production certificate, with keystroke output disabled. Qualify physical trigger capture, Back/Apps navigation, session termination, reconnect behavior and photo capture using the signed APK. Subsequent customer updates should preserve server selection and device enrollment and keep the same signing identity. Customer branding comes from server metadata and does not require rebuilding the APK.
For Zebra devices, qualify the device-provided EMDK scanner path described in [Zebra scanning](ZEBRA-SCANNING.md). Production must exclude the synthetic scan receiver and compile-only SDK implementation. Verify actual trigger capture, repeated and empty reads, rotation/reacquisition, background/leave/logout release, and restoration of scanner ownership to other applications. The production path requires no DataWedge profile changes; do not persistently disable DataWedge or treat its recipient signature check as sender authentication. Complete the separately signed ordinary-UID broadcast rejection test against the isolated production-path QA build, then qualify Back/Apps navigation, session termination, reconnect behavior and photo capture using the actual signed release APK. Subsequent customer updates should preserve server selection and device enrollment and keep the same signing identity. Customer branding comes from server metadata and does not require rebuilding the APK.
GitHub APK delivery supports manual or MDM distribution. Play distribution, managed enterprise rollout, signing-key rotation and a production backend deployment need their own qualified configuration; this workflow does not claim to establish them.
+1 -1
View File
@@ -10,7 +10,7 @@ review were outside the completed scope. This is not production release clearanc
| Delayed password completion after logout/server change | Medium | Fixed origin/generation binding and deferred cookie adoption; HTTP and native delayed-response cases pass. |
| Sample release cleartext traffic | Low | Debug-only HTTP environments/manifest override; packaged release denies cleartext. |
| Auth0 provider failure preserving local QQQ login | Low | Local sign-out is independent, bounded and idempotent; failed-provider, restart and remount cases pass. |
| Unauthenticated external scan broadcasts | Low | Open. Trusted scanner integration awaits the separate Zebra SDK license decision; no production ingress change installed. |
| Unauthenticated external scan broadcasts | Low | Trusted optional EMDK adapter and production receiver removal verified by actual DEX inspection,51 scanner JVM tests and an ordinary-UID/distinct-signer TC22 case: eight forged broadcasts cause zero process/record mutations; manual positive control succeeds. Actual physical-trigger positive control remains required before closing the complete scanner acceptance; normal operator installation is unchanged. |
| Legacy Auth0 callback crossing server origins | Low | Origin-bound callback/exchange lifecycle, one-use exchange and discarded-session cleanup; HTTP/native cases pass. |
| Sample session backup | Low | No-backup storage, disabled backups and explicit cloud/transfer exclusions; native storage and packaged release checks pass. |
+30
View File
@@ -1,5 +1,35 @@
# Session state
## October 31 — final SDK/navigation candidate verified for publication
All three Android Back handlers and the toolbar arrow now use one live Main-owned drawer-first action, preserving standalone fallbacks. The native regression first proved a real process-level bypass opening Leave behind the drawer; the corrected case passes with one actual Android Back, preserved process/draft and zero cancellations. It also covers seven other overlay/focus gates. Current source passes127 JVM tests,34 TC22 shared cases in115.717s and the additional overlay case in17.948s. A distinct-signer ordinary-UID test passes against the exact current QA APK: eight forged broadcasts cause zero extra process steps/records, while a manual positive control saves through the real API. Actual physical trigger proof is still false/pending; the barcode-visibility question is unanswered.
All four APKs pass fresh DEX, manifest, native-hash and packaged-notice inspection. Independent review of the final SDK/navigation patch found no concrete defects requiring changes. See [exact hashes and evidence boundaries](TRUSTED-SCANNER-ACCEPTANCE.md). The final code is ready for an explicitly authorized signed source checkpoint; hosted client checks must run after publication. Coreecf has18 hosted successes and published serverbf174a6 has8. New whole-handheld viewing scope clarification is pending; current capture is QQQ Mobile only. Normal operator app/server and emulator remain unchanged by these tests.
## October 31 — coherent backend accepted and published; final Android gate running
The owner explicitly authorized signed commits and publication after automatic review required direct commit approval. Core `ecf0f550` is published to draftPR1334. Server code `b14d925` plus acceptance documentation `bf174a6` is published to draftPR6. Fresh37 server/five demo tests, strict runtime provenance (195 JARs/2,440 unique QQQ classes/zero duplicates, Guava33.7.2-jre/H2 2.2.224), actual API1.9/mobile1.1 discovery and all15 browser tests pass. The normal operator runtime remains unchanged. Hosted checks are running; one Windows job failed to download PSScriptAnalyzer before project analysis and received one scoped retry.
The trusted scanner now also blocks physical input behind drawer/Settings/Diagnostics/Account/leave overlays and lost window focus, with a live callback guard. Two new coordinator tests and one production-widget QA native regression are added. Auth0 stable3.0.0 and Coil3.0.4 replace inherited prereleases; two native raster/SVG/error rendering cases are added. These source changes require the new combined build and device run. The first build correctly rejected stale notices: AboutLibraries10.9.2 did not declare its dependency graph as an incremental input. A lazy resolved-coordinate input now invalidates its standard generator; strict license/version gates remain enabled. The combined gate is running again. Earlier APK hashes and hardware evidence do not qualify these changes.
## October 31 — trusted scanning packages verified; runtime provenance caught a mixed dependency
The approved optional EMDK implementation passes all 49 scanner JVM tests, debug/release lint, debug/unsigned-release/E2E assembly, and production-path QA/probe assembly. The separately signed adversarial probe has a verified distinct test certificate; its temporary private key was deleted. These results do not establish physical trigger or injection resistance: disposable TC22 acceptance is next, and the normal operator APK remains unchanged.
Exact-source core `ecf0f550a0357cec6387c2b54efca128a405a629` passes the full selected dependency closure: 4,507 tests discovered, zero failures/errors, 16 existing skips, plus the configured coverage/static gates. Scanner server `a8ed108` passes 37 tests and its demo passes five. Runtime provenance then correctly rejected an old RC.20 backend dependency in the demo classpath. Browser and QA fixture execution are held while the consumer dependency management is corrected; these test passes are not qualification of that mixed runtime. The verifier remains strict, and no shared artifact or normal server was replaced.
## October 31 final client audit and isolated integration follow-up
The owner explicitly accepted Zebra's EMDK terms and selected native trusted scanning. The optional adapter is now being implemented; the earlier 32-test result does not qualify that new hardware path. Wrapper checkpoint `7e0cbee` and the five verified client security fixes `df532ff` are signed local commits, not yet pushed. Core's local `331a99d` additionally pins patched Guava; its full selected dependency-closure suite and verification analyses are running in a fresh private repository. On Windows, only the inspected cosmetic shell coverage-summary execution is omitted because of quoting; actual coverage checks and static analyses remain enabled. Hosted Ubuntu retains that summary.
The completed remediation batch passes 109 JVM tests, scanner lint with zero errors/warnings, debug/native-test and unsigned sample release assembly. All 32 selected TC22 native cases pass in 117.325 seconds, including ten new authentication lifecycle tests. Actual packaged sample release controls deny cleartext and backup and include all ten cloud/transfer exclusions. Five audit fixes have regression evidence; trusted scan ingress remains open pending the separate EMDK license decision. The new debug APK `8c4d3886...c512a` is not installed over the operator app. See [the scoped review checkpoint](SECURITY-REVIEW-2026-10-31.md).
The deep client audit completed and sealed against clean published `d1de0bf`, with one medium and five low findings. The medium finding is delayed password-session adoption after logout/server switch, including retained original-server models. Low findings cover sample release cleartext, failed Auth0 local sign-out, unauthenticated exported scan input, stale legacy Auth0 callbacks, and sample session backup. Source fixes are being prepared with delayed-response/provider and host regression tests; none is closed merely from editing source. Static coverage is partial, with external provider/server/dependency implementations and bundled binary implementation gaps excluded. Real configured Authentik and physical operator acceptance remain separate.
An isolated `fix/hosted-native-ci` checkout keeps this work separate from the audited source and installed operator app. Core draft PR #1334 publishes reviewed native API 1.9, credential-safe logging and explicit-null parser fixes at `00f6c49ef4c21bedb7c62ccad535bd6de1008948`. Focused source gates passed 154 tests with two pre-existing disabled cases, plus a separate 17-test null/HTTP gate. Server `a8ed108` canonical UTC migration passes 37 root and five demo tests and every hosted check.
A fresh private Maven repository and unique CI-only core version are qualifying the fixture from those exact commits; no shared artifacts or operator runtime are replaced. Hosted acceptance must include the nullable admin form and session-monitor scenarios. A failure-only focus diagnostic preserves the five-second guard and actual Back input; the cause of the hosted focus loss remains unproven.
## October 31 unconfirmed scan exit correction
The operator reported Leave process permanently disabled with “The last scan is not confirmed.” The existing JVM harness reproduced that settled missing receipts remained a hard navigation block. A new regression failed before the correction. Settled unconfirmed scans now show an explicit warning that they may already be saved; Stay retains the original retry event, while Leave process uses the existing server cancellation and clears the event after cancellation succeeds. Failed cancellation retains the event and exposes a separate, explicit Leave anyway choice that clears local state without another request; the dialog warns that the server process may still run and saved changes are not undone. Active requests and pending photos remain hard blockers, and receipt validation is unchanged. Added JVM guards/cancellation/fallback cases and a native lost-response/malformed-receipt/Stay/failed-cancellation/explicit-exit regression. Final bounded verification passed all 46 mobile JVM tests, scanner lint/debug APK and sample/test APK assembly in 18 seconds. The TC22 follow-up passed all 21 selected native cases in 53.482 seconds, including the new lost-response/failed-cancellation/explicit-exit case. The operator update is being coordinated separately; actual configured Authentik acceptance remains pending.
+2
View File
@@ -2,6 +2,8 @@
Review date: 2026-10-31. This is source, cached dependency metadata and APK evidence, followed by authorized vector provenance remediation. It is not license clearance or a final release SBOM. No Gradle build was run for this review. The source was changing during inspection; final release outputs must be reviewed again after dependency and asset remediation.
The inventory below is historical checkpoint evidence. The current candidate replaces Auth0's beta with stable 3.0.0 and Coil's RC with 3.0.4; see [the dependency decision](DEPENDENCY-DECISIONS.md). Auth0's tagged stable LICENSE matches the bundled MIT text byte-for-byte, including its copyright. Post-change packaged graph/notice qualification is pending: an initial build correctly rejected stale generated Auth0 metadata, exposing a missing dependency-graph input in the pinned AboutLibraries collector. The build wiring now tracks resolved coordinates; a subsequent successful build and APK inspection must establish the new inventory rather than reusing these old counts or hashes.
## Evidence boundary
- Repository baseline: `6574ec4991dbb859b60c3a0be462af96918aee32`, plus the working changes.
+7
View File
@@ -1,5 +1,12 @@
# TODO
- [x] Verify five client audit fixes: password/legacy callback origin binding, independent local Auth0 sign-out, sample release HTTPS and no-backup session storage. The batch passes 109 JVM tests and all 32 selected TC22 native cases; packaged release controls are inspected. See [review scope and evidence](SECURITY-REVIEW-2026-10-31.md).
- [ ] Close the remaining unauthenticated scanner-ingress finding using actual EMDK hardware/adversarial acceptance. The approved implementation passes 49 scanner JVM tests, lint and all package builds; no production input change is installed yet.
- [x] Correct the demo's mixed RC.20 runtime and test-engine dependencies; qualify exact-source core `ecf0f550a0357cec6387c2b54efca128a405a629` with server `b14d925b741e815d1cfa4f62301f19207f25b03f`. Fresh37 server/five demo, strict provenance and all15 nullable-admin/session-monitor/browser cases pass. Core full closure:4,507 discovered/16 existing skips/zero failures/errors.
- [ ] Diagnose hosted Back-test window-focus failures from bounded state flags while retaining real Back injection and the focus guard.
- [x] Qualify stable Auth03.0.0/Coil3.0.4, notice-generator incremental inputs and overlay/window-focus scan gating:127 JVM,34 shared TC22 cases, an additional eight-overlay native regression, ordinary-UID injection rejection and all package/lint/notice gates pass. See [candidate acceptance](TRUSTED-SCANNER-ACCEPTANCE.md).
- [ ] Resolve entire-handheld versus QQQ-app viewing scope. Current capture covers QQQ Mobile; it must not be represented as Android Home/other-app capture. Implement and qualify full-device consent/capture if that is required.
- [x] Verify the unconfirmed-scan exit correction: 46 mobile JVM tests and all 21 selected TC22 native cases pass, including failed cancellation and explicit Leave anyway with preserved receipt/retry identity and in-flight/photo guards.
- [ ] Finish the operator walkthrough of the unconfirmed-scan exit correction on the normal scanner installation.
+34
View File
@@ -0,0 +1,34 @@
# Trusted scanner candidate acceptance
Verified October 31, 2026. This is a source-review and development-package checkpoint, not a production release or actual physical-trigger acceptance.
## Executed checks
- **127 JVM tests** pass: core30, mobile46, scanner51. Debug/release lint and debug, unsigned release, E2E, QA and instrumentation package gates pass.
- **34 native TC22 cases** pass in115.717 seconds, covering navigation, input, authentication lifecycle, host storage, and real PNG/SVG/error rendering.
- **One additional TC22 overlay case** passes in17.948 seconds through the actual Main and scanner widget. Drawer Back, Settings, Diagnostics, Account, Leave, Sign out, a separate window and a parent host gate reject hidden input; dismissal preserves the current draft and permits exactly one new event. The hardware driver in this case is synthetic. One real Android Back closes the drawer without opening Leave, changing the process or cancelling it.
- **A separate ordinary-UID injection case** passes against the production-path QA APK. A distinct-signer application with no permissions sends eight forged broadcasts. A temporary observer proves the actual scan action/payload route; after that observer is removed, no forged scan creates a process step or record. One explicit manual positive control receives a real server receipt. The EMDK driver reaches ready; no physical trigger is inferred.
- Every native request in that server acceptance matches the documented semantic API. The isolated fixture uses core `ecf0f550a0357cec6387c2b54efca128a405a629`, server `b14d925b741e815d1cfa4f62301f19207f25b03f`, UI API1.9 and mobile-scanners1.1. Its separate server37/demo5/browser15 and runtime-provenance gates pass.
Independent review of the final SDK/navigation source found no concrete defects requiring changes. The earlier deep audit remains a scoped historical report for `d1de0bf`; it does not certify every later change or external implementation.
## Inspected artifacts
| Artifact | SHA-256 |
| --- | --- |
| Debug APK | `89e74b7af682f199996322fd271115db1ffaa99e12c2b153a53815025b616e9a` |
| Unsigned release APK | `0e412ea62a8cbbb5f867b70eeff6a1ffb3b5bdab5199576e387684cb517f365b` |
| E2E APK | `80ef10d1109a355fba82f99b48a92d6b1b04907247adc093e0664b5e59f5b694` |
| Production-path QA APK | `cfff47b5a03d578a21fa69583111b23c225a4998f0cf795502d379dcb93ab017` |
All four actual APKs contain131 notice entries, nine complete license texts and the eight reviewed native libraries with unchanged hashes. Auth0 resolves to3.0.0 and all seven Coil modules to3.0.4; no Google identity or Credential Manager dependencies were introduced. Each packaged notice resource equals its generated report byte-for-byte.
Standard SDK DEX inspection finds no defined EMDK SDK implementation classes. Debug, release and QA exclude the synthetic scan action, source label and receiver definitions; E2E contains the expected positive control. All variants disable backup with ten cloud/transfer exclusions. Release also disables debugging and cleartext traffic. The vendor library remains optional and device-provided.
The notice collector now declares its resolved dependency coordinates as an incremental input. Changed versions regenerate standard notices; a subsequent unchanged QA build reuses collection/preparation while the strict runtime/version/license verifier still executes. No license allowlist or runtime-absence check was weakened.
## Remaining acceptance
Actual Code128/QR trigger-to-save, leading zeros, distinct repeated trigger events, recreation/background/resume and scanner-ownership restoration remain physical checks. Ordinary-UID rejection alone could not establish that the hardware feature works.
Configured Authentik, actual camera/GPS/customer assets, coherent optional Record Files deployment, final hosted-client checks and preserving operator upgrade remain separate gates. Current viewing captures QQQ Mobile; entire-handheld viewing scope is awaiting owner clarification. Production signing/distribution is deferred to the final step. Normal operator data and application were preserved during these disposable tests; the emulator remained off.
+41
View File
@@ -0,0 +1,41 @@
# Zebra scanner input
Normal debug/release builds obtain scans through Zebra EMDK's device-provided service and private Binder callbacks. A package-targeted DataWedge broadcast is still forgeable by another installed application; destination signature checks do not establish sender identity. Production builds therefore contain no scan broadcast receiver.
The app first requires the system's `com.symbol.emdk` shared library and enabled, preinstalled `com.symbol.emdk.emdkservice/.EMDKService`. Vendor classes are loaded only after these checks, through an isolated wrapper. The optional shared-library manifest allows the same APK to install on non-Zebra devices, where explicit manual barcode entry remains available. The EMDK permission is a normal vendor permission, not a claim of signature-based authentication.
## Lifecycle and process contract
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.
Null, empty or unsuccessful data collections do not submit a scan or tear down the hardware; subsequent IDLE and pending-read state control recovery. A cancellation collection after a successful decode cannot clear its acknowledgment gate. A successful collection with multiple barcodes rejects the whole batch, shows a one-barcode warning and permits a later single scan. SDK ERROR status, service loss and exceptions still release the hardware and expose recovery. This follows Zebra's [data/status callback example](https://techdocs.zebra.com/emdk-for-android/latest/tutorial/tutbasicscanningapi/); no payload or vendor error text is logged.
Pausing, leaving, logging out or changing server disposes the widget and releases the scanner and EMDK manager. Released generations ignore late status/data callbacks, and a late asynchronous manager-open callback releases that manager immediately. Cleanup attempts every owned resource even if one operation fails; a failed release never reports scanner readiness. Errors show manual fallback without printing scan contents or SDK exception messages.
Hardware input also requires the process surface to be interactive and its window focused. Drawer opening/partial swipes, Settings, Account, Diagnostics and leave/sign-out confirmation suspend input while preserving the active process and draft. Shared navigation and the host supply a nested live availability predicate; the widget observes it to cancel/rearm and checks it again with actual window focus before accepting a callback. This prevents callbacks arriving before recomposition from submitting behind an overlay. Closing the overlay rearms when the process has no pending request. Toolbar and Android Back share the host's live drawer priority, so Back closes the drawer without opening a process-leave confirmation behind it, even when authenticated content registers its handler later. Process Leave and explicit abandonment policy remain unchanged.
Zebra documents that acquiring BarcodeManager disables DataWedge scanning and releasing it restores DataWedge. The app does not edit persistent profiles or disable DataWedge globally. Physical acceptance must verify that another DataWedge application can scan after QQQ leaves/backgrounds. See [Zebra scanner lifecycle](https://techdocs.zebra.com/emdk-for-android/latest/guide/barcode_scanning_guide/) and [runtime ownership/compatibility](https://techdocs.zebra.com/emdk-for-android/latest/guide/about/).
## SDK provenance and terms
The owner explicitly accepted Zebra's EMDK terms on October31,2026. The SDK is proprietary vendor software under the [Zebra EULA](https://techdocs.zebra.com/emdk-for-android/eula/), not an Apache/MIT dependency. This acceptance does not relicense vendor code or constitute a general license-clearance opinion.
The build uses `compileOnly("com.symbol:emdk:11.0.134")` from [Zebra's documented Maven repository](https://techdocs.zebra.com/emdk-for-android/13-0/guide/setup/). Repository access is exclusive to that exact group/artifact. `verifyEmdkSdk` checks the selected SDK in each variant's actual compile classpath before compiler/preBuild tasks, requiring version11.0.134 and SHA256 `e0c6b481c25bf641333bf644de940f1ac7f3fc4f8d5c92ba4ae62ea2252c4e36`. There is no dynamic version or checksum bypass.
The SDK implementation is not bundled. Zebra documents11/13 runtime compatibility; the inspected TC22 has runtime13.0.23.4723. Its system framework explicitly binds the named preinstalled service and registers scan callbacks through Binder. This observation is not an audit of all Zebra firmware. Final APK inspection must confirm vendor implementation classes are absent and the standard packaged runtime notices remain complete. The SDK is a build prerequisite, not a falsely labeled OSS runtime entry.
AboutLibraries10.9.2 also collects compile classpaths, so its standard exclusion pattern matches only `com.symbol:emdk`. The checksum task first rejects that artifact in every resolvable runtime classpath, including tests; an accidental runtime dependency therefore fails before packaging and cannot be hidden by the notice exclusion. No permitted-license list was broadened. The separate SDK documentation records the proprietary terms and owner acceptance.
## Test boundary
Debug, release and isolated QA builds share `src/production` and the trusted factory. QA uses the `.qa` application suffix, existing debug signing and a separate test manifest for local test HTTP. Build its instrumentation with `-PscannerTestBuildType=qa :scanner-host:assembleQa :scanner-host:assembleQaAndroidTest`. Only `e2e` (the default) and `qa` are accepted instrumentation variants. Neither option changes production signing.
Only `src/e2e` supplies the exported synthetic broadcast adapter; its application ID has the `.e2e` suffix and its source label is `e2e-fixture`. It cannot be enabled in production through an intent or server setting. Existing synthetic payloads keep their fixture action for runner compatibility. The QA variant contains no synthetic ingress, so it exercises the same scanner trust boundary without installing over the operator's app.
`ScannerInputSessionTest` covers duplicate reads, no-decode/empty/canceled completion, multiple-barcode rejection/retry, IDLE-before-data ordering, raw-string preservation, acknowledgment gating, pause/resume generations, late callbacks, reconnect after busy acquisition, vendor pending-read state, read/cancel failures and failed-release readiness. It does not exercise Zebra hardware. Required physical acceptance remains: Code128/QR with leading zeros, repeated triggers, rotation during initial acquisition as well as ready state, background/resume, logout/leave, uncertain acknowledgment retry and DataWedge ownership restoration. A separately signed ordinary application must also send forged scan broadcasts to the foreground production-path app while the backend confirms zero resulting scan records. Shell UID injection alone is not that test.
Implementation/source tests and physical/packaged verification have separate evidence; do not claim the latter from a passing synthetic E2E fixture.
The standalone QA instrumentation class `io.qrun.mobile.scanner.ScannerOverlayTest` exercises the production widget and shared navigation with a private in-process fake driver and synthetic repository. It needs no backend configuration, probe app or operator scan, and is restricted to the `.qa` target. It tests overlay/focus suppression, callbacks before recomposition, preserved manual draft and dismissal recovery; it is not EMDK hardware acceptance. Its empty instrumentation Activity is declared only in the QA manifest and is not exported. The production widget has no intent, server setting or runtime toggle for injecting this driver.
+6 -4
View File
@@ -1,8 +1,8 @@
[versions]
agp = "8.6.0"
coilCompose = "3.0.0-rc01"
coilNetworkOkhttp = "3.0.0-rc01"
coilSvg = "3.0.0-rc01"
coilCompose = "3.0.4"
coilNetworkOkhttp = "3.0.4"
coilSvg = "3.0.4"
datastorePreferences = "1.1.1"
kotlin = "1.9.0"
coreKtx = "1.13.1"
@@ -20,7 +20,7 @@ runtimeAndroid = "1.7.2"
uiAndroid = "1.6.6"
material3Android = "1.3.0"
junitJupiter = "5.8.1"
auth0 = "3.0.0-beta.0"
auth0 = "3.0.0"
uiToolingPreviewAndroid = "1.6.6"
navigationCompose = "2.8.3"
okhttp = "4.12.0"
@@ -29,8 +29,10 @@ jsonTest = "20240303"
coroutinesTest = "1.9.0"
aboutLibraries = "10.9.2"
appauth = "0.11.1"
zebraEmdk = "11.0.134"
[libraries]
zebra-emdk = { module = "com.symbol:emdk", version.ref = "zebraEmdk" }
appauth = { module = "net.openid:appauth", version.ref = "appauth" }
aboutlibraries-compose = { module = "com.mikepenz:aboutlibraries-compose", version.ref = "aboutLibraries" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
@@ -55,6 +55,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
@@ -63,6 +64,9 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.QTopAppBar
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.LeaveProcessDialog
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.LocalBusinessNavigationEnabled
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.LocalOpenDiagnostics
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalNavigationBackAction
import androidx.compose.runtime.CompositionLocalProvider
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.MenuDialog
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
@@ -143,8 +147,22 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
val topLevelAppState = remember(qViewModel, scope) { TopLevelAppState(coroutineScope = scope, qViewModel = qViewModel) }
val settingsOpen = remember { mutableStateOf(false) }
val diagnosticsOpen = remember { mutableStateOf(false) }
val accountOpen = remember { mutableStateOf(false) }
val drawerWidth = remember { mutableIntStateOf(0) }
val parentInputAllowed = LocalProcessInputAllowed.current
val openDiagnostics = LocalOpenDiagnostics.current
val businessReady = LocalBusinessNavigationEnabled.current && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested
fun drawerActive(): Boolean
{
val drawer = topLevelAppState.navDrawerState
return topLevelAppState.drawerRequested || drawer.isOpen || drawer.targetValue == DrawerValue.Open ||
drawer.isAnimationRunning ||
// Material's closed anchor is minus the measured width, including partial swipes.
(drawerWidth.intValue > 0 && drawer.currentOffset > -drawerWidth.intValue.toFloat())
}
val navigateBack: () -> Unit = {
if(drawerActive()) topLevelAppState.closeNavDrawer() else qViewModel.qNavigator.popStack()
}
LaunchedEffect(qViewModel.isAuthenticated, qViewModel.isLogoutRequested, businessReady)
{
if(!businessReady) topLevelAppState.closeNavDrawer()
@@ -163,7 +181,7 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
{
ModalNavigationDrawer(drawerState = topLevelAppState.navDrawerState,
drawerContent = {
ModalDrawerSheet {
ModalDrawerSheet(Modifier.onSizeChanged { drawerWidth.intValue = it.width }) {
Column(Modifier.fillMaxSize()) {
Text(title, Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
@@ -195,6 +213,7 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
}
}
}) {
CompositionLocalProvider(LocalNavigationBackAction provides navigateBack) {
Scaffold(
Modifier.fillMaxSize(),
topBar = {
@@ -203,7 +222,8 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
navMenuCallback = topLevelAppState::openNavDrawer,
defaultTitle = title,
qNavigator = qViewModel.qNavigator,
showNetworkDialog = showNetworkDialog
showNetworkDialog = showNetworkDialog,
accountOpenState = accountOpen
)
}
) { innerPadding ->
@@ -213,19 +233,25 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
.fillMaxSize()
)
{
QAuthenticationWrapper(
qViewModel,
Modifier
.fillMaxSize(),
qNavigator = qViewModel.qNavigator,
)
CompositionLocalProvider(LocalProcessInputAllowed provides {
parentInputAllowed() && businessReady && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested &&
!drawerActive() && drawerWidth.intValue > 0 && topLevelAppState.navDrawerState.currentOffset.isFinite() &&
!settingsOpen.value && !diagnosticsOpen.value && !accountOpen.value &&
qViewModel.qNavigator.leaveRequest == null
}) {
QAuthenticationWrapper(
qViewModel,
Modifier
.fillMaxSize(),
qNavigator = qViewModel.qNavigator,
)
}
}
}
}
// This is composed after the process content so drawer Back closes only the drawer.
BackHandler(enabled = topLevelAppState.navDrawerState.isOpen || topLevelAppState.navDrawerState.targetValue == DrawerValue.Open) {
topLevelAppState.closeNavDrawer()
}
// Authenticated content can register later; both handlers use the same live priority.
BackHandler(enabled = drawerActive(), onBack = navigateBack)
if(settingsOpen.value) MenuDialog(qViewModel, title, settingsOpen)
if(diagnosticsOpen.value) AlertDialog(onDismissRequest = { diagnosticsOpen.value = false },
title = { Text("Diagnostics") }, text = { Text("Server: ${qViewModel.environment.baseUrl}\nApplication version: ${QViewModel.applicationVersion}") },
@@ -3,6 +3,7 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
@@ -18,3 +19,9 @@ val LocalCustomWidgetRenderer = staticCompositionLocalOf<@Composable (WidgetBloc
{
{ _, _, _ -> false }
}
/** Live surface availability for asynchronous input; nested hosts must preserve the parent's gate. */
val LocalProcessInputAllowed = compositionLocalOf<() -> Boolean> { { true } }
/** Host surface priority shared by toolbar and system Back, independent of callback registration order. */
internal val LocalNavigationBackAction = compositionLocalOf<(() -> Unit)?> { null }
@@ -35,6 +35,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
import com.kingsrook.qqq.frontend.android.mobileapp.ui.QNavigator
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalNavigationBackAction
import com.kingsrook.qqq.frontend.android.mobileapp.ui.apps.QAppHome
import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullSizedAllCenteredColumn
import com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.QProcessHome
@@ -54,7 +55,10 @@ fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, q
val loadStates = listOf(qInstanceLoadState)
val navController = qNavigator?.navController ?: rememberNavController()
BackHandler(enabled = qNavigator?.atHome == false) { qNavigator?.popStack() }
val hostBackAction = LocalNavigationBackAction.current
BackHandler(enabled = qNavigator?.atHome == false) {
if(hostBackAction != null) hostBackAction() else qNavigator?.popStack()
}
LoadStateView(
loadStates,
@@ -38,6 +38,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
@@ -48,6 +49,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.core.content.ContextCompat.getSystemService
import com.kingsrook.qqq.frontend.android.mobileapp.ui.QNavigator
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalNavigationBackAction
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
/***************************************************************************
@@ -55,9 +57,10 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
***************************************************************************/
@OptIn(ExperimentalMaterial3Api::class) // needed for TopAppBar
@Composable
fun QTopAppBar(qViewModel: QViewModel, defaultTitle: String = "A QQQ Android Application", navMenuCallback: (() -> Unit)? = null, qNavigator: QNavigator? = null, showNetworkDialog: Boolean = true)
fun QTopAppBar(qViewModel: QViewModel, defaultTitle: String = "A QQQ Android Application", navMenuCallback: (() -> Unit)? = null, qNavigator: QNavigator? = null, showNetworkDialog: Boolean = true, accountOpenState: MutableState<Boolean>? = null)
{
val accountOpen = remember { mutableStateOf(false) }
val accountOpen = accountOpenState ?: remember { mutableStateOf(false) }
val hostBackAction = LocalNavigationBackAction.current
val authenticated = qViewModel.isAuthenticated && !qViewModel.isLogoutRequested
val businessReady = authenticated && LocalBusinessNavigationEnabled.current
LaunchedEffect(qViewModel.isLogoutRequested)
@@ -78,7 +81,7 @@ fun QTopAppBar(qViewModel: QViewModel, defaultTitle: String = "A QQQ Android App
IconButton(onClick = navMenuCallback, modifier = Modifier.testTag("topBar.menuIcon"))
{ Icon(Icons.Default.Menu, "Open navigation menu") }
if(businessReady && qNavigator?.atHome == false)
IconButton(onClick = qNavigator::popStack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Go Back") }
IconButton(onClick = hostBackAction ?: qNavigator::popStack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Go Back") }
}
},
actions = {
@@ -53,6 +53,7 @@ import com.kingsrook.qqq.frontend.android.core.model.processes.QJobComplete
import com.kingsrook.qqq.frontend.android.core.model.processes.QJobError
import com.kingsrook.qqq.frontend.android.core.model.processes.QJobRunning
import com.kingsrook.qqq.frontend.android.core.model.processes.QJobStarted
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalNavigationBackAction
import com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.components.ProcessComponentEditForm
import com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.components.ProcessComponentViewForm
import com.kingsrook.qqq.frontend.android.mobileapp.ui.processes.components.ProcessComponentHTML
@@ -81,7 +82,8 @@ fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifie
}
val processMetaDataLoadState = processViewModel.processMetaDataLoadState
BackHandler { qViewModel.qNavigator.popStack() }
val hostBackAction = LocalNavigationBackAction.current
BackHandler { if(hostBackAction != null) hostBackAction() else qViewModel.qNavigator.popStack() }
val initLoadState = processViewModel.initLoadState
val topLevelErrorState = processViewModel.topLevelErrorState
val loadStates = listOf(processMetaDataLoadState, initLoadState, topLevelErrorState)
@@ -38,15 +38,19 @@ class TopLevelAppState(
{
var navDrawerState: DrawerState by mutableStateOf(DrawerState(DrawerValue.Closed))
private set
var drawerRequested: Boolean by mutableStateOf(false)
private set
/***************************************************************************
**
***************************************************************************/
fun openNavDrawer()
{
drawerRequested = true
coroutineScope.launch()
{
navDrawerState.open()
try { navDrawerState.open() }
finally { drawerRequested = false }
}
}
@@ -55,6 +59,7 @@ class TopLevelAppState(
***************************************************************************/
fun closeNavDrawer()
{
drawerRequested = false
coroutineScope.launch()
{
navDrawerState.close()
@@ -77,4 +82,4 @@ class TopLevelAppState(
return this.qViewModel.isAuthenticated
}
}
}
@@ -0,0 +1,173 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package com.kingsrook.qqq.sampleandroidmobileapp
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PixelMap
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModelStore
import androidx.test.platform.app.InstrumentationRegistry
import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock
import com.kingsrook.qqq.frontend.android.mobileapp.R
import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore
import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.ImageWidgetBlock
import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import java.io.File
import kotlin.math.abs
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** Decode and draw local bytes through the production widget, including its configured SVG decoder. */
class CoilRenderingTests : BaseTest()
{
@get:Rule val ui = createComposeRule()
private val files = mutableListOf<File>()
private val models = ViewModelStore()
private val visible = mutableStateOf(true)
private val path = mutableStateOf("")
private var contentInstalled = false
private val left = Color(0xFFC92B1F)
private val right = Color(0xFF04AACC)
@After fun disposeFixtures()
{
if(contentInstalled)
{
ui.runOnIdle { visible.value = false; models.clear() }
ui.waitForIdle()
}
files.forEach { it.delete() }
}
@Test fun rasterBytesDecodeAndRenderAtSemanticDimensions()
{
val png = fixture(".png")
val bitmap = Bitmap.createBitmap(32, 16, Bitmap.Config.ARGB_8888)
try
{
for(y in 0 until bitmap.height)
{
for(x in 0 until bitmap.width)
{
bitmap.setPixel(x, y, if(x < 16) 0xFFC92B1F.toInt() else 0xFF04AACC.toInt())
}
}
png.outputStream().use { assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) }
}
finally { bitmap.recycle() }
render(png)
assertDecodedRegions()
}
@Test fun svgBytesRenderAndInvalidSourceShowsTheProductionErrorPainter()
{
val svg = fixture(".svg").apply {
writeText("""<svg xmlns="http://www.w3.org/2000/svg" width="32" height="16" viewBox="0 0 32 16"><rect width="16" height="16" fill="#c92b1f"/><rect x="16" width="16" height="16" fill="#04aacc"/></svg>""")
}
render(svg)
assertDecodedRegions()
val invalid = fixture(".png").apply { writeText("This is a deliberately invalid local image.") }
ui.runOnIdle { path.value = Uri.fromFile(invalid).toString() }
ui.waitUntil(10000)
{
val actual = pixels("production-image")
val expected = pixels("error-reference")
actual.width == expected.width && actual.height == expected.height &&
samplePoints(actual).all { (x, y) -> sameColor(actual[x, y], expected[x, y]) }
}
val actual = pixels("production-image")
val expected = pixels("error-reference")
assertEquals(expected.width, actual.width)
assertEquals(expected.height, actual.height)
assertTrue("Invalid bytes must replace the previous SVG with the configured error drawable",
samplePoints(actual).all { (x, y) -> sameColor(actual[x, y], expected[x, y]) })
}
private fun render(file: File)
{
val repository = object : QQQRepository by SampleAppMockQQQRepository()
{
override fun getURI(path: String): String
{
check(path.startsWith("file://")) { "Image qualification accepts only its private local fixtures" }
return path
}
}
lateinit var model: QViewModel
ui.runOnIdle()
{
model = QViewModel(repository, MockPreferencesDataStore())
models.put("image-rendering", model)
path.value = Uri.fromFile(file).toString()
}
ui.setContent()
{
if(visible.value)
{
Column()
{
Box(Modifier.testTag("production-image"))
{
ImageWidgetBlock(WidgetBlockParameters(
WidgetBlock("image", "IMAGE", styles = mutableMapOf("width" to 64, "height" to 32),
values = mutableMapOf("path" to path.value)), qViewModel = model))
}
Image(painterResource(R.drawable.ic_broken_image), contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.size(64.dp, 32.dp).testTag("error-reference"))
}
}
}
contentInstalled = true
}
private fun assertDecodedRegions()
{
ui.onNodeWithTag("production-image").assertWidthIsEqualTo(64.dp).assertHeightIsEqualTo(32.dp)
ui.waitUntil(10000)
{
val rendered = pixels("production-image")
sameColor(rendered[rendered.width / 4, rendered.height / 2], left) &&
sameColor(rendered[rendered.width * 3 / 4, rendered.height / 2], right)
}
val rendered = pixels("production-image")
assertTrue("Decoded fixture must render both distinct source regions",
sameColor(rendered[rendered.width / 4, rendered.height / 2], left) &&
sameColor(rendered[rendered.width * 3 / 4, rendered.height / 2], right))
}
private fun fixture(suffix: String): File =
File.createTempFile("qqq-coil-fixture-", suffix, InstrumentationRegistry.getInstrumentation().targetContext.cacheDir)
.also { files.add(it) }
private fun pixels(tag: String): PixelMap = ui.onNodeWithTag(tag).captureToImage().toPixelMap()
private fun samplePoints(image: PixelMap): List<Pair<Int, Int>> =
(1..7).flatMap { x -> (1..7).map { y -> image.width * x / 8 to image.height * y / 8 } }
private fun sameColor(actual: Color, expected: Color): Boolean =
abs(actual.red - expected.red) < 0.025f && abs(actual.green - expected.green) < 0.025f &&
abs(actual.blue - expected.blue) < 0.025f && abs(actual.alpha - expected.alpha) < 0.025f
}
@@ -41,7 +41,26 @@ class NavigationTests : BaseTest()
{
ui.waitForIdle()
// Dialog dismissal is asynchronous at the Android window layer, even after Compose settles.
ui.waitUntil(5000) { ui.activity.hasWindowFocus() }
try
{
ui.waitUntil(5000) { ui.activity.hasWindowFocus() }
}
catch(error: androidx.compose.ui.test.ComposeTimeoutException)
{
var windowState = "unavailable"
// Collect only state flags on the main thread; never include UI text or device/account identifiers.
runCatching {
ui.activityRule.scenario.onActivity { activity ->
val power = activity.getSystemService(android.os.PowerManager::class.java)
val keyguard = activity.getSystemService(android.app.KeyguardManager::class.java)
windowState = "interactive=${power.isInteractive}, keyguard=${keyguard.isKeyguardLocked}, " +
"deviceLocked=${keyguard.isDeviceLocked}, activityFocus=${activity.hasWindowFocus()}, " +
"decorFocus=${activity.window.decorView.hasWindowFocus()}, lifecycle=${activity.lifecycle.currentState}, " +
"finishing=${activity.isFinishing}, destroyed=${activity.isDestroyed}"
}
}
throw AssertionError("Back requires a focused Activity: $windowState", error)
}
InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(KeyEvent.KEYCODE_BACK)
ui.waitForIdle()
}
+69 -2
View File
@@ -1,3 +1,5 @@
import java.security.MessageDigest
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -10,12 +12,30 @@ aboutLibraries {
fetchRemoteLicense = false
fetchRemoteFunding = false
includePlatform = false
filterVariants = arrayOf("debug", "release", "e2e")
filterVariants = arrayOf("debug", "release", "e2e", "qa")
// The plugin also scans compile classpaths. This SDK is not redistributed;
// verifyEmdkSdk separately forbids it in every runtime classpath.
exclusionPatterns = listOf(Regex("^com\\.symbol:emdk$").toPattern())
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
allowedLicenses = arrayOf("Apache-2.0", "MIT", "MPL-2.0", "BSD-2-Clause", "Apache-2.0 WITH LLVM-exception")
prettyPrint = true
}
// AboutLibraries 10.9.2 marks the collected graph @Internal. Without this input,
// changing a selected dependency can reuse the previous dependency_cache.json.
tasks.withType<com.mikepenz.aboutlibraries.plugin.AboutLibrariesCollectorTask>().configureEach {
inputs.property("resolvedDependencyCoordinates", providers.provider {
listOf("debug", "release", "e2e", "qa").flatMap { variant ->
listOf("CompileClasspath", "RuntimeClasspath").flatMap { suffix ->
val configurationName = "$variant$suffix"
configurations.getByName(configurationName).incoming.resolutionResult.allComponents.map { component ->
"$configurationName:${component.id.displayName}"
}
}
}.sorted()
})
}
val scannerVersionCode = providers.gradleProperty("scannerVersionCode").orElse("1").get().toInt()
val scannerVersionName = providers.gradleProperty("scannerVersionName").orElse("0.1.0").get()
require(scannerVersionCode in 1..2100000000) { "scannerVersionCode must be a positive Android version code" }
@@ -25,6 +45,8 @@ require(Regex("[0-9]+\\.[0-9]+\\.[0-9]+(?:-[A-Za-z0-9.-]+)?").matches(scannerVer
val signingVariables = listOf("SCANNER_KEYSTORE", "SCANNER_STORE_PASSWORD", "SCANNER_KEY_ALIAS", "SCANNER_KEY_PASSWORD")
val signingValues = signingVariables.associateWith { providers.environmentVariable(it).orNull }
val hasSigningValues = signingValues.values.any { !it.isNullOrBlank() }
val scannerTestBuildType = providers.gradleProperty("scannerTestBuildType").orElse("e2e").get()
require(scannerTestBuildType in setOf("e2e", "qa")) { "scannerTestBuildType must be e2e or qa" }
require(!hasSigningValues || signingValues.values.all { !it.isNullOrBlank() }) {
"Release signing requires all four SCANNER signing environment variables"
}
@@ -61,8 +83,20 @@ android {
applicationIdSuffix = ".e2e"
matchingFallbacks += "debug"
}
create("qa") {
initWith(getByName("debug"))
applicationIdSuffix = ".qa"
matchingFallbacks += "debug"
}
}
testBuildType = scannerTestBuildType
sourceSets.getByName("debug").java.srcDir("src/production/java")
sourceSets.getByName("release").java.srcDir("src/production/java")
sourceSets.getByName("qa").java.srcDir("src/production/java")
if(scannerTestBuildType == "qa") {
sourceSets.getByName("androidTestQa").java.srcDir("src/qaAndroidTest/java")
sourceSets.getByName("androidTestQa").manifest.srcFile("src/qaAndroidTest/AndroidManifest.xml")
}
testBuildType = "e2e"
buildFeatures { compose = true; buildConfig = true }
composeOptions { kotlinCompilerExtensionVersion = "1.5.1" }
compileOptions {
@@ -74,6 +108,7 @@ android {
}
dependencies {
compileOnly(libs.zebra.emdk)
implementation(libs.aboutlibraries.compose)
implementation(project(":qqq:android:core"))
implementation(project(":qqq:android:mobileapp"))
@@ -100,6 +135,38 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
}
// The vendor SDK is compile-only, supplied at runtime by Zebra firmware. Verify
// its independently reviewed bytes before any variant compiler consumes them.
val verifyEmdkSdk = tasks.register("verifyEmdkSdk") {
doLast {
configurations.filter { it.isCanBeResolved && it.name.endsWith("RuntimeClasspath") }.forEach { runtime ->
val vendorRuntime = runtime.incoming.artifactView {
componentFilter {
it is org.gradle.api.artifacts.component.ModuleComponentIdentifier && it.group == "com.symbol" && it.module == "emdk"
}
}.artifacts.artifacts
check(vendorRuntime.isEmpty()) { "Zebra EMDK must remain compile-only; found in ${runtime.name}" }
}
listOf("debug", "release", "e2e", "qa").forEach { variant ->
val artifacts = configurations.getByName("${variant}CompileClasspath").incoming.artifactView {
componentFilter {
it is org.gradle.api.artifacts.component.ModuleComponentIdentifier && it.group == "com.symbol" && it.module == "emdk"
}
}.artifacts.artifacts
check(artifacts.size == 1) { "Expected one Zebra EMDK compile-only SDK for $variant" }
val artifact = artifacts.single()
val id = artifact.id.componentIdentifier as org.gradle.api.artifacts.component.ModuleComponentIdentifier
check(id.version == "11.0.134") { "Unexpected Zebra EMDK SDK version for $variant" }
val digest = MessageDigest.getInstance("SHA-256").digest(artifact.file.readBytes())
.joinToString("") { "%02x".format(it) }
check(digest == "e0c6b481c25bf641333bf644de940f1ac7f3fc4f8d5c92ba4ae62ea2252c4e36") {
"Zebra EMDK SDK checksum differs from the reviewed 11.0.134 artifact"
}
}
}
}
tasks.matching { it.name.startsWith("compile") || it.name == "preBuild" }.configureEach { dependsOn(verifyEmdkSdk) }
// AboutLibraries generates/parses notices. Its strict mode rejects unapproved license
// IDs, but does not reject an omitted dependency or empty license text. Validate the
// standard report against Gradle's resolved runtime before any variant is packaged.
@@ -0,0 +1,48 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
internal const val HARDWARE_SCAN_SOURCE = "e2e-fixture"
/** Disposable E2E package only. No debug/release source set contains this ingress. */
internal fun createScannerDriver(context: Context): ScannerDriver = object : ScannerDriver
{
private var listener: ScannerDriver.Listener? = null
private var registered = false
private var pending = false
private val receiver = object : BroadcastReceiver()
{
override fun onReceive(context: Context?, intent: Intent?)
{
if(!pending || intent?.action != "io.qrun.mobile.scanner.SCAN") return
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?.idle()
}
}
override fun open(listener: ScannerDriver.Listener)
{
this.listener = listener
ContextCompat.registerReceiver(context, receiver, IntentFilter("io.qrun.mobile.scanner.SCAN").apply {
addCategory(Intent.CATEGORY_DEFAULT)
}, ContextCompat.RECEIVER_EXPORTED)
registered = true
listener.idle()
}
override fun isReadPending() = pending
override fun read() { pending = true }
override fun cancelRead() { pending = false; listener?.idle() }
override fun close()
{
listener = null
pending = false
if(registered) { context.unregisterReceiver(receiver); registered = false }
}
}
@@ -4,10 +4,15 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="com.symbol.emdk.permission.EMDK" />
<queries>
<package android:name="com.symbol.emdk.emdkservice" />
</queries>
<application android:allowBackup="false" android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:usesCleartextTraffic="false"
android:icon="@drawable/ic_scanner"
android:label="QQQ Mobile" android:theme="@android:style/Theme.Material.Light.NoActionBar">
<uses-library android:name="com.symbol.emdk" android:required="false" />
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.photos"
android:exported="false" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/photo_paths" />
@@ -4,6 +4,7 @@ package io.qrun.mobile.scanner
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibilityException
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.LocalOpenDiagnostics
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed
import kotlinx.coroutines.CancellationException
import android.net.ConnectivityManager
import android.net.Network
@@ -46,6 +47,7 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
var connection by remember(server) { mutableStateOf("Checking") }
var checkedAt by remember(server) { mutableStateOf("not checked") }
var showDiagnostics by remember(server) { mutableStateOf(false) }
val parentInputAllowed = LocalProcessInputAllowed.current
var diagnosticText by remember(server) { mutableStateOf("") }
val context = LocalContext.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
@@ -112,7 +114,9 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
}
Box(Modifier.weight(1f))
{
if(compatible) CompositionLocalProvider(LocalOpenDiagnostics provides { diagnosticText = runtime.diagnostics.text(); showDiagnostics = true })
if(compatible) CompositionLocalProvider(
LocalOpenDiagnostics provides { diagnosticText = runtime.diagnostics.text(); showDiagnostics = true },
LocalProcessInputAllowed provides { parentInputAllowed() && !showDiagnostics })
{ content(brand, runtime.diagnostics, runtime.compatibility, onChangeServer) }
else Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
@@ -0,0 +1,157 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
/** Device boundary; implementations deliver callbacks on the main thread. */
internal interface ScannerDriver
{
interface Listener
{
fun idle()
fun scanned(barcode: String, symbology: String)
fun noData(multipleBarcodes: Boolean)
fun failed()
}
fun open(listener: Listener)
fun isReadPending(): Boolean
fun read()
fun cancelRead()
fun close()
}
/** One visible, resumed scanner owns the hardware; released generations cannot submit scans. */
internal class ScannerInputSession(
private val createDriver: () -> ScannerDriver?,
private val onScan: (String, String) -> Unit,
private val onStatus: (String, Boolean) -> Unit,
private val canAcceptInput: () -> Boolean = { true },
)
{
private var driver: ScannerDriver? = null
private var generation = 0L
private var active = false
private var accepting = false
private var idle = false
private var armed = false
private var releaseFailed = false
private var readNotice: String? = null
fun setActive(value: Boolean)
{
if(active == value) return
active = value
generation++
release()
if(!value) return
if(releaseFailed) return
val current = try { createDriver() } catch(exception: Exception) { null }
if(current == null)
{
onStatus("Hardware scanning is unavailable. Enter a barcode and submit it manually.", false)
return
}
driver = current
readNotice = null
val token = generation
onStatus("Connecting to scanner…", false)
try
{
current.open(object : ScannerDriver.Listener
{
private fun isCurrent() = active && generation == token && driver === current
override fun idle()
{
if(!isCurrent()) return
try
{
// A read can finish without data (for example, trigger release or timeout).
if(!current.isReadPending()) armed = false
idle = true
arm()
}
catch(exception: Exception) { fail() }
}
override fun scanned(barcode: String, symbology: String)
{
if(!isCurrent() || !accepting || !armed || !canAcceptInput()) return
armed = false
accepting = false
readNotice = null
onStatus("Waiting for scan confirmation…", false)
onScan(barcode, symbology)
// IDLE may precede data. Stop any read armed in that interval before accepting more input.
try { if(driver === current && current.isReadPending()) current.cancelRead() }
catch(exception: Exception) { fail() }
}
override fun noData(multipleBarcodes: Boolean)
{
if(!isCurrent() || !accepting) return
// Cancellation/empty collections are ordinary read completion. Only IDLE
// and the driver's pending state may rearm; never release the hardware here.
if(multipleBarcodes)
{
readNotice = "Multiple barcodes were rejected. Scan one barcode at a time."
onStatus(requireNotNull(readNotice), false)
}
}
override fun failed() { if(isCurrent()) fail() }
})
}
catch(exception: Exception) { fail() }
}
fun reconnect()
{
if(!active || releaseFailed) return
setActive(false)
setActive(true)
}
fun setAccepting(value: Boolean)
{
accepting = value
if(!value && armed)
{
armed = false
try { driver?.cancelRead() } catch(exception: Exception) { fail() }
}
if(value) arm()
}
private fun arm()
{
val current = driver ?: return
if(!active || !accepting || !idle || armed || !canAcceptInput()) return
try
{
if(current.isReadPending()) return
armed = true
idle = false
current.read()
if(driver === current && armed) onStatus(readNotice ?: "Scanner ready. Press the handheld trigger.", false)
}
catch(exception: Exception) { fail() }
}
private fun fail()
{
generation++
val released = release()
onStatus(if(released) "Hardware scanning is unavailable. Reconnect the scanner or enter a barcode manually."
else "Scanner release could not be confirmed. Restart QQQ Mobile before using the scanner.", released)
}
private fun release(): Boolean
{
val previous = driver
driver = null
idle = false
armed = false
return try { previous?.close(); true }
catch(exception: Exception)
{
releaseFailed = true
onStatus("Scanner release could not be confirmed. Restart QQQ Mobile before using the scanner.", false)
false
}
}
}
@@ -1,10 +1,7 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -16,6 +13,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -24,29 +22,35 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessAcknowledgment
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed
const val SCAN_ACTION = "io.qrun.mobile.scanner.SCAN"
/** A server-defined SCANNER widget consumes typed DataWedge extras only while visible. */
/** A server-defined SCANNER widget accepts device-owned callbacks only while visible and resumed. */
@Composable
fun ScannerWidget(parameters: WidgetBlockParameters, modifier: Modifier, disabled: Boolean)
internal fun ScannerWidget(parameters: WidgetBlockParameters, modifier: Modifier, disabled: Boolean,
driverFactory: (Context) -> ScannerDriver? = ::createScannerDriver)
{
val context = LocalContext.current
val view = LocalView.current
val windowFocused = LocalWindowInfo.current.isWindowFocused
val inputAllowed = LocalProcessInputAllowed.current
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
var manual by rememberSaveable { mutableStateOf("") }
var notice by remember { mutableStateOf("") }
var scannerStatus by remember { mutableStateOf("") }
var canReconnect by remember { mutableStateOf(false) }
val currentDisabled by rememberUpdatedState(disabled)
val metadataResult = runCatching { ScannerMetaData.parse(parameters.widgetBlock.values) }
if(metadataResult.isFailure)
@@ -56,70 +60,60 @@ fun ScannerWidget(parameters: WidgetBlockParameters, modifier: Modifier, disable
}
val scannerMetaData = metadataResult.getOrThrow()
val process = parameters.qViewModel?.activeProcessViewModel
fun processReady() = process != null && parameters.qViewModel?.activeProcessViewModel === process &&
!currentDisabled && !process.cancelling && !process.waitingOnBackend && process.pendingStepValues == null
fun inputReady() = inputAllowed() && view.hasWindowFocus() &&
lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) && processReady()
val ready = windowFocused && inputAllowed() && processReady()
val currentInputReady by rememberUpdatedState<() -> Boolean>(::inputReady)
LaunchedEffect(parameters.values["acknowledgedEventId"]) { notice = "" }
/** Each new physical scan has an identity; retries are handled by ProcessViewModel. */
fun submit(barcode: String, symbology: String, source: String)
fun submit(barcode: String, symbology: String, source: String): Boolean
{
if(currentDisabled || process?.pendingStepValues != null)
if(process == null)
{
notice = "This scanner control requires an active process."
return false
}
if(!inputReady())
{
notice = "Wait for the previous scan to be confirmed."
return
return false
}
if(barcode.isEmpty() || barcode.length > scannerMetaData.maxLength)
{
notice = "Scan must contain between 1 and ${scannerMetaData.maxLength} characters."
return
return false
}
notice = "Sending scan…"
val event = ScanEvent(barcode, symbology, source)
if(process == null)
{
notice = "This scanner control requires an active process."
return
}
process.runStep(event.values(), ProcessAcknowledgment("acknowledgedEventId", event.eventId,
requiredValues = mapOf("scanSaved" to true), nonEmptyStringFields = setOf("scanId")))
// runStep can reject local metadata/validation before accepting an event.
// A synchronous test repository can also finish and acknowledge it immediately.
val accepted = process.pendingStepValues?.get("eventId") == event.eventId ||
process.processValues["acknowledgedEventId"] == event.eventId
if(!accepted) notice = "Scan was not submitted. Review the process message and try again."
return accepted
}
val currentSubmit by rememberUpdatedState<(String, String, String) -> Unit>(::submit)
DisposableEffect(context, lifecycle)
val currentSubmit by rememberUpdatedState<(String, String, String) -> Boolean>(::submit)
val input = remember(context, process, driverFactory)
{
var registered = false
val receiver = object : BroadcastReceiver()
{
override fun onReceive(receiverContext: Context?, intent: Intent?)
{
if(intent?.action != SCAN_ACTION) return
if(intent.getStringExtra("com.symbol.datawedge.decode_mode") == "multiple" || intent.hasExtra("com.symbol.datawedge.barcodes"))
{
notice = "Multiple barcode decoding is not supported. Configure DataWedge for a single barcode."
return
}
val barcode = intent.getStringExtra("com.symbol.datawedge.data_string") ?: return
val symbology = intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN"
val source = intent.getStringExtra("com.symbol.datawedge.source") ?: "scanner"
currentSubmit(barcode, symbology, source)
}
}
fun syncRegistration()
{
val resumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
if(resumed && !registered)
{
ContextCompat.registerReceiver(context, receiver, IntentFilter(SCAN_ACTION).apply { addCategory(Intent.CATEGORY_DEFAULT) }, ContextCompat.RECEIVER_EXPORTED)
registered = true
}
else if(!resumed && registered)
{
context.unregisterReceiver(receiver)
registered = false
}
}
ScannerInputSession({ driverFactory(context) },
{ barcode, symbology -> currentSubmit(barcode, symbology, HARDWARE_SCAN_SOURCE) },
{ message, reconnect -> scannerStatus = message; canReconnect = reconnect },
canAcceptInput = { currentInputReady() })
}
SideEffect { input.setAccepting(ready) }
DisposableEffect(input, lifecycle)
{
fun syncRegistration() { input.setActive(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) }
val observer = LifecycleEventObserver { _, _ -> syncRegistration() }
lifecycle.addObserver(observer)
syncRegistration()
onDispose { lifecycle.removeObserver(observer); if(registered) context.unregisterReceiver(receiver) }
onDispose { lifecycle.removeObserver(observer); input.setActive(false) }
}
Column(modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
@@ -131,11 +125,15 @@ fun ScannerWidget(parameters: WidgetBlockParameters, modifier: Modifier, disable
Text("Saved scan ${parameters.values["scanId"] ?: ""}")
}
if(notice.isNotEmpty()) Text(notice)
OutlinedTextField(manual, { manual = it }, label = { Text(scannerMetaData.manualLabel) }, enabled = !disabled, singleLine = true,
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 = !disabled && manual.isNotEmpty(), onClick = {
focusManager.clearFocus(); keyboard?.hide(); submit(manual, "MANUAL", "manual")
Button(enabled = ready && manual.isNotEmpty(), onClick = {
focusManager.clearFocus(); keyboard?.hide()
if(submit(manual, "MANUAL", "manual")) manual = ""
}) { Text(scannerMetaData.submitLabel) }
}
}
@@ -0,0 +1,150 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.content.Context
import android.os.Handler
import android.os.Looper
import com.symbol.emdk.EMDKManager
import com.symbol.emdk.EMDKResults
import com.symbol.emdk.barcode.BarcodeManager
import com.symbol.emdk.barcode.Scanner
import com.symbol.emdk.barcode.StatusData
/** Loaded only after checking Zebra's device-provided library and preinstalled service. */
internal class ZebraScannerDriver(private val context: Context) : ScannerDriver
{
companion object
{
// EMDK supports one manager per application, including a bind that has not completed yet.
private var managerClaimed = false
}
private val main = Handler(Looper.getMainLooper())
private var listener: ScannerDriver.Listener? = null
private var manager: EMDKManager? = null
private var scanner: Scanner? = null
private var closed = false
private var opening = false
private var ownsClaim = false
private val dataListener = Scanner.DataListener { collection ->
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")
}
}
}
}
private val statusListener = Scanner.StatusListener { status ->
main.post {
if(!closed)
{
if(status.state == StatusData.ScannerStates.IDLE) listener?.idle()
else if(status.state == StatusData.ScannerStates.ERROR) listener?.failed()
}
}
}
override fun open(listener: ScannerDriver.Listener)
{
check(!closed && this.listener == null)
this.listener = listener
if(managerClaimed) { listener.failed(); return }
managerClaimed = true
ownsClaim = true
opening = true
val result = try { EMDKManager.getEMDKManager(context, object : EMDKManager.EMDKListener
{
override fun onOpened(opened: EMDKManager)
{
main.post {
opening = false
if(closed)
{
// The asynchronous bind can complete after the widget has already left.
try { opened.release(); releaseClaim() }
catch(exception: Exception) { android.util.Log.w("Scanner", "Late scanner release could not be confirmed") }
}
else
{
manager = opened
try
{
val barcodeManager = opened.getInstance(EMDKManager.FEATURE_TYPE.BARCODE) as BarcodeManager
val device = barcodeManager.getDevice(BarcodeManager.DeviceIdentifier.DEFAULT)
scanner = device
device.addDataListener(dataListener)
device.addStatusListener(statusListener)
device.triggerType = Scanner.TriggerType.HARD
device.enable()
}
catch(exception: Exception) { this@ZebraScannerDriver.listener?.failed() }
}
}
}
override fun onClosed()
{
main.post {
opening = false
if(closed) releaseClaim() else this@ZebraScannerDriver.listener?.failed()
}
}
}) }
catch(exception: Exception)
{
opening = false
releaseClaim()
throw exception
}
if(result.statusCode != EMDKResults.STATUS_CODE.SUCCESS)
{
opening = false
releaseClaim()
listener.failed()
}
}
override fun isReadPending(): Boolean = scanner?.isReadPending ?: false
override fun read() { requireNotNull(scanner).read() }
override fun cancelRead() { scanner?.let { if(it.isReadPending) it.cancelRead() } }
override fun close()
{
if(closed) return
closed = true
listener = null
val device = scanner
val owner = manager
scanner = null
manager = null
var failure: Exception? = null
fun attempt(action: () -> Unit) { try { action() } catch(exception: Exception) { failure = exception } }
if(device != null)
{
attempt { device.removeDataListener(dataListener) }
attempt { device.removeStatusListener(statusListener) }
attempt { if(device.isReadPending) device.cancelRead() }
attempt { device.release() }
}
if(owner != null)
{
attempt { owner.release(EMDKManager.FEATURE_TYPE.BARCODE) }
attempt { owner.release() }
}
if(!opening && failure == null) releaseClaim()
failure?.let { throw it }
}
private fun releaseClaim()
{
if(ownsClaim) { ownsClaim = false; managerClaimed = false }
}
}
@@ -0,0 +1,27 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.content.ComponentName
import android.content.Context
import android.content.pm.ApplicationInfo
internal const val HARDWARE_SCAN_SOURCE = "zebra-emdk"
/** Never load optional vendor classes on other devices or trust an ordinary installed lookalike. */
internal fun createScannerDriver(context: Context): ScannerDriver?
{
val application = context.applicationContext
val packages = application.packageManager
return try
{
if(packages.systemSharedLibraryNames?.contains("com.symbol.emdk") != true) return null
val service = packages.getServiceInfo(ComponentName("com.symbol.emdk.emdkservice",
"com.symbol.emdk.emdkservice.EMDKService"), 0)
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
if(!service.enabled || !service.applicationInfo.enabled || service.applicationInfo.flags and systemFlags == 0) return null
Class.forName("io.qrun.mobile.scanner.ZebraScannerDriver").getDeclaredConstructor(Context::class.java)
.newInstance(application) as ScannerDriver
}
catch(exception: Exception) { null }
catch(error: LinkageError) { null }
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- Isolated production-path acceptance, never the operator's normal package. -->
<queries><package android:name="io.qrun.mobile.scanner.probe" /></queries>
<application android:usesCleartextTraffic="true" android:label="QQQ Mobile QA"
tools:replace="android:usesCleartextTraffic,android:label">
<!-- Empty host for isolated production-widget instrumentation; not externally launchable. -->
<activity android:name="androidx.activity.ComponentActivity" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<package android:name="io.qrun.mobile.scanner.probe" />
</queries>
</manifest>
@@ -0,0 +1,299 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.app.KeyguardManager
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.PowerManager
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.net.URI
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/** Real production scanner factory + separate ordinary UID. Only the disposable .qa host is permitted. */
@RunWith(AndroidJUnit4::class)
class ScannerInjectionTest
{
@get:Rule val ui = createAndroidComposeRule<ScannerActivity>()
private val client = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build()
private lateinit var origin: String
private lateinit var adminCookie: String
private val probePackage = "io.qrun.mobile.scanner.probe"
@Test fun unrelatedUidCannotSubmitAProductionScan()
{
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
check(context.packageName == "io.qrun.mobile.scanner.qa" && BuildConfig.BUILD_TYPE == "qa")
assertEquals("QA must compile the production factory", "zebra-emdk", HARDWARE_SCAN_SOURCE)
check(android.os.Build.VERSION.SDK_INT >= 28)
val packages = context.packageManager
val probe = packages.getPackageInfo(probePackage, PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_PERMISSIONS)
val target = packages.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
val probeUid = requireNotNull(probe.applicationInfo).uid
assertTrue("Probe must have an ordinary unrelated UID", probeUid >= 10000 && probeUid != context.applicationInfo.uid)
assertTrue("Probe may not hold target or network permissions", probe.requestedPermissions.isNullOrEmpty())
fun signers(info: android.content.pm.PackageInfo) = requireNotNull(info.signingInfo).apkContentsSigners
.map { MessageDigest.getInstance("SHA-256").digest(it.toByteArray()).toList() }.toSet()
assertTrue("Root must re-sign the probe with a distinct throwaway test key", signers(probe).intersect(signers(target)).isEmpty())
val configFile = File(context.filesDir, "qa-injection.json")
val config = JSONObject(configFile.readText())
configFile.delete()
val expectedBarcode = if(config.has("expectedBarcode"))
{
val value = config.get("expectedBarcode")
check(value is String && value.length in 1..4096 && value.all { it.code in 32..126 })
value
}
else null
val server = URI(config.getString("serverUrl"))
check(server.scheme == "http" && server.host == "127.0.0.1" && server.port > 0 &&
server.userInfo == null && server.query == null && server.fragment == null && server.path == "/")
origin = server.toString().trimEnd('/')
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().assertIsDisplayed().assertIsEnabled().performClick()
waitFor("An administrator must approve this device before you can run mobile apps.")
val device = DeviceCredentials(context, "$origin/")
adminCookie = client.newCall(Request.Builder().url("$origin/qqq/v1/manageSession")
.header("Authorization", Credentials.basic("admin", config.getString("adminPassword")))
.post("{}".toRequestBody("application/json".toMediaType())).build()).execute().use {
assertEquals(200, it.code)
it.headers("Set-Cookie").first { cookie -> cookie.startsWith("sessionUUID=") }.substringBefore(';')
}
val launch = process("mobileDeviceApprove", "/init", JSONObject())
val id = launch.getString("processUUID")
val applied = process("mobileDeviceApprove", "/$id/step/input", JSONObject().put("deviceId", device.deviceId))
if(applied.optString("nextStep") == "result") process("mobileDeviceApprove", "/$id/step/result", JSONObject())
ui.onNodeWithText("Check approval").performScrollTo().performClick()
waitForTag("appHome.rowForApp:mobileScanApp")
ui.onNodeWithTag("appHome.rowForApp:mobileScanApp").performClick()
waitForTag("appHome.rowForProcess:mobileScanCapture")
ui.onNodeWithTag("appHome.rowForProcess:mobileScanCapture").performClick()
waitFor("Scanner ready. Press the handheld trigger.")
val control = "manual-control-${UUID.randomUUID()}"
ui.onNodeWithText("Manual barcode").performScrollTo().performTextReplacement(control)
ui.onNodeWithText("Manual barcode").performImeAction()
ui.onNodeWithText("Submit barcode").performScrollTo().performClick()
waitFor("Saved scan ", substring = true)
waitFor("Scanner ready. Press the handheld trigger.")
val before = scans()
assertEquals(1, before.count { it.optString("barcode") == control && it.optString("scanSource") == "manual" })
foreground(context)
val marker = "probe-${UUID.randomUUID()}"
val routed = java.util.concurrent.CopyOnWriteArrayList<Intent>()
val delivered = CountDownLatch(4)
// Route control in instrumentation only; it observes payloads and NEVER invokes a process/driver.
val observer = object : BroadcastReceiver()
{
override fun onReceive(receiverContext: Context?, intent: Intent?)
{
if(intent?.getStringExtra("com.symbol.datawedge.data_string")?.startsWith("$marker-route-") == true)
{
routed.add(Intent(intent))
delivered.countDown()
}
}
}
instrumentation.sendStatus(0, Bundle().apply { putString("qqqStage", "injection-baseline") })
ContextCompat.registerReceiver(context, observer, IntentFilter("io.qrun.mobile.scanner.SCAN").apply {
addCategory(Intent.CATEGORY_DEFAULT)
}, ContextCompat.RECEIVER_EXPORTED)
try
{
emitProbe(context, "$marker-route", probeUid)
assertTrue("Actual SCAN payload must reach the route observer", delivered.await(10, TimeUnit.SECONDS))
assertEquals((0 until 4).map { "$marker-route-$it" }.toSet(), routed.map { it.getStringExtra("com.symbol.datawedge.data_string") }.toSet())
assertTrue(routed.all { it.action == "io.qrun.mobile.scanner.SCAN" &&
it.getStringExtra("com.symbol.datawedge.label_type") == "LABEL-TYPE-CODE128" &&
it.getStringExtra("com.symbol.datawedge.decode_mode") == "single" })
foreground(context)
}
finally { context.unregisterReceiver(observer) }
// The route observer is absent during the actual negative attempt.
emitProbe(context, "$marker-negative", probeUid)
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3)
while(System.nanoTime() < deadline) { foreground(context); Thread.sleep(100) }
val after = scans()
assertEquals("Unrelated-UID broadcasts must not create any scan", before.size, after.size)
assertFalse(after.any { it.optString("barcode").startsWith(marker) })
waitFor("Scanner ready. Press the handheld trigger.")
foreground(context)
instrumentation.sendStatus(0, Bundle().apply {
putString("qqqStage", "injection-complete")
putInt("qqqProbeUid", probeUid)
putInt("qqqTargetUid", context.applicationInfo.uid)
putInt("qqqForgedScanCount", 8)
})
if(expectedBarcode != null)
{
val events = mutableSetOf<String>()
physicalScan(context, device.deviceId, expectedBarcode, "initial", after.size, events)
ui.activityRule.scenario.recreate()
physicalScan(context, device.deviceId, expectedBarcode, "recreated", after.size + 1, events)
// Exercise only this disposable Activity's real stop/resume lifecycle, without touching other apps/settings.
ui.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
Thread.sleep(500)
ui.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
physicalScan(context, device.deviceId, expectedBarcode, "resumed", after.size + 2, events)
assertEquals(3, events.size)
instrumentation.sendStatus(0, Bundle().apply {
putString("qqqStage", "hardware-complete")
putInt("qqqHardwareScans", 3)
putString("qqqHardwareEventIds", events.joinToString(","))
})
}
}
private fun physicalScan(context: Context, deviceId: String, expectedBarcode: String, phase: String,
expectedBefore: Int, events: MutableSet<String>)
{
waitFor("Scanner ready. Press the handheld trigger.")
foreground(context)
val before = scans()
assertEquals("No extra scan may occur between operator phases", expectedBefore, before.size)
val priorIds = before.map { it.getString("id") }.toSet()
val priorEvents = before.map { it.getString("eventId") }.toSet()
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120)
fun remainingMillis() = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(1)
InstrumentationRegistry.getInstrumentation().sendStatus(0, Bundle().apply { putString("qqqStage", "hardware-$phase-ready") })
var saved: JSONObject? = null
while(saved == null)
{
assertTrue("Timed out waiting for the operator's physical trigger", System.nanoTime() < deadline)
foreground(context)
val current = scans(remainingMillis().coerceAtMost(20_000))
val added = current.filter { it.getString("id") !in priorIds }
assertTrue("One trigger must not create multiple records", added.size <= 1 && current.size <= before.size + 1)
if(added.isNotEmpty()) saved = added.single() else Thread.sleep(200)
}
val row = requireNotNull(saved)
assertTrue("Hardware barcode must exactly match the configured synthetic value", row.getString("barcode") == expectedBarcode)
assertEquals("zebra-emdk", row.getString("scanSource"))
assertTrue("Hardware scan must belong to the approved QA device", row.getString("deviceId") == deviceId)
assertEquals("scanner", row.getString("userId"))
val event = row.getString("eventId")
UUID.fromString(event)
assertTrue("Each physical trigger needs its own event identity", event !in priorEvents && events.add(event))
waitFor("Saved scan ${row.getString("id")}", timeoutMillis = remainingMillis())
waitFor("Scanner ready. Press the handheld trigger.", timeoutMillis = remainingMillis())
assertEquals(before.size + 1, scans(remainingMillis().coerceAtMost(20_000)).size)
foreground(context)
InstrumentationRegistry.getInstrumentation().sendStatus(0, Bundle().apply { putString("qqqStage", "hardware-$phase-saved") })
}
private fun emitProbe(context: Context, marker: String, probeUid: Int)
{
val completed = CountDownLatch(1)
val result = AtomicReference<Bundle?>()
context.sendOrderedBroadcast(Intent("io.qrun.mobile.scanner.probe.EMIT")
.setComponent(ComponentName(probePackage, "$probePackage.ScanProbeReceiver"))
.putExtra("targetPackage", context.packageName).putExtra("marker", marker), null,
object : BroadcastReceiver()
{
override fun onReceive(receiverContext: Context?, intent: Intent?)
{
result.set(getResultExtras(false)?.apply { putInt("resultCode", resultCode) })
completed.countDown()
}
}, null, 0, null, null)
assertTrue("Separate probe must finish", completed.await(10, TimeUnit.SECONDS))
assertEquals(1, result.get()?.getInt("resultCode"))
assertEquals(probeUid, result.get()?.getInt("senderUid"))
assertEquals(4, result.get()?.getInt("sent"))
assertEquals(marker, result.get()?.getString("marker"))
}
private fun foreground(context: Context)
{
ui.activityRule.scenario.onActivity {
assertTrue("Target must stay resumed", it.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED))
assertTrue("Target must retain window focus", it.window.decorView.hasWindowFocus())
assertTrue(context.getSystemService(PowerManager::class.java).isInteractive)
assertFalse(context.getSystemService(KeyguardManager::class.java).isKeyguardLocked)
}
}
private fun scans(timeoutMillis: Long = 20_000): List<JSONObject>
{
val rows = json(Request.Builder().url("$origin/qqq/v1/table/mobileScan/query").header("Cookie", adminCookie)
.post("{}".toRequestBody("application/json".toMediaType())).build(), timeoutMillis).getJSONArray("records")
return (0 until rows.length()).map { rows.getJSONObject(it).getJSONObject("values") }
}
private fun process(name: String, suffix: String, values: JSONObject): JSONObject
{
var result = json(Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", adminCookie)
.post(MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString())
.addFormDataPart("stepTimeoutMillis", "10000").build()).build())
val processId = result.optString("processUUID")
val jobId = result.optString("jobUUID")
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30)
while(result.optString("type") in listOf("JOB_STARTED", "RUNNING"))
{
assertTrue(System.nanoTime() < deadline)
Thread.sleep(50)
result = json(Request.Builder().url("$origin/qqq/v1/processes/$name/$processId/status/$jobId").header("Cookie", adminCookie).build())
}
assertFalse(result.has("error"))
return result
}
private fun json(request: Request, timeoutMillis: Long = 20_000): JSONObject
{
val call = client.newCall(request)
call.timeout().timeout(timeoutMillis, TimeUnit.MILLISECONDS)
return call.execute().use {
assertEquals("Semantic fixture request must succeed", 200, it.code)
JSONObject(requireNotNull(it.body).string())
}
}
private fun waitFor(text: String, substring: Boolean = false, timeoutMillis: Long = 60_000)
{ ui.waitUntil(timeoutMillis) { ui.onAllNodesWithText(text, substring = substring).fetchSemanticsNodes().isNotEmpty() } }
private fun waitForTag(tag: String)
{ ui.waitUntil(60_000) { runCatching { ui.onNodeWithTag(tag).fetchSemanticsNode() }.isSuccess } }
}
@@ -0,0 +1,253 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.content.Context
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.lifecycle.viewModelScope
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.navigation.compose.rememberNavController
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.test.platform.app.InstrumentationRegistry
import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
import com.kingsrook.qqq.frontend.android.core.model.Environment
import com.kingsrook.qqq.frontend.android.core.model.metadata.*
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.*
import com.kingsrook.qqq.frontend.android.core.model.processes.*
import com.kingsrook.qqq.frontend.android.mobileapp.QMobileAppMain
import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalCustomWidgetRenderer
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import org.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlinx.coroutines.cancel
import java.util.concurrent.atomic.AtomicReference
/** Production widget and navigation surfaces; only the private hardware driver is synthetic. */
class ScannerOverlayTest
{
@get:Rule val ui = createAndroidComposeRule<ComponentActivity>()
private var previousEnvironments: List<Environment>? = null
private var fixtureModel: QViewModel? = null
@Before fun configureSyntheticEnvironment()
{
previousEnvironments = runCatching { QAppContainer.availableEnvironments }.getOrNull()
QAppContainer.availableEnvironments = listOf(Environment("QA overlay", "https://overlay.invalid/"))
}
@After fun disposeSyntheticEnvironment()
{
ui.activityRule.scenario.onActivity {
fixtureModel?.activeProcessViewModel?.reset()
fixtureModel?.viewModelScope?.cancel()
fixtureModel = null
QAppContainer.availableEnvironments = previousEnvironments ?: listOf(Environment("Unconfigured", "https://unused.invalid/"))
}
}
private class Device : ScannerDriver
{
lateinit var listener: ScannerDriver.Listener
var pending = false
var closed = false
var reads = 0
var cancels = 0
override fun open(listener: ScannerDriver.Listener) { this.listener = listener; listener.idle() }
override fun isReadPending() = pending
override fun read() { check(!closed); reads++; pending = true }
override fun cancelRead() { cancels++; pending = false; listener.idle() }
override fun close() { closed = true; pending = false }
fun emit(value: String) { pending = false; listener.scanned(value, "QRCODE"); listener.idle() }
}
@Test fun overlaysAndLostWindowFocusBlockScansWithoutLosingDraftOrTrappingResume()
{
check(InstrumentationRegistry.getInstrumentation().targetContext.packageName == "io.qrun.mobile.scanner.qa")
val repository = Repository()
val model = QViewModel(repository, MockPreferencesDataStore()).apply { supportedCustomWidgetTypes = setOf("SCANNER") }
fixtureModel = model
val devices = mutableListOf<Device>()
val driverFactory: (Context) -> ScannerDriver? = { Device().also { devices.add(it) } }
val externalDialog = mutableStateOf(false)
val parentAllowed = mutableStateOf(true)
val observedInputAllowed = AtomicReference<(() -> Boolean)?>(null)
ui.setContent {
model.setNavController(rememberNavController())
CompositionLocalProvider(
LocalProcessInputAllowed provides { parentAllowed.value },
LocalCustomWidgetRenderer provides { parameters, modifier, disabled ->
if(parameters.widgetBlock.blockType == "SCANNER") {
val availability = LocalProcessInputAllowed.current
SideEffect { observedInputAllowed.set(availability) }
ScannerWidget(parameters, modifier, disabled, driverFactory)
true
} else false
}) { QMobileAppMain(model, "Scanner overlay test", showNetworkDialog = false) }
// Deliberately outside semantic availability: proves actual window focus is independently checked.
if(externalDialog.value) AlertDialog(onDismissRequest = { externalDialog.value = false },
title = { Text("External window") }, confirmButton = {
TextButton(onClick = { externalDialog.value = false }) { Text("Close external") }
})
}
waitFor("Log In")
ui.onNodeWithText("Log In").performClick()
ui.waitUntil(5000) { model.isAuthenticated }
ui.onNodeWithTag("appHome.rowForApp:app").performClick()
ui.onNodeWithTag("appHome.rowForProcess:scan").performClick()
fun imeHidden() = ViewCompat.getRootWindowInsets(ui.activity.window.decorView)
?.isVisible(WindowInsetsCompat.Type.ime()) == false
fun ready(phase: String) {
try { ui.waitUntil(5000) { devices.lastOrNull()?.pending == true && ui.activity.hasWindowFocus() } }
catch(error: ComposeTimeoutException) {
val drawerVisible = runCatching { ui.onNodeWithTag("drawer.settings").assertIsDisplayed(); true }.getOrDefault(false)
val closeVisible = runCatching { ui.onNodeWithText("Close").assertIsDisplayed(); true }.getOrDefault(false)
var flags = "unavailable"
ui.activityRule.scenario.onActivity { activity ->
val device = devices.lastOrNull()
flags = "focus=${activity.hasWindowFocus()}, lifecycle=${activity.lifecycle.currentState}, " +
"imeHidden=${imeHidden()}, inputAllowed=${observedInputAllowed.get()?.invoke()}, " +
"driverPending=${device?.pending}, driverClosed=${device?.closed}, reads=${device?.reads}, cancels=${device?.cancels}, " +
"drivers=${devices.size}, drawerVisible=$drawerVisible, closeVisible=$closeVisible, " +
"waiting=${model.activeProcessViewModel?.waitingOnBackend}, pendingEvent=${model.activeProcessViewModel?.pendingStepValues != null}, " +
"leaveOpen=${model.qNavigator.leaveRequest != null}, parentAllowed=${parentAllowed.value}"
}
throw AssertionError("Scanner did not resume at $phase: $flags", error)
}
}
fun reject() {
val count = repository.events.size
ui.runOnIdle { devices.last().emit("blocked") }
ui.waitForIdle()
assertEquals("Overlay must not submit a step", count, repository.events.size)
}
fun resumeAndScan(phase: String) {
ready(phase)
ui.onNodeWithText("Manual barcode").assertTextContains("draft")
val count = repository.events.size
ui.runOnIdle { devices.last().emit("00123") }
ui.waitUntil(5000) { repository.events.size == count + 1 && model.activeProcessViewModel?.pendingStepValues == null }
ready("$phase-acknowledged")
}
fun seedDraft(phase: String) {
// Each successful scan submits/remounts the step; preservation applies within that step.
ready("$phase-before-overlay")
ui.onNodeWithText("Manual barcode").performTextReplacement("draft")
ui.onNodeWithText("Manual barcode").performImeAction()
ui.waitUntil(5000) { imeHidden() }
ui.onNodeWithText("Manual barcode").assertTextContains("draft")
}
ready("initial")
val initialProcess = model.activeProcessViewModel
seedDraft("drawer-back")
ui.onNodeWithTag("topBar.menuIcon").performClick()
ui.onNodeWithTag("drawer.settings").assertIsDisplayed()
reject()
ui.waitForIdle()
ui.waitUntil(5000) { imeHidden() && ui.activity.hasWindowFocus() }
InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(KeyEvent.KEYCODE_BACK)
resumeAndScan("drawer-back")
ui.onNodeWithTag("drawer.settings").assertIsNotDisplayed()
ui.runOnIdle {
assertNull("Drawer Back must not open Leave behind the drawer", model.qNavigator.leaveRequest)
assertSame(initialProcess, model.activeProcessViewModel)
assertEquals(0, repository.cancellations)
}
for(destination in listOf("settings", "diagnostics")) {
seedDraft(destination)
ui.onNodeWithTag("topBar.menuIcon").performClick()
ui.onNodeWithTag("drawer.$destination").performClick()
waitFor("Close")
reject()
ui.onNodeWithText("Close").performClick()
resumeAndScan(destination)
}
seedDraft("account")
ui.onNodeWithTag("topBar.accountIcon").performClick()
ui.onNodeWithTag("accountDialog.title").assertIsDisplayed()
reject()
ui.onNodeWithText("Close").performClick()
resumeAndScan("account")
seedDraft("leave-stay")
ui.onNodeWithContentDescription("Go Back").performClick()
waitFor("Stay")
reject()
ui.onNodeWithText("Stay").performClick()
resumeAndScan("leave-stay")
seedDraft("signout-stay")
ui.onNodeWithTag("topBar.accountIcon").performClick()
ui.onNodeWithText("Sign out").performClick()
waitFor("Stay")
reject()
ui.onNodeWithText("Stay").performClick()
resumeAndScan("signout-stay")
seedDraft("external-window")
ui.runOnIdle { externalDialog.value = true }
waitFor("Close external")
ui.waitUntil(5000) { !ui.activity.hasWindowFocus() }
reject()
ui.onNodeWithText("Close external").performClick()
resumeAndScan("external-window")
// A host-owned secondary surface blocks input even when it does not create another window.
seedDraft("host-surface")
ui.runOnIdle { parentAllowed.value = false; devices.last().emit("blocked-before-recomposition") }
ui.waitForIdle()
assertEquals(7, repository.events.size)
ui.runOnIdle { parentAllowed.value = true }
resumeAndScan("host-surface")
assertEquals(8, repository.events.size)
assertEquals(8, repository.events.map { it["eventId"] }.toSet().size)
assertTrue(repository.events.all { it["barcode"] == "00123" })
assertEquals(0, repository.cancellations)
assertTrue(model.isAuthenticated)
}
private fun waitFor(text: String) { ui.waitUntil(5000) { ui.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() } }
private class Repository : QQQRepository
{
val events = mutableListOf<Map<String, Any?>>()
var cancellations = 0
// Match decoded server JSON containers: WidgetBlock owns mutable values/styles.
private val scannerComponent = QFrontendComponent(QComponentType.WIDGET,
mutableMapOf("isAdHocWidget" to true, "blocks" to listOf(
mutableMapOf("blockId" to "scan", "blockType" to "SCANNER",
"styles" to mutableMapOf<String, Any?>(), "values" to mutableMapOf<String, Any?>()))))
private val metadata = QProcessMetaData("scan", "Scan", frontendSteps = listOf(
QFrontendStepMetaData("input", "Input", components = listOf(scannerComponent))))
override fun setEnvironment(environment: Environment) { }
override fun setSessionUUID(sessionUUID: String) { }
override fun getURI(path: String) = "https://unused.invalid/$path"
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = FullyAnonymousAuthenticationMetaData("FULLY_ANONYMOUS")
override suspend fun manageSession(body: ManageSessionRequest) = ManageSessionResponse("synthetic-overlay-session")
override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String) = QInstance(
appTree = listOf(QAppTreeNode("app", "App", QAppNodeType.APP)),
apps = mapOf("app" to QAppMetaData("app", "App", sections = listOf(QAppSection("section", "Section", processes = listOf("scan"))))),
processes = mapOf("scan" to metadata))
override suspend fun getProcessMetaData(processName: String) = metadata
override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?) = QJobComplete("synthetic-process", nextStep = "input")
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult {
events.add(values.toMap())
return QJobComplete(processUUID, mapOf("acknowledgedEventId" to values["eventId"], "scanSaved" to true, "scanId" to events.size.toString()), "input")
}
override suspend fun processCancel(processName: String, processUUID: String) { cancellations++ }
override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult = error("No jobs in this fixture")
}
}
@@ -0,0 +1,306 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ScannerInputSessionTest
{
private class Device : ScannerDriver
{
lateinit var listener: ScannerDriver.Listener
var reads = 0
var cancels = 0
var releases = 0
var pending = false
var failRead = false
var failCancel = false
var failRelease = false
override fun open(listener: ScannerDriver.Listener) { this.listener = listener }
override fun isReadPending() = pending
override fun read() { if(failRead) error("Synthetic read error"); reads++; pending = true }
override fun cancelRead() { cancels++; pending = false; if(failCancel) error("Synthetic cancel error") }
override fun close() { releases++; if(failRelease) error("Synthetic close error") }
fun idle() { listener.idle() }
fun scan(value: String = "001234567890") { pending = false; listener.scanned(value, "QRCODE") }
}
private class Fixture
{
val devices = mutableListOf<Device>()
val scans = mutableListOf<Pair<String, String>>()
val statuses = mutableListOf<String>()
val session = ScannerInputSession({ Device().also { devices.add(it) } },
{ value, type -> scans.add(value to type) }, { message, _ -> statuses.add(message) })
fun start(): Device
{
session.setAccepting(true)
session.setActive(true)
return devices.last()
}
}
@Test fun missingHardwareKeepsManualFallback()
{
val statuses = mutableListOf<String>()
val session = ScannerInputSession({ null }, { _, _ -> error("Unexpected scan") }, { message, _ -> statuses.add(message) })
session.setAccepting(true)
session.setActive(true)
assertTrue(statuses.single().contains("manually"))
}
@Test fun liveSurfaceGateRejectsCallbackBeforeRecompositionAndRearmsAfterDismissal()
{
val device = Device()
var allowed = true
val scans = mutableListOf<String>()
val session = ScannerInputSession({ device }, { value, _ -> scans.add(value) }, { _, _ -> }, { allowed })
session.setAccepting(true)
session.setActive(true)
device.idle()
allowed = false // An overlay opened; Compose has not run setAccepting(false) yet.
device.scan()
device.idle()
assertTrue(scans.isEmpty())
assertEquals(1, device.reads)
session.setAccepting(false)
allowed = true
session.setAccepting(true)
assertEquals(2, device.reads)
device.scan("00123")
assertEquals(listOf("00123"), scans)
}
@Test fun hiddenSurfaceNeverArmsEvenWhenOldReadinessFlagIsTrue()
{
val device = Device()
var allowed = false
val session = ScannerInputSession({ device }, { _, _ -> error("Unexpected scan") }, { _, _ -> }, { allowed })
session.setAccepting(true)
session.setActive(true)
device.idle()
assertEquals(0, device.reads)
allowed = true
session.setAccepting(true)
assertEquals(1, device.reads)
}
@Test fun waitsForIdleAndDoesNotDuplicateAnArmedRead()
{
val fixture = Fixture()
val device = fixture.start()
assertEquals(0, device.reads)
device.idle()
device.idle()
fixture.session.setAccepting(true)
assertEquals(1, device.reads)
}
@Test fun preservesRawStringAndWaitsForAcknowledgmentBeforeRearming()
{
val fixture = Fixture()
val device = fixture.start()
device.idle()
device.scan(" 001234567890 ")
device.idle()
device.scan("injected-duplicate")
assertEquals(listOf(" 001234567890 " to "QRCODE"), fixture.scans)
assertEquals(1, device.reads)
fixture.session.setAccepting(true)
assertEquals(2, device.reads)
}
@Test fun idleBeforeDataAlsoRearmsAfterAcknowledgment()
{
val fixture = Fixture()
val device = fixture.start()
device.idle()
device.pending = false
device.idle()
assertEquals(2, device.reads)
// Deliver the first read's data while the second read is pending.
device.listener.scanned("001234567890", "QRCODE")
assertEquals(1, device.cancels)
assertEquals(1, fixture.scans.size)
device.idle()
fixture.session.setAccepting(true)
assertEquals(3, device.reads)
}
@Test fun idleWithoutDecodedDataAllowsTheNextTrigger()
{
val fixture = Fixture()
val device = fixture.start()
device.idle()
device.pending = false
device.idle()
assertEquals(2, device.reads)
device.scan()
assertEquals(1, fixture.scans.size)
}
@Test fun explicitReconnectRecoversAfterOldAcquisitionWasBusy()
{
val fixture = Fixture()
val first = fixture.start()
fixture.session.setActive(false)
fixture.session.setActive(true)
val busy = fixture.devices.last()
busy.listener.failed()
first.idle()
fixture.session.reconnect()
val recovered = fixture.devices.last()
assertEquals(3, fixture.devices.size)
recovered.idle()
recovered.scan()
assertEquals(1, fixture.scans.size)
assertEquals(1, busy.releases)
}
@Test fun emptyOrCanceledCollectionWaitsForIdleAndAllowsNextRead()
{
val fixture = Fixture()
val device = fixture.start()
device.idle()
device.pending = false
device.listener.noData(false)
assertEquals(1, device.reads)
assertEquals(0, device.releases)
assertTrue(fixture.scans.isEmpty())
device.idle()
assertEquals(2, device.reads)
device.scan()
assertEquals(1, fixture.scans.size)
}
@Test fun canceledCollectionAfterAcceptedScanDoesNotClearAcknowledgmentGate()
{
val fixture = Fixture()
val device = fixture.start()
device.idle()
device.pending = false
device.idle()
device.listener.scanned("001234567890", "QRCODE")
device.listener.noData(false)
device.idle()
assertEquals(1, device.cancels)
assertEquals(2, device.reads)
assertEquals(0, device.releases)
assertTrue(fixture.statuses.last().contains("Waiting for scan confirmation"))
fixture.session.setAccepting(true)
assertEquals(3, device.reads)
}
@Test fun multipleBarcodesAreRejectedWithNoticeAndNextSingleScanWorks()
{
val fixture = Fixture()
val device = fixture.start()
device.idle()
device.pending = false
device.listener.noData(true)
device.idle()
assertTrue(fixture.scans.isEmpty())
assertEquals(0, device.releases)
assertEquals(2, device.reads)
assertTrue(fixture.statuses.last().contains("Multiple barcodes were rejected"))
device.scan()
assertEquals(1, fixture.scans.size)
assertTrue(fixture.statuses.last().contains("Waiting for scan confirmation"))
}
@Test fun vendorPendingReadIsNotDuplicated()
{
val fixture = Fixture()
val device = fixture.start()
device.pending = true
device.idle()
assertEquals(0, device.reads)
device.pending = false
device.idle()
assertEquals(1, device.reads)
}
@Test fun blockedWorkCancelsReadAndIgnoresLateData()
{
val fixture = Fixture()
val device = fixture.start()
device.idle()
fixture.session.setAccepting(false)
fixture.session.setAccepting(false)
device.scan()
device.idle()
assertEquals(1, device.cancels)
assertTrue(fixture.scans.isEmpty())
fixture.session.setAccepting(true)
assertEquals(2, device.reads)
}
@Test fun pauseReleasesOnceAndOldGenerationCannotAffectResumedDevice()
{
val fixture = Fixture()
val first = fixture.start()
first.idle()
fixture.session.setActive(false)
fixture.session.setActive(false)
fixture.session.setActive(true)
val second = fixture.devices.last()
first.idle()
first.scan()
first.listener.failed()
assertEquals(1, first.releases)
assertEquals(0, second.releases)
assertTrue(fixture.scans.isEmpty())
second.idle()
second.scan()
assertEquals(1, fixture.scans.size)
}
@Test fun lateReadyAfterDisposeNeverArmsOrSubmits()
{
val fixture = Fixture()
val device = fixture.start()
fixture.session.setActive(false)
device.idle()
device.scan()
assertEquals(1, device.releases)
assertEquals(0, device.reads)
assertTrue(fixture.scans.isEmpty())
}
@Test fun readFailureReleasesAndDoesNotClaimReady()
{
val fixture = Fixture()
val device = fixture.start()
device.failRead = true
device.idle()
device.idle()
fixture.session.setAccepting(true)
assertEquals(1, device.releases)
assertEquals(0, device.reads)
assertTrue(fixture.statuses.last().contains("Reconnect"))
}
@Test fun failedCancellationStillReleasesScanner()
{
val fixture = Fixture()
val device = fixture.start()
device.idle()
device.failCancel = true
fixture.session.setAccepting(false)
assertEquals(1, device.releases)
assertTrue(fixture.statuses.last().contains("Reconnect"))
}
@Test fun failedReleaseDoesNotClaimReadyOrAcquireAnotherDevice()
{
val fixture = Fixture()
val device = fixture.start()
device.idle()
device.failRelease = true
fixture.session.setActive(false)
fixture.session.setActive(true)
assertEquals(1, fixture.devices.size)
assertTrue(fixture.statuses.last().contains("release could not be confirmed"))
}
}
+3 -1
View File
@@ -9,7 +9,7 @@ License text is supplied locally. `offlineMode`, disabled remote-license lookup
Sources of supplemental notices:
- `Apache-2.0.json`: the exact Apache license retained from the pinned Google icon source in `docs/third-party/material-design-icons-LICENSE.txt`.
- `MIT.json`: the complete [Auth0 Android 3.0.0-beta.0 license](https://github.com/auth0/Auth0.Android/blob/894cdf3e27faf37e382f0689a21b486dbf15a5cf/LICENSE), including its Auth0 copyright notice. Re-review this text when adding another MIT dependency.
- `MIT.json`: the complete [Auth0 Android 3.0.0 license](https://github.com/auth0/Auth0.Android/blob/dcf37dd3c06421275dcc6cb1e2fbe792354a8a38/LICENSE), including its Auth0 copyright notice. The stable tag's text was byte-identical to the previously reviewed beta text on 2026-10-31 (SHA-256 `beae0f7843273017913b01f0fdd9fe5d63a42f9b3ca2c5cb611bffe8cd8e1cee`). Re-review this text when adding another MIT dependency.
- `Google-Material-Icons.json`: Google artwork attribution, documented integration modifications, and the full Apache license. The 2,142 resource mappings and source/file hashes are in `docs/material-icons-provenance.json`, pinned to `d7a78a2ad434d5d38e1b8708c9dd9847cc3ada9b`.
- `MPL-2.0.json`: full Mozilla Public License 2.0 from the [Public Suffix List license](https://github.com/publicsuffix/list/blob/master/LICENSE), retrieved 2026-10-31. A distinct supplemental library explains that this is the list embedded by OkHttp 4.12.0; **OkHttp itself remains Apache-2.0**. Its original embedded NOTICE is retained by APK packaging.
- `QQQ-Android.json`: the repository's project LICENSE/NOTICE. Refresh this content when those source files change.
@@ -19,6 +19,8 @@ Sources of supplemental notices:
## Build and review gates
The pinned AboutLibraries collector does not declare its resolved dependency graph as a task input. Our wiring supplies sorted resolved component coordinates from each selected variant's compile/runtime classpaths. A version or graph change therefore invalidates dependency collection, then the standard generated cache-file input invalidates notice preparation. Unchanged graphs can remain up-to-date; no generated JSON is manually patched and no verification is bypassed.
Every scanner variant runs `prepareLibraryDefinitions<Variant>` followed by `verify<Variant>ThirdPartyNotices` before resource merging. AboutLibraries strict mode rejects license IDs outside the reviewed Apache-2.0, MIT, MPL-2.0, BSD-2-Clause and Apache-2.0 WITH LLVM-exception set. The additional verification reads its standard output and compares all Gradle-resolved external runtime coordinates and versions, then requires each listed library to reference nonempty bundled license text and requires the five supplemental entries. MIT is reviewed only for Auth0, MPL only for the embedded Public Suffix List, and BSD/LLVM exceptions only for their named NDK entries; other dependencies require explicit review and suitable copyright text. The embedded-list notice must match the resolved OkHttp version, native dependencies must match the reviewed versions and expose their supplemental notices first, and project notice content must match LICENSE/NOTICE. A missing POM, empty license or stale runtime version fails packaging rather than silently omitting attribution. This is report validation, not a replacement dependency/license generator.
Unmodified standard JSON plus project LICENSE/NOTICE are copied into `scanner-host/build/reports/third-party/<variant>/` for distribution and inspection. The JSON is also packaged in the APK. Existing assemble tasks therefore enforce the gate in CI and local builds. The license screen instrumentation test uses the actual bundled notices without a server.
+1 -1
View File
@@ -2,6 +2,6 @@
"name": "MIT License (Auth0)",
"spdxId": "MIT",
"content": "The MIT License (MIT)\n\nCopyright (c) 2021 Auth0\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
"url": "https://github.com/auth0/Auth0.Android/blob/894cdf3e27faf37e382f0689a21b486dbf15a5cf/LICENSE",
"url": "https://github.com/auth0/Auth0.Android/blob/dcf37dd3c06421275dcc6cb1e2fbe792354a8a38/LICENSE",
"hash": "MIT"
}
+1
View File
@@ -0,0 +1 @@
/build
+27
View File
@@ -0,0 +1,27 @@
# Disposable scan injection probe
This test application has no network permission, target permissions, Activity, shared UID or external runtime dependency. Its exported control receiver creates new DataWedge-shaped intents under its own ordinary Android UID. It only accepts the disposable `.qa` and `.e2e` scanner package names and synthetic `probe-...` markers. It cannot target the normal scanner package.
Build the QA target against the production factory, its test APK, and the probe:
```text
gradlew --max-workers=2 --no-parallel -PscannerTestBuildType=qa :scanner-host:assembleQa :scanner-host:assembleQaAndroidTest :scanner-security-probe:assembleDebug
```
The ordinary debug key is used only to compile the probe. Before physical acceptance, the operator must re-sign a copy with a different **throwaway test-only key**. The native test rejects equal signing certificates and equal app UIDs. Do not change the normal scanner signing key, generate a release key for this test, or commit any private key/password.
Root coordinates the serial build/device lane. The physical harness requires a private JSON file with `purpose: "scanner-injection-qa"`, an explicit disposable loopback `baseUrl`, and random `scannerPassword`/`adminPassword` values. It does not create or reconfigure the server. The fixture must contain the ordinary scanner demo metadata/processes and an empty dedicated database.
```text
node scripts/ci/scanner-injection-device.mjs <TC22-serial> <private-fixture.json> <separately-signed-probe.apk>
```
The runner refuses existing QA/test/probe installations, installs only those three packages, adds one new non-rebinding reverse and removes only its own installations/reverse. It never installs, clears, targets or changes the normal scanner app or the operator's existing E2E visual demo. It does not wake/unlock the handheld or change scanner profiles, animation settings, USB debugging or Android keys.
The native test requires the real scanner to report ready, keeps the Activity resumed/focused/unlocked, and saves one manual positive control through the real semantic API. A brief instrumentation-only observer proves the exact scan action and typed payload reached the QA package; it cannot submit a scan and is unregistered before the negative attempt. Eight forged intents must produce zero additional server records and zero additional native semantic process-step requests. No screenshots or customer barcodes are captured.
Evidence is written under `scanner-host/build/native-device-qa-injection`: sanitized instrumentation, semantic operation audit and APK hashes. Without `expectedBarcode`, this is an injection-only proof and explicitly records `physicalTriggerVerified: false`.
To add real hardware acceptance in the same signed-in session, add an `expectedBarcode` string to the private fixture JSON. Use a known non-sensitive printed barcode; leading zeroes and spaces are preserved exactly. The runner prints `hardware-initial-ready`, `hardware-recreated-ready`, and `hardware-resumed-ready` status lines. At each line, the operator has up to 120 seconds to aim at that barcode and press the physical trigger **once**. No fake hardware input is injected. The second phase uses Activity recreation; the third stops/resumes only the disposable QA Activity. This does not claim a global screen-orientation/MDM test or change any system setting.
Each phase requires exactly one new `zebra-emdk` server record with the exact configured barcode, a new event UUID, the approved device/operator and the corresponding UI receipt. Full mode requires exactly four native process-step requests: one manual control and three real scans. `physicalTriggerVerified: true` is written only after all three phases and the server/API checks pass. The proof stores the expected barcode's SHA-256 instead of its raw text. Root remains responsible for preparing the non-sensitive target and operator prompts.
+22
View File
@@ -0,0 +1,22 @@
plugins {
alias(libs.plugins.android.application)
}
android {
namespace = "io.qrun.mobile.scanner.probe"
compileSdk = 35
defaultConfig {
applicationId = "io.qrun.mobile.scanner.probe"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
// Deliberately no runtime dependencies, network permission, shared UID, or target-app permissions.
// The physical harness requires a separately signed copy; it rejects the ordinary debug signer.
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:label="QQQ scan injection probe" android:allowBackup="false"
android:fullBackupContent="false" android:usesCleartextTraffic="false">
<receiver android:name=".ScanProbeReceiver" android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,49 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner.probe;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Process;
/** Headless adversarial sender: the NEW intents originate under this ordinary application's UID. */
public final class ScanProbeReceiver extends BroadcastReceiver
{
@Override
public void onReceive(Context context, Intent control)
{
if(control == null || !"io.qrun.mobile.scanner.probe.EMIT".equals(control.getAction()))
{
return;
}
String target = control.getStringExtra("targetPackage");
String marker = control.getStringExtra("marker");
if(!("io.qrun.mobile.scanner.qa".equals(target) || "io.qrun.mobile.scanner.e2e".equals(target)) ||
marker == null || !marker.matches("probe-[A-Za-z0-9-]{1,80}"))
{
setResultCode(2);
return;
}
for(int index = 0; index < 4; index++)
{
Intent forged = new Intent("io.qrun.mobile.scanner.SCAN").setPackage(target)
.addCategory(Intent.CATEGORY_DEFAULT)
.putExtra("com.symbol.datawedge.data_string", marker + "-" + index)
.putExtra("com.symbol.datawedge.label_type", "LABEL-TYPE-CODE128")
.putExtra("com.symbol.datawedge.source", index % 2 == 0 ? "scanner" : "zebra-emdk")
.putExtra("senderPackage", "com.symbol.datawedge")
.putExtra("com.symbol.datawedge.decode_mode", "single");
context.sendBroadcast(forged);
}
// Separate test observation channel, never a scanner callback or submission seam.
context.sendBroadcast(new Intent("io.qrun.mobile.scanner.probe.ECHO").setPackage(target)
.putExtra("marker", marker).putExtra("senderUid", Process.myUid()).putExtra("sent", 4));
Bundle result = new Bundle();
result.putInt("senderUid", Process.myUid());
result.putInt("sent", 4);
result.putString("marker", marker);
setResultExtras(result);
setResultCode(1);
}
}
+155
View File
@@ -0,0 +1,155 @@
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
// Explicit physical-only security test. Never installs, clears, or targets the normal/E2E operator apps.
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startSemanticApiProxy } from './semantic-api-proxy.mjs';
const [serial, fixturePath, probePath] = process.argv.slice(2);
if (!serial || !/^[A-Za-z0-9._:-]+$/.test(serial) || serial.startsWith('emulator-') || !fixturePath || !probePath || process.argv.length !== 5) {
throw new Error('Usage: node scripts/ci/scanner-injection-device.mjs <explicit-TC22-serial> <private-qa-fixture.json> <separately-signed-probe.apk>');
}
const repository = resolve(fileURLToPath(new URL('../..', import.meta.url)));
const config = JSON.parse(await readFile(resolve(fixturePath), 'utf8'));
const origin = new URL(config.baseUrl);
if (config.purpose !== 'scanner-injection-qa' || origin.protocol !== 'http:' || origin.hostname !== '127.0.0.1' ||
!origin.port || origin.pathname !== '/' || origin.username || origin.password || origin.search || origin.hash ||
typeof config.scannerPassword !== 'string' || config.scannerPassword.length < 16 ||
typeof config.adminPassword !== 'string' || config.adminPassword.length < 16) {
throw new Error('Requires an explicitly designated disposable loopback QA fixture with private random scanner/admin credentials.');
}
const physicalTriggers = Object.hasOwn(config, 'expectedBarcode');
if (physicalTriggers && (typeof config.expectedBarcode !== 'string' || !/^[\x20-\x7e]{1,4096}$/.test(config.expectedBarcode))) {
throw new Error('Optional expectedBarcode must be a nonempty printable synthetic test value; it is never trimmed or coerced.');
}
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
if (!sdk) throw new Error('ANDROID_HOME is required; the harness never changes Android user home or device keys.');
const adb = join(sdk, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
const target = 'io.qrun.mobile.scanner.qa';
const probe = 'io.qrun.mobile.scanner.probe';
const evidence = join(repository, 'scanner-host/build/native-device-qa-injection');
await mkdir(evidence, { recursive: true });
await writeFile(join(evidence, 'proof.json'), JSON.stringify({ status: 'PREPARING', physicalTriggerVerified: false }, null, 2) + '\n');
const redact = value => value.replaceAll(config.scannerPassword, '<REDACTED>').replaceAll(config.adminPassword, '<REDACTED>');
let proxy;
const stages = new Map();
let stepIds = new Set();
const nativeSteps = () => proxy.evidence().filter(call => call.android && call.method === 'POST' && stepIds.has(call.operationId)).length;
const run = (args, input) => new Promise((done, fail) => {
const child = spawn(adb, ['-s', serial, ...args], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
let output = '';
let lines = '';
const timer = setTimeout(() => { child.kill(); fail(new Error('Bounded QA adb operation timed out.')); }, args.includes('instrument') ? (physicalTriggers ? 600_000 : 300_000) : 60_000);
child.stdout.on('data', data => {
output += data;
lines += data;
const complete = lines.split(/\r?\n/);
lines = complete.pop();
for (const line of complete) {
const stage = /^INSTRUMENTATION_STATUS: qqqStage=(injection-baseline|injection-complete|hardware-(?:initial|recreated|resumed)-(?:ready|saved)|hardware-complete)$/.exec(line);
if (stage) { stages.set(stage[1], nativeSteps()); console.log(line); }
}
});
child.stderr.on('data', data => { output += data; });
child.on('error', error => { clearTimeout(timer); fail(error); });
child.on('close', code => { clearTimeout(timer); code === 0 ? done(redact(output)) : fail(new Error(redact(`QA adb operation failed (${code}): ${output}`))); });
child.stdin.end(input);
});
if ((await run(['get-serialno'])).trim() !== serial || (await run(['shell', 'getprop', 'ro.kernel.qemu'])).trim() === '1') {
throw new Error('Requires the explicitly selected physical device; no emulator is started.');
}
if (!/^zebra/i.test((await run(['shell', 'getprop', 'ro.product.manufacturer'])).trim()) ||
!/^TC22(?:$|[ _-])/i.test((await run(['shell', 'getprop', 'ro.product.model'])).trim())) {
throw new Error('This physical acceptance is limited to the approved Zebra TC22.');
}
for (const name of [target, target + '.test', probe]) {
const installed = (await run(['shell', 'pm', 'list', 'packages', '-u', name])).split(/\r?\n/).map(line => line.trim());
if (installed.includes('package:' + name)) throw new Error(`Refusing to replace or clear existing disposable package ${name}.`);
}
const apks = [
[target, join(repository, 'scanner-host/build/outputs/apk/qa/scanner-host-qa.apk')],
[target + '.test', join(repository, 'scanner-host/build/outputs/apk/androidTest/qa/scanner-host-qa-androidTest.apk')],
[probe, resolve(probePath)],
];
const buildTools = process.env.ANDROID_BUILD_TOOLS_VERSION ?? '35.0.0';
if (!/^\d+\.\d+\.\d+$/.test(buildTools)) throw new Error('ANDROID_BUILD_TOOLS_VERSION must identify an installed stable SDK toolset.');
const aapt2 = join(sdk, 'build-tools', buildTools, process.platform === 'win32' ? 'aapt2.exe' : 'aapt2');
const packageName = apk => new Promise((done, fail) => {
const child = spawn(aapt2, ['dump', 'badging', apk], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
let output = '';
const timer = setTimeout(() => { child.kill(); fail(new Error('APK identity verification timed out.')); }, 30_000);
child.stdout.on('data', data => { output += data; });
child.on('error', error => { clearTimeout(timer); fail(error); });
child.on('close', code => {
clearTimeout(timer);
const identity = /^package: name='([^']+)'/m.exec(output)?.[1];
if (code !== 0 || !identity) fail(new Error('Cannot verify APK package identity before installation.'));
else done(identity);
});
});
for (const [expected, apk] of apks) {
if (await packageName(apk) !== expected) throw new Error('APK application ID does not match its disposable allowlisted target.');
}
const artifacts = await Promise.all(apks.map(async ([applicationId, path]) => ({
applicationId, sha256: createHash('sha256').update(await readFile(path)).digest('hex'),
})));
const installedByRun = [];
let reversed = false;
let output = '';
await mkdir(evidence, { recursive: true });
try {
const api = await fetch(new URL('qqq/v1/openapi.json', origin), { signal: AbortSignal.timeout(20_000) });
if (!api.ok) throw new Error('Disposable fixture OpenAPI is unavailable.');
const contract = await api.json();
stepIds = new Set(Object.entries(contract.paths).filter(([path]) => /\/processes\/\{[^}]+\}\/\{[^}]+\}\/step\/\{[^}]+\}$/.test(path))
.map(([, value]) => value.post?.operationId).filter(Boolean));
if (stepIds.size !== 1) throw new Error('Expected exactly one documented semantic process-step operation.');
proxy = await startSemanticApiProxy(origin.toString().replace(/\/$/, ''));
await run(['reverse', '--no-rebind', `tcp:${proxy.port}`, `tcp:${proxy.port}`]);
reversed = true;
for (const [applicationId, apk] of apks) { await run(['install', apk]); installedByRun.push(applicationId); }
await run(['shell', 'run-as', target, 'mkdir', '-p', 'files']);
await run(['shell', 'run-as', target, 'sh', '-c', "'cat > files/qa-injection.json'"],
JSON.stringify({ serverUrl: `http://127.0.0.1:${proxy.port}/`, scannerPassword: config.scannerPassword, adminPassword: config.adminPassword,
...(physicalTriggers ? { expectedBarcode: config.expectedBarcode } : {}) }));
output = await run(['shell', 'am', 'instrument', '-w', '-r', '-e', 'class',
'io.qrun.mobile.scanner.ScannerInjectionTest#unrelatedUidCannotSubmitAProductionScan',
`${target}.test/androidx.test.runner.AndroidJUnitRunner`]);
await writeFile(join(evidence, 'instrumentation.txt'), output);
if (!/OK \(1 test\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output)) throw new Error('Production-path scan injection acceptance failed.');
const expectedSteps = physicalTriggers ? 4 : 1;
if (stages.get('injection-baseline') !== 1 || stages.get('injection-complete') !== 1 || nativeSteps() !== expectedSteps) {
throw new Error('Native process steps must equal the manual control plus configured operator scans; forged scans must add zero mutations.');
}
if (physicalTriggers && (stages.get('hardware-initial-saved') !== 2 || stages.get('hardware-recreated-saved') !== 3 ||
stages.get('hardware-resumed-saved') !== 4 || stages.get('hardware-complete') !== 4)) {
throw new Error('All three actual operator scans must have distinct verified receipts after initial start, recreation and resume.');
}
const semantic = proxy.verify(true);
await writeFile(join(evidence, 'proof.json'), JSON.stringify({ status: 'PASSED', artifacts, semantic, nativeProcessSteps: nativeSteps(),
expectedNativeProcessSteps: expectedSteps,
manualPositiveControls: 1, forgedScanIntents: 8, extraNativeProcessSteps: 0,
targetVariant: 'qa-production-factory', source: 'ordinary-uid-separate-signer',
physicalTriggerVerified: physicalTriggers,
physicalTriggerCount: physicalTriggers ? 3 : 0,
...(physicalTriggers ? { expectedBarcodeSha256: createHash('sha256').update(config.expectedBarcode).digest('hex'),
physicalLifecyclePhases: ['initial', 'activity-recreation', 'activity-stop-resume'] } : {}) }, null, 2) + '\n');
console.log('QA injection proof passed: separate UID/signer, actual-action route control, foreground production path, zero forged scan mutations.');
if (physicalTriggers) console.log('Actual TC22 trigger proof passed: three distinct acknowledged hardware scans across recreation and background/resume.');
} catch (error) {
await writeFile(join(evidence, 'proof.json'), JSON.stringify({ status: 'FAILED', artifacts, physicalTriggerVerified: false }, null, 2) + '\n');
throw error;
} finally {
const cleanupFailures = [];
if (proxy) await writeFile(join(evidence, 'operations.json'), JSON.stringify(proxy.evidence(), null, 2) + '\n')
.catch(() => { cleanupFailures.push('operation evidence'); });
if (installedByRun.includes(target)) await run(['shell', 'run-as', target, 'rm', '-f', 'files/qa-injection.json'])
.catch(() => { cleanupFailures.push('QA credential file'); });
for (const name of installedByRun.reverse()) await run(['uninstall', name]).catch(() => { cleanupFailures.push(name); });
if (reversed) await run(['reverse', '--remove', `tcp:${proxy.port}`]).catch(() => { cleanupFailures.push('test reverse'); });
if (proxy) await proxy.close().catch(() => { cleanupFailures.push('test proxy'); });
if (cleanupFailures.length) throw new Error(`QA cleanup/recording incomplete: ${cleanupFailures.join(', ')}`);
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
// Inspect Maven's resolved runtime closure; never resolve or substitute dependencies here.
import { createHash } from 'node:crypto';
import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { delimiter, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { execFileSync } from 'node:child_process';
const [serverArgument, repositoryArgument, coreSha, serverSha] = process.argv.slice(2);
if (!serverArgument || !repositoryArgument || !/^[a-f0-9]{40}$/.test(coreSha ?? '') ||
!/^[a-f0-9]{40}$/.test(serverSha ?? '')) {
throw new Error('Usage: verify-fixture-provenance.mjs server private-m2 core-sha server-sha');
}
const server = resolve(serverArgument);
const repository = realpathSync(repositoryArgument);
const revision = `4.1.0-ci.${coreSha}`;
const requiredModules = new Set([
'qqq-backend-core', 'qqq-backend-module-rdbms', 'qqq-middleware-javalin', 'qqq-openapi',
]);
const seenModules = new Set();
const seenClasses = new Map();
const classpath = readFileSync(join(server, 'demo/target/e2e-classpath.txt'), 'utf8').trim().split(delimiter);
if (classpath.length === 0 || classpath.some(entry => !entry.endsWith('.jar'))) {
throw new Error('Runtime classpath must contain only resolved JARs');
}
const artifacts = classpath.map(entry => {
const file = realpathSync(entry);
const relativePath = relative(repository, file);
if (isAbsolute(relativePath) || relativePath.startsWith(`..${sep}`) || relativePath === '..' || resolve(repository, relativePath) !== file) {
throw new Error('Runtime artifact is outside the private Maven repository');
}
const coordinatePath = relativePath.split(sep).join('/');
if (coordinatePath.startsWith('com/kingsrook/qqq/') && !coordinatePath.startsWith('com/kingsrook/qqq/qqq-frontend-next/')) {
const [, , , module, version] = coordinatePath.split('/');
if (version !== revision || seenModules.has(module)) {
throw new Error(`Unexpected or repeated QQQ module: ${module}/${version}`);
}
seenModules.add(module);
}
// Inspect every QQQ-owned JAR, including the frontend and scanner, for shadowed backend classes.
if (coordinatePath.startsWith('com/kingsrook/')) {
const jar = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'jar.exe' : 'jar') : 'jar';
const entries = execFileSync(jar, ['tf', file], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
for (const name of entries.split(/\r?\n/).filter(name => name.startsWith('com/kingsrook/') && name.endsWith('.class'))) {
if (seenClasses.has(name)) throw new Error(`Duplicate QQQ class: ${name}`);
seenClasses.set(name, coordinatePath);
}
}
return { path: coordinatePath, sha256: createHash('sha256').update(readFileSync(file)).digest('hex') };
});
for (const module of requiredModules) {
if (!seenModules.has(module)) throw new Error(`Required runtime module missing: ${module}`);
}
const h2Artifacts = artifacts.filter(artifact => artifact.path.startsWith('com/h2database/h2/'));
if (h2Artifacts.length !== 1 || h2Artifacts[0].path !== 'com/h2database/h2/2.2.224/h2-2.2.224.jar') {
throw new Error('Expected exactly the qualified runtime H2 2.2.224 artifact');
}
const guavaArtifacts = artifacts.filter(artifact => artifact.path.startsWith('com/google/guava/guava/'));
if (guavaArtifacts.length !== 1 || guavaArtifacts[0].path !== 'com/google/guava/guava/33.7.2-jre/guava-33.7.2-jre.jar') {
throw new Error('Expected exactly the qualified runtime Guava 33.7.2-jre artifact');
}
writeFileSync(join(server, 'demo/target/fixture-provenance.json'), `${JSON.stringify({
coreSha, serverSha, revision, artifacts, qqqClassCount: seenClasses.size, duplicateQqqClasses: 0,
}, null, 2)}\n`);
console.log(`Verified ${artifacts.length} runtime JAR hashes and ${seenClasses.size} unique QQQ classes from the pinned source closure.`);
+5
View File
@@ -16,6 +16,10 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
exclusiveContent {
forRepository { maven { url = uri("https://zebratech.jfrog.io/artifactory/EMDK-Android/") } }
filter { includeModule("com.symbol", "emdk") }
}
}
}
@@ -24,3 +28,4 @@ include(":qqq:android:core")
include(":qqq:android:mobileapp")
include(":qqqsampleandroidmobileapp")
include(":scanner-host")
include(":scanner-security-probe")