Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf82dd5d78
|
||
|
|
3325bd6061
|
||
|
|
9e06e1f3e9
|
||
|
|
c782aaaaba
|
No files matched your search
@@ -59,7 +59,7 @@ See [emulator testing](docs/EMULATOR.md) for an emulator demo, synthetic scans a
|
||||
|
||||
### Signed APK (direct install / MDM)
|
||||
|
||||
For real devices. Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). A signed 1.0.0 (versionCode 2) APK is published as a [signed preview release](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease, forge sign-in required). It was installed and launched on one MC9400, but production HTTPS enrollment, physical scanning and in-place update have not been qualified, so it is not a stable release. The development-signed and production-signed builds cannot update each other in place.
|
||||
Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). The published download remains the [signed 1.0.0 preview](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease, forge sign-in required). The signed 1.0.1 candidate has passed trusted-HTTPS enrollment, physical scanning, navigation and an in-place update on the MC9400, preserving login and enrollment. Its publication, native Authentik qualification and final dependency-cohort checks remain open. Development-signed installations require an explicit migration to the production signer.
|
||||
|
||||
## Build and test
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ Ordinary **Sign out** continues to end only the QQQ session. The scanner also ex
|
||||
|
||||
Register the installed package's exact callback separately as a provider **post-logout** redirect, even when it matches the existing login callback. The client sends `{"nativeLogout":{"postLogoutRedirectUri":"<registered callback>"}}` to the existing `POST /qqq/v1/logout`. Its optional `nativeLogoutData` contains `issuer`, `endSessionEndpoint`, `postLogoutRedirectUri` and sensitive `idTokenHint`. Old sessions or unsupported servers may legitimately omit this data. The ordinary logout request remains unchanged.
|
||||
|
||||
**Ordering (fix prepared 2026-11-05, not released):** for company sign-out the client sends the provider logout request *before* running the host's `beforeLogout` hook, then clears local state, then opens the browser. The scanner host's hook ends the device session (`POST /qbit/mobile-scanners/sessions/current/end`), which on the server runs the core logout and removes the QQQ session; running it first left no session for `POST /qqq/v1/logout` to describe, so `nativeLogoutData` was never returned and the browser never opened (observed on an MC9400 against a real Authentik fixture). The device-session end may now answer 401 because the login already ended; the client tolerates that and still clears local authentication. Ordinary sign-out is unchanged: host hook first, then the core logout.
|
||||
|
||||
The client clears QQQ session/private process state before opening AppAuth's end-session browser. It independently fetches discovery from the original login binding and checks the issuer, HTTPS end-session endpoint (without user information, query or fragment), and exact callback. A distinct in-memory, single-use state attempt binds completion to server, provider and endpoint. Login cannot start during company logout; cancellation, wrong state, reset or a late callback cannot restore a QQQ session. Browser cancellation or unavailable data explicitly reports incomplete company sign-out. Returning from the provider is protocol completion, not an independent claim about every upstream SSO session.
|
||||
|
||||
The hint is deliberately held in transient ordinary classes with no generated value `toString`, never application preferences, ViewModel state, diagnostics, process values or application saved state. Parsing failures and HTTP diagnostics do not expose response bodies. AppAuth0.11.1 necessarily serializes its end-session request into Android Intents/saved Activity state, and the browser receives the hint in a URL query. This is not a zero-persistence guarantee. Do not enable URI/body logging, browser traces or proxy access logs containing query strings. AppAuth's warning on a state mismatch can contain state values; do not treat SDK debug output as a safe diagnostics feed. Native synthetic tests include a hint logcat/preferences canary; real-provider and hardware qualification are separate gates.
|
||||
|
||||
+14
-4
@@ -4,6 +4,16 @@ Use this for the **production-signed** APK on a small number of devices. Move to
|
||||
|
||||
This guide is not the local demo. The demo uses the development-signed debug APK, plain HTTP and `adb reverse` port forwarding to a workstation server; see [README](../README.md#installing-on-a-device). The two builds have different signers and cannot update each other.
|
||||
|
||||
**Current release status (November 5, 2026):** the only published release is the signed preview 1.0.0 below. A signed **1.0.1 (versionCode 3) candidate** exists but is **not published and has no download link**; this guide does not point to one. Use the table below for what is actually hosted. When 1.0.1 is published, use the file, checksum and link given by the release owner, and verify the same signer.
|
||||
|
||||
| Candidate (not published) | Value |
|
||||
| --- | --- |
|
||||
| Version | 1.0.1, versionCode 3 |
|
||||
| APK SHA-256 | `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F` (15015419 bytes) |
|
||||
| Signing certificate SHA-256 | Same as below (`BDDB2D2F...5B17A9FF`) |
|
||||
|
||||
Details and qualification status: [RELEASING](RELEASING.md).
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Source | [Signed preview release scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease; forge sign-in required) |
|
||||
@@ -15,12 +25,12 @@ This guide is not the local demo. The demo uses the development-signed debug APK
|
||||
|
||||
The same APK is used for every customer; branding comes from the server at runtime. Download the APK and `SHA256SUMS` from the release page and compare the SHA-256 before installing, then verify the signing certificate matches the value above. The checksum shows the file is intact, not who signed it. This is a preview, not a stable release. Only the APK and `SHA256SUMS` are hosted; there is no hosted install kit.
|
||||
|
||||
This APK was installed and launched once on a fresh Zebra MC9400 (Android 13): install succeeded, the Connect screen appeared and the installed bytes matched the hash above. It has **not** been enrolled, scanned or navigated, because no HTTPS server was available, so these steps are not hardware-qualified until the first install is confirmed (below). That MC9400 now runs the development demo build instead.
|
||||
**Device evidence.** *Historical (November 4):* this APK was installed and launched once on a fresh Zebra MC9400 (Android 13) and showed the Connect screen; it was not yet enrolled. *Current (November 5):* with the user's explicit authorization, the MC9400 was migrated: 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready were observed, and the operator confirmed a physical Saved scan that the backend verified. These checks are done and need not be repeated for this device. The 1.0.1 candidate is still unpublished, native Authentik login/logout/account switching is not yet qualified with the production APK, and the TC22 keeps its original development-signed installation.
|
||||
|
||||
## Before you start
|
||||
|
||||
- The device is connected to Wi-Fi.
|
||||
- You have an **HTTPS** QQQ server URL the device can reach. Release builds reject HTTP. The server is not chosen yet: use the address you are given, and do not guess one.
|
||||
- You have a customer **HTTPS** QQQ server URL with a trusted certificate that the device can reach. Release builds reject HTTP, and certificate checks must not be weakened. Use the address you are given (`https://<your-server>`); do not guess one. Local-network qualification used a temporary private network and is not a customer deployment.
|
||||
- You have the QQQ credentials for the sign-in the server offers. None are in this guide.
|
||||
|
||||
## Install: copy the file (recommended)
|
||||
@@ -52,7 +62,7 @@ Grant location when Android asks; scanning works without it. Zebra devices with
|
||||
|
||||
## Confirm the first new install
|
||||
|
||||
On the first device only, check each of these:
|
||||
On the first device of a new customer deployment, check each of these (already done for the MC9400 qualification above):
|
||||
|
||||
- The footer shows **Connected**.
|
||||
- One scan is captured and the server keeps it (**Saved scan**).
|
||||
@@ -67,7 +77,7 @@ For a later build signed by the same key with a higher versionCode, run the same
|
||||
|
||||
## Devices running the development-signed build
|
||||
|
||||
Devices running the development-signed demo build (the TC22, and the MC9400 in demo mode) cannot be updated by this APK, because the signer differs. Android is expected to refuse the install. This is not a fault: do not uninstall the app or reset the device to get around it. Moving that device needs a planned one-time migration that includes re-enrollment.
|
||||
Devices running the development-signed demo build (the TC22) cannot be updated by this APK, because the signer differs. Android is expected to refuse the install. This is not a fault: do not uninstall the app or reset the device to get around it. Moving that device needs an explicitly authorized, planned one-time migration that includes re-enrollment.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ Source review: [Android PR 15](https://git.kof22.com/QRun-IO/qqq-android/pulls/1
|
||||
|
||||
Open **Test Data → Test Orders**, search or filter, and select a record. Back preserves page/search/filter context. A fixed identity heading sits above Details, Items, History and Photos. Groups collapse and can be selected from Sections. Related items show concise reference/quantity/SKU labels; timestamps include timezone context and server currency/units are preserved.
|
||||
|
||||
Use **Actions → Add photo → Take photo / Choose photo**. The selected record remains visible in the chooser. Saved attachments appear under Photos with review status. Available images have thumbnails and previews; quarantined originals remain unavailable. Photo transfer remains online-only, idempotent and protected by parent permissions and device/session authorization.
|
||||
Use **Actions → Add photo → Take photo / Choose photo**. The selected record remains visible in the chooser. Saved attachments appear under Photos with review status. Available images have thumbnails and previews; quarantined originals remain unavailable. The client follows the server's receipt: a host that admits approved-device captures immediately returns `AVAILABLE`, so the app shows "Saved photo."; hosts that retain quarantine return `QUARANTINED` ("Awaiting server review."). The maintained Echo host now uses Record Files' scoped `quarantineCapturedFiles` setting. The app has no admission policy of its own and no client change was needed. Photos already uploaded stay quarantined until the server releases them; the three earlier demo captures were released through the normal audited admin process. Photo transfer remains online-only, idempotent and protected by parent permissions and device/session authorization.
|
||||
|
||||
The signed MC9400 app's gallery upload returned "Saved photo." for a new synthetic PNG; server readback verified `AVAILABLE`, the intended parent, media type, byte count and SHA256. Updated native acceptance on the TC22 also passed synthetic camera/gallery uploads, the newly captured photo's thumbnail and full preview, parent scoping, rotation and forced-logout cleanup. All requests passed semantic API checks; the disposable app was removed afterward. This is not a camera-hardware qualification. The captured-photo policy still needs its public dependency release.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
+34
-8
@@ -1,6 +1,18 @@
|
||||
# Scanner APK release readiness
|
||||
|
||||
Status (November 4, 2026): **a QRun-owned production signing key exists with a verified backup, and the signed 1.0.0 (versionCode 2) APK is published as a signed preview (Gitea prerelease, not a stable release): [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), release ID 1361, tag on protected `main` `3eef6431ca39b6450cf764424d921a02b2afb9ab`. It was installed and launched once on a fresh MC9400, but production HTTPS enrollment, physical scanning and in-place update qualification are still pending.** The owner selected direct signed-APK / MDM distribution. Current local demo devices (TC22 and, by the user's later choice, the MC9400) run the development-signed debug APK (0.1.0, versionCode 1) against the local HTTP server; that is not a production release. Manual TC22 acceptance was reported all-pass by the operator on November 4 (operator report, no per-case artifacts) for the development-signed build; it does not qualify the production signer and is not requested again for that build.
|
||||
## Current status (November 5, 2026)
|
||||
|
||||
- **Published:** the only published release is still [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), a **signed preview** (Gitea prerelease, not a stable release). It is unchanged.
|
||||
- **Candidate, not published:** QQQ Mobile **1.0.1 (versionCode 3)** is a signed candidate. Its release qualification is incomplete, and no tag or download link is available yet.
|
||||
- **Signer:** the candidate is signed with the existing permanent QRun key (certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`). No new or replacement key was created; the existing recovery backup was verified earlier.
|
||||
- **Candidate APK:** SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes.
|
||||
- **Source:** Android PR 15 `ab6e44c`, merged as `c782aaaaba3f62422232436a9000af9d4b38fc82` with the same app-source tree. Installed APK bytes, signature, non-debuggable flag and 16 KiB alignment were verified. `lintRelease` reported 0 errors, 0 warnings and 5 informational findings; the release build passed.
|
||||
- **Distribution:** maintainers use the existing manual signing path for direct APK/MDM distribution. Automated signing CI is optional.
|
||||
- **Open:** see [Qualification status](#qualification-status) and [TODO](TODO.md).
|
||||
|
||||
## Historical status (November 4, 2026, superseded where noted)
|
||||
|
||||
Dated evidence below is preserved. As of November 4: a QRun-owned production signing key existed with a verified backup, and the signed 1.0.0 (versionCode 2) APK was published as a signed preview: [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), release ID 1361, tag on protected `main` `3eef6431ca39b6450cf764424d921a02b2afb9ab`. It had been installed and launched once on a fresh MC9400; at that time enrollment, scanning and update were unqualified. *(Superseded November 5: the MC9400 was migrated to the signed build; see below. The statement that the MC9400 ran the development-signed debug APK is no longer current. The TC22 still runs its original development-signed installation.)* Manual TC22 acceptance was reported all-pass by the operator on November 4 (operator report, no per-case artifacts) for the development-signed build; it does not qualify the production signer and is not rerun.
|
||||
|
||||
## Decisions (made)
|
||||
|
||||
@@ -34,12 +46,26 @@ Version `1.0.0`, `versionCode` 2 was built unsigned (`lintRelease` and `assemble
|
||||
|
||||
**Device evidence (MC9400, November 4):** the signed APK was installed on a fresh MC9400 (Android 13, EMDK present), launched without a startup crash, showed the Connect screen, and its installed bytes read back with the exact hash above. It was not enrolled: release builds require HTTPS and the only server available was the local HTTP demo. The same key and signer are unchanged. The user then chose to run the MC9400 in demo mode like the TC22, so that fresh, unenrolled production install was replaced with the development debug APK and port forwarding to the local server. No signed-build enrollment, scan or navigation was tested. The TC22 and its data were untouched throughout.
|
||||
|
||||
Remaining steps:
|
||||
*(The November 4 MC9400 paragraph above is historical: the fresh install was later replaced by the migration recorded below.)*
|
||||
|
||||
1. Qualify the **actual production-signed APK** on a device: install, enrollment, scanner path ([Zebra scanning](ZEBRA-SCANNING.md)), Back/Apps navigation, and update over a prior production build. Use a planned device migration or a spare device; do not use a demo device implicitly, and note that moving a demo device to the signed build removes its development install and enrollment.
|
||||
2. Provide an **HTTPS** server. Source release builds disallow cleartext; HTTP is limited to debug builds. The local demo does not need HTTPS.
|
||||
3. Use the first direct installation to confirm the device flow, then distribute the same APK to the remaining devices. MDM can be introduced later.
|
||||
4. Automated scanner-APK signing/release workflow is not configured and is separate from manual distribution. The preview release exists; create no further tags or releases (including a stable promotion) without current authorization. A stable release needs the qualification in step 1.
|
||||
## Qualification status
|
||||
|
||||
Done (not to be repeated as unchecked work):
|
||||
|
||||
- **MC9400 migration:** the user explicitly authorized it. Release 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready state were observed.
|
||||
- **Physical scan:** the operator confirmed a physical Saved scan. The backend verified CODE128, AIM identifier, raw bytes, user and device. The actual barcode differed from the requested literal.
|
||||
- **TC22:** the original development-signed installation and its data are retained. The accepted November 4 manual all-pass is not rerun.
|
||||
- **Server discovery:** against the real shared Authentik-backed server, UI API 1.10 discovery with strict nonce passed for both scanner and printing channels, extensions and callbacks.
|
||||
- **Photo policy (live):** a signed MC9400 gallery upload returned Saved photo with `AVAILABLE` status and exact parent, type, size and hash; the previous three photos were released normally. Updated native acceptance on the TC22 passed synthetic camera/gallery uploads, the captured photo's thumbnail and full preview, parent scoping, rotation and forced-logout cleanup. All requests passed the semantic API checks. The disposable app was removed and the normal app reopened. This does not establish camera-hardware or native-Authentik acceptance.
|
||||
- **Local network:** the local QQQ server remains on a PC and was reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is local qualification, not a customer production deployment. Customer instructions still require a customer HTTPS URL with a trusted certificate. TLS must not be weakened, and workstation hostnames, IPs, ports, device IDs, vault IDs, private file paths and passwords are not published.
|
||||
|
||||
Open:
|
||||
|
||||
1. **Native Authentik** login, logout and account switching with the actual production APK remain open, awaiting the MC wireless-debug endpoint. No native-Authentik PASS is claimed.
|
||||
2. **Printing:** the print nonce fix (PR 11, merged `abc3d0d`, 532 hosted tests passed) postdates the published print 1.1.1; the next installer remains with the active printing owner.
|
||||
3. Public compatible Core/Collab/document/files/scripting/bridges, the final public cohort gate, GS1 physical print/rescan, and full release-asset publication and distribution.
|
||||
4. Company profiles and wallpaper are tracked separately.
|
||||
5. Publish the 1.0.1 tag, release, APK, checksum and installation instructions after its remaining qualification. The published download remains the 1.0.0 signed preview; automated signing CI is optional.
|
||||
|
||||
## Release requirements
|
||||
|
||||
@@ -51,8 +77,8 @@ Remaining steps:
|
||||
|
||||
Android updates in place only when the signer matches and `versionCode` does not decrease. Losing this key, or its backup, means installed copies can no longer be updated in place; they would have to be uninstalled and reinstalled, which removes enrollment and session state. Keep the vault backup recoverable and re-verify it (hash, open, certificate comparison) after any change to it.
|
||||
|
||||
Development-signed demo installations (TC22, and the MC9400 in demo mode) cannot be upgraded by the production key. Moving one requires an explicit migration: preserve its test evidence, plan re-enrollment, and never uninstall or reset a device or application implicitly. No update or key-rotation policy beyond keeping this one permanent signer is defined here.
|
||||
Development-signed demo installations (the TC22) cannot be upgraded by the production key. Moving one requires an explicit migration: preserve its test evidence, plan re-enrollment, and never uninstall or reset a device or application implicitly. The MC9400 was migrated under explicit user authorization (see above). No update or key-rotation policy beyond keeping this one permanent signer is defined here.
|
||||
|
||||
## Not yet established
|
||||
|
||||
Production-signed-APK device qualification (the preview is published, but production HTTPS enrollment, scanning and in-place update are unqualified), stable-release promotion, an HTTPS production server, an automated scanner release workflow and its secret handling, and key-rotation planning. SDK publication by the central workflow is done and its completed audits are not reopened here. Other pending items are in [TODO](TODO.md).
|
||||
Publication of the 1.0.1 candidate, native Authentik qualification with the production APK, a customer production HTTPS deployment, stable-release promotion, an automated scanner release workflow (optional) and its secret handling, and key-rotation planning. SDK publication by the central workflow is done and its completed audits are not reopened here. Other pending items are in [TODO](TODO.md).
|
||||
+17
-4
@@ -1,12 +1,25 @@
|
||||
# Current status
|
||||
|
||||
As of **2026-11-05**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is a development preview, not production 1.0 qualification.
|
||||
As of **2026-11-05**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is not a final-release or full-qualification claim. See [Release facts](#release-facts-2026-11-05) below.
|
||||
|
||||
## Release facts (2026-11-05)
|
||||
|
||||
- **Published:** still `scanner-v1.0.0`, a signed preview. Signed candidate **1.0.1 (versionCode 3)** is **not published**, has no tag and no download link, and is not production-complete. Signer: existing permanent QRun key (certificate SHA-256 `BDDB2D2F...5B17A9FF`); no new key. APK SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes. Details: [RELEASING](RELEASING.md).
|
||||
- **Source:** Android PR 15 `ab6e44c`, merged `c782aaaaba3f62422232436a9000af9d4b38fc82`, same app-source tree. Root verified installed APK bytes/signature/non-debuggable/16 KiB alignment; `lintRelease` 0 errors, 0 warnings, 5 informational; release build passed.
|
||||
- **Done:** MC9400 migration (user-authorized): 1.0.0 enrollment over trusted HTTPS, then in-place 1.0.1 update preserving login/enrollment; Apps/ScanCapture and EMDK Scanner-ready observed; operator-confirmed physical Saved scan verified in the backend (CODE128/AIM/raw bytes/user/device; actual barcode differed from the requested literal). Not repeat work. TC22 keeps its original development-signed installation and data; the accepted November 4 manual all-pass is not rerun.
|
||||
- **Network:** the local QQQ server stays on a PC, reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is not a customer production deployment; customer instructions require a customer HTTPS URL with a trusted certificate. TLS is not weakened; no hostnames, IPs, ports, device/vault IDs, private paths or passwords are published.
|
||||
- **Server/Authentik:** real shared Authentik server discovery verified (UI API 1.10, strict nonce, scanner and printing channels/extensions/callbacks). Production-APK native login/logout/account switching is **open**, awaiting the MC wireless-debug endpoint; no native-Authentik PASS is claimed. Print nonce fix PR 11 (merged `abc3d0d`, 532 hosted tests passed) postdates published print 1.1.1; the next installer remains with the active printing owner.
|
||||
- **Photo policy:** live. A signed MC9400 gallery synthetic PNG became a Saved photo, `AVAILABLE`, with exact parent/type/size/hash; the previous three photos were released normally. The app has no admission policy; the server receipt controls the message. The root is running the updated disposable TC22 E2E; no full new-preview or native PASS is claimed until it completes.
|
||||
- **Company sign-out bug (2026-11-05):** on the physical MC9400 with the production APK against the real Authentik fixture, **Sign out of company** never opened the browser and reported "Company sign-out did not complete". Cause: `QViewModel.performLogout` ran the host `beforeLogout` hook (scanner device-session end, which runs the core logout server-side) before the provider logout request, so `nativeLogoutData` was never returned. A fix reordering provider logout before the host hook, plus adoption of the server-reported user name after browser login (Account showed a blank "Current User"), is prepared on branch `fix/company-signout-before-session-end` with unit tests; it is **not released** and no new APK is built. See [AUTHENTICATION](AUTHENTICATION.md#optional-company-sign-out-implementation-awaiting-qualification).
|
||||
- **Open:** public compatible Core/Collab/document/files/scripting/bridges, final public cohort gate, GS1 physical print/rescan, full release-asset publication/distribution. Company profiles/wallpaper are tracked separately; signing CI is optional and not blocking a direct APK release. Release authorizations already exist.
|
||||
|
||||
Statements below were written before these facts and are historical where they conflict (for example "debug update", MC9400 trigger check "outstanding", signed-build HTTPS qualification "open").
|
||||
|
||||
## Record browsing and photos
|
||||
|
||||
The existing TC22 app now opens permitted tables and records through the standard semantic metadata/query/get API. Tables support search, exact text filters, counts and stable paging; Back restores page/search/filter context. Records retain identity above Details, related-record and Photos tabs, with collapsible groups and a section selector. Dates/timezones, currency and empty values are readable; identifiers keep leading zeros.
|
||||
|
||||
**Record → Actions → Add photo** opens Take photo / Choose photo. Saved photos show parent-scoped lifecycle status and approved thumbnails/previews. Quarantined originals remain unavailable. Existing upload identity, retry handling, server authorization, pending-navigation guards and logout cleanup are preserved.
|
||||
**Record → Actions → Add photo** opens Take photo / Choose photo. Saved photos show parent-scoped lifecycle status and approved thumbnails/previews. Quarantined originals remain unavailable. Availability is decided only by the server receipt/lifecycle; focused tests cover both `AVAILABLE` and `QUARANTINED`, and the native E2E expects `AVAILABLE` for the configured Echo fixture while retaining quarantine assertions for the generic fixture. Thirteen focused photo/API tests passed. A fresh synthetic gallery upload through the signed MC9400 app returned "Saved photo."; server readback verified availability, parent, type, byte count and SHA256. The three earlier demo captures were released through the normal audited admin process. Updated TC22 native photo acceptance passed synthetic camera/gallery upload, the captured photo thumbnail/full preview, parent scoping, rotation and forced-logout cleanup, with all requests checked against the semantic API. The disposable test packages were removed and the normal app reopened. Public captured-photo dependency publication and native Authentik qualification remain open. Existing upload identity, retry handling, server authorization, pending-navigation guards and logout cleanup are preserved.
|
||||
|
||||
The maintained companion demo supplies Test Data: 1,000 orders, 39 fields in six groups, 12,000 items and 3,000 history entries. See [screenshots and verification](RECORD-UX-2026-11-05.md).
|
||||
|
||||
@@ -22,8 +35,8 @@ The maintained companion demo supplies Test Data: 1,000 orders, 39 fields in six
|
||||
|
||||
Protected `main` contains PRs 8/10, with signing/documentation through PR 14 (`464814f`). The user authorized commit, push and merge of the GS1/record/UX work through normal Gitea checks. No new artifact release was requested. The maintained server uses public QQQ RC.21 and Printing 1.0.0 plus qualified document/files/scripting owner inputs. Contract versions remain UI API 1.10, mobile-scanners 1.5, printing 2.3 and mobile-record-files 1.0. Device loopback 8080 forwards to the existing host port 57832.
|
||||
|
||||
The QRun-owned signing key and [signed preview `scanner-v1.0.0`](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) remain unchanged. That preview predates this debug update. It is not a stable release or an automatic signing/publication pipeline; see [RELEASING](RELEASING.md).
|
||||
The QRun-owned signing key and [signed preview `scanner-v1.0.0`](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) remain unchanged. That preview predates the record UX update; candidate 1.0.1 is unpublished. The preview is not a stable release or an automatic signing/publication pipeline; see [RELEASING](RELEASING.md).
|
||||
|
||||
## Accepted and outstanding hardware work
|
||||
|
||||
The November 4 TC22 operator group pass remains accepted; do not request it again. MC9400 remains installed, signed in and approved, with a physical trigger/backend check outstanding. Physical GS1 print/rescan, native provider callback, signed-build HTTPS qualification, public owner-dependency adoption and fleet/MDM rollout remain open. Historical hashes, signed-preview provenance and older tests are preserved in [the prior checkpoint](history/SESSION-STATE-before-record-ux-2026-11-05.md).
|
||||
The November 4 TC22 operator group pass remains accepted; do not request it again. MC9400's physical scan/backend check and signed-build trusted-HTTPS enrollment/update are done (see Release facts). Physical GS1 print/rescan, native provider callback, public owner-dependency adoption and fleet/MDM rollout remain open. Historical hashes, signed-preview provenance and older tests are preserved in [the prior checkpoint](history/SESSION-STATE-before-record-ux-2026-11-05.md).
|
||||
+10
-6
@@ -5,15 +5,19 @@ Open work only, as of **2026-11-05**. Current evidence: [SESSION-STATE](SESSION-
|
||||
## Hardware and release qualification
|
||||
|
||||
- [ ] Qualify actual GS1 label media and physical printed-label rescan. Synthetic capture/render evidence does not close this check.
|
||||
- [ ] Confirm one physical MC9400 trigger scan in the backend. The normal app is installed, signed in and approved.
|
||||
- [ ] Qualify the production-signed build against an HTTPS server: enrollment, scanner path, navigation and update.
|
||||
- [ ] Verify an actual native Authentik/provider callback, logout and account switching.
|
||||
- [ ] Distribute the qualified signed build to remaining devices; validate MDM/company profile separately. The published signed preview predates the current record UX update.
|
||||
- [ ] Automatic APK signing/publication remains owned by the shared CI design; no new pipeline is configured here.
|
||||
- [ ] Verify actual production-APK native Authentik login, logout and account switching; awaiting the MC wireless-debug endpoint. Real-server discovery (UI API 1.10, strict nonce, scanner and printing channels) is already verified; no native-Authentik PASS is claimed.
|
||||
- [ ] Publish signed candidate 1.0.1 (versionCode 3, APK SHA-256 `5124716B...AC2717F`, same permanent signer), checksum and installation instructions after remaining qualification. The published release is still the 1.0.0 signed preview.
|
||||
- [ ] Publish and distribute full release assets to remaining devices; validate MDM/company profile and wallpaper separately. A customer deployment needs a customer HTTPS URL with a trusted certificate.
|
||||
- [ ] Printing: the next installer remains with the active printing owner (print nonce fix PR 11 postdates published print 1.1.1).
|
||||
- [ ] Optional, non-blocking: automatic APK signing CI. Direct APK release does not depend on it.
|
||||
|
||||
Done (November 5, do not repeat): MC9400 migration with release 1.0.0 trusted-HTTPS enrollment then in-place 1.0.1 update preserving login/enrollment; Apps/ScanCapture navigation and EMDK Scanner-ready observed; operator-confirmed physical Saved scan verified in the backend (CODE128, AIM, raw bytes, user, device; actual barcode differed from the requested literal). Live photo policy verified with a signed MC9400 synthetic gallery photo (`AVAILABLE`, exact parent/type/size/hash).
|
||||
|
||||
Updated TC22 native photo acceptance passed: synthetic camera/gallery uploads, the newly captured photo's thumbnail/full preview, parent scoping, rotation and forced-logout cleanup, with semantic API checks. The disposable test packages were removed and the normal app reopened. Native Authentik remains a separate open check.
|
||||
|
||||
## Dependencies and broader coverage
|
||||
|
||||
- [ ] Adopt public, coherent Generation/Record Files/Scripting owner inputs through a reviewed companion-server change and a fresh complete gate.
|
||||
- [ ] Adopt public compatible Core/Collab/document/files/scripting/bridges inputs through a reviewed companion-server change, and pass the final public cohort gate.
|
||||
- [ ] Verify company profile / EHS / OEMConfig / MDM deployment ([DEVICE-PROVISIONING](DEVICE-PROVISIONING.md), [COMPANY-PROFILE](COMPANY-PROFILE.md)).
|
||||
- [ ] Resolve applicable [legacy source follow-ups](LEGACY-SOURCE-FOLLOWUPS.md).
|
||||
- [ ] Extend specialized forms, reports and other unsupported [native components](UI-API.md) with conformance and E2E coverage as needed. Read-only table/record browsing and record photo attachment are implemented.
|
||||
|
||||
+5
@@ -31,4 +31,9 @@ data class ManageSessionResponse(
|
||||
val uuid: String? = null,
|
||||
val values: MapOfStringAny = emptyMap(),
|
||||
) {
|
||||
/*******************************************************************************
|
||||
** Display name from the optional values.user object; absent, non-object or blank names yield null.
|
||||
*******************************************************************************/
|
||||
val userName: String?
|
||||
get() = ((values["user"] as? Map<*, *>)?.get("name") as? String)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
@@ -72,6 +72,9 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
+25
-9
@@ -582,8 +582,23 @@ open class QViewModel(
|
||||
*******************************************************************************/
|
||||
fun logOut(): Job = performLogout()
|
||||
|
||||
/*******************************************************************************
|
||||
** Host session resources end in a bounded, best-effort step; core logout and local clearing never depend on it.
|
||||
*******************************************************************************/
|
||||
private suspend fun endHostSession() {
|
||||
try {
|
||||
kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() }
|
||||
} catch (exception: kotlinx.coroutines.CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) { /* Core logout and local clearing still run. */
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional provider material is captured only by the initiating coroutine, never by ViewModel state.
|
||||
** Ordinary logout ends host resources first. Provider logout runs first instead: it both returns the
|
||||
** transient hint and ends the QQQ session, and a host session end (such as the scanner device-session end,
|
||||
** which itself runs the core logout server-side) would otherwise remove that session before the hint is read.
|
||||
*******************************************************************************/
|
||||
private fun performLogout(providerLogout: (suspend () -> Unit)? = null): Job {
|
||||
logoutJob?.takeIf {
|
||||
@@ -606,16 +621,15 @@ open class QViewModel(
|
||||
var serverConfirmed = false
|
||||
try {
|
||||
settingsMutex.withLock { settingsRepository.setSessionUUID(""); settingsRepository.setSessionUserFullName("") }
|
||||
if (providerLogout == null) endHostSession()
|
||||
try {
|
||||
kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() }
|
||||
} catch (exception: kotlinx.coroutines.CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) { /* Core logout and local clearing still run. */
|
||||
serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) {
|
||||
if (providerLogout == null) qqqRepository.logout() else providerLogout()
|
||||
true
|
||||
} ?: false
|
||||
} finally {
|
||||
if (providerLogout != null) endHostSession()
|
||||
}
|
||||
serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) {
|
||||
if (providerLogout == null) qqqRepository.logout() else providerLogout()
|
||||
true
|
||||
} ?: false
|
||||
} catch (exception: kotlinx.coroutines.CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) { /* Local sign-out still completes when the network is unavailable. */
|
||||
@@ -672,7 +686,8 @@ open class QViewModel(
|
||||
binding.login.server == environment.baseUrl && isLogoutRequested
|
||||
|
||||
/*******************************************************************************
|
||||
** Local invalidation finishes before the browser sees the optional transient hint.
|
||||
** Local invalidation finishes before the browser sees the optional transient hint: the provider logout
|
||||
** request runs inside performLogout, whose completion clears local state before this returns.
|
||||
*******************************************************************************/
|
||||
suspend fun logOutWithProvider(generation: Long, binding: OAuth2LogoutBinding): NativeLogoutData? {
|
||||
if (!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.PREPARING) return null
|
||||
@@ -846,6 +861,7 @@ open class QViewModel(
|
||||
if (!isCurrentOAuth2Login(generation, binding)) return@launch
|
||||
sessionUUID = requireNotNull(newSession)
|
||||
qqqRepository.setSessionUUID(sessionUUID)
|
||||
response.userName?.let { doSetSessionUserFullName(it) }
|
||||
manageSessionLoadState = LoadState.Success(response)
|
||||
logInSuccessful()
|
||||
adopted = true
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.mobileapp.viewmodel
|
||||
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
|
||||
import com.kingsrook.qqq.frontend.android.core.model.Environment
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionRequest
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.NativeLogoutData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LoginBinding
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private const val SERVER = "https://example.invalid"
|
||||
private const val ISSUER = "https://idp.example.invalid"
|
||||
private const val REDIRECT = "io.qrun.test.oauth://callback/oauth2redirect"
|
||||
private const val STATE = "state-0123456789abcdef"
|
||||
private const val NONCE = "nonce-0123456789abcdef"
|
||||
private const val VERIFIER = "verifier-0123456789abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordering of host session end, provider logout and local clearing; identity adoption after browser login.
|
||||
*******************************************************************************/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class QViewModelLogoutTest {
|
||||
/*******************************************************************************
|
||||
** (a) Provider logout must read the hint before the host ends its device session (which ends the QQQ session).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun companyLogoutRequestsProviderMaterialBeforeHostSessionEnd() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
val repository = LogoutRepository()
|
||||
val model = signedInModel(repository)
|
||||
model.beforeLogout = { repository.calls += "beforeLogout" }
|
||||
val material = companyLogout(model)
|
||||
assertNotNull(material)
|
||||
assertEquals(listOf("logoutWithProvider", "beforeLogout"), repository.calls)
|
||||
assertEquals("Signed out.", model.logoutMessage)
|
||||
assertFalse(model.isAuthenticated)
|
||||
assertEquals("", model.sessionUUID)
|
||||
assertEquals("", model.sessionUserFullName)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** (b) A failing host session end after the provider logout neither loses the hint nor leaves local state.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun failingHostSessionEndAfterProviderLogoutKeepsMaterialAndClearsLocally() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
val repository = LogoutRepository()
|
||||
val model = signedInModel(repository)
|
||||
model.beforeLogout = { repository.calls += "beforeLogout"; throw IllegalStateException("401 Unauthorized") }
|
||||
val material = companyLogout(model)
|
||||
assertNotNull(material)
|
||||
assertEquals(listOf("logoutWithProvider", "beforeLogout"), repository.calls)
|
||||
assertEquals("Signed out.", model.logoutMessage)
|
||||
assertFalse(model.isAuthenticated)
|
||||
assertFalse(model.logoutInProgress)
|
||||
assertEquals("", model.sessionUUID)
|
||||
assertEquals("", repository.activeSession)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** (c) Ordinary sign-out keeps ending host resources before the core logout.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun ordinaryLogoutEndsHostSessionBeforeCoreLogout() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
val repository = LogoutRepository()
|
||||
val model = signedInModel(repository)
|
||||
model.beforeLogout = { repository.calls += "beforeLogout" }
|
||||
model.requestLogout()
|
||||
model.logOut().join()
|
||||
assertEquals(listOf("beforeLogout", "logout"), repository.calls)
|
||||
assertEquals("Signed out.", model.logoutMessage)
|
||||
assertFalse(model.isAuthenticated)
|
||||
assertEquals("", model.sessionUUID)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Browser login adopts the server-reported display name, as password login already does.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun browserLoginAdoptsServerReportedUserName() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
val repository = LogoutRepository()
|
||||
val model = signedInModel(repository)
|
||||
assertTrue(model.isAuthenticated)
|
||||
assertEquals("scanner-b", model.sessionUserFullName)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The user object is optional and unknown keys stay tolerated; blank or non-object users yield no name.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun manageSessionResponseExposesOptionalUserName() {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val full = json.decodeFromString<ManageSessionResponse>(
|
||||
"""{"uuid":"s","values":{"user":{"name":"scanner-b","email":"scanner-b@example.invalid","extra":1},"other":true},"unknown":"x"}"""
|
||||
)
|
||||
assertEquals("scanner-b", full.userName)
|
||||
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s"}""").userName)
|
||||
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":"scanner-b"}}""").userName)
|
||||
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete a native browser login against the fake repository so sign-out tests start authenticated.
|
||||
*******************************************************************************/
|
||||
private fun TestScope.signedInModel(repository: LogoutRepository): QViewModel {
|
||||
QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER))
|
||||
val model = QViewModel(repository, MockPreferencesDataStore())
|
||||
advanceUntilIdle()
|
||||
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
|
||||
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
|
||||
model.completeOAuth2Login(loginBinding, STATE, STATE, NONCE, "client", REDIRECT, "login", "code")
|
||||
advanceUntilIdle()
|
||||
assertTrue(model.isAuthenticated)
|
||||
assertEquals("session-1", model.sessionUUID)
|
||||
repository.calls.clear()
|
||||
return model
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the company sign-out preparation exactly as QOAuth2Logout does before opening the browser.
|
||||
*******************************************************************************/
|
||||
private suspend fun companyLogout(model: QViewModel): NativeLogoutData? {
|
||||
val binding = OAuth2LogoutBinding(loginBinding, REDIRECT)
|
||||
model.requestCompanyLogout(binding)
|
||||
assertEquals(OAuth2LogoutPhase.PREPARING, model.companyLogoutPhase)
|
||||
return model.logOutWithProvider(model.companyLogoutGeneration, binding)
|
||||
}
|
||||
|
||||
private val loginBinding = OAuth2LoginBinding(
|
||||
SERVER, "oauth", ISSUER, "$ISSUER/.well-known/openid-configuration", "client", "openid", REDIRECT, "login", "REQUIRED"
|
||||
)
|
||||
|
||||
/*******************************************************************************
|
||||
** Records the order of logout-related calls; any call after the session ended returns no provider material.
|
||||
*******************************************************************************/
|
||||
private class LogoutRepository : QQQRepository {
|
||||
val calls = mutableListOf<String>()
|
||||
var activeSession = ""
|
||||
|
||||
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData(
|
||||
"oauth", values = OAuth2AuthenticationMetaData.Values(
|
||||
clientId = "client", scopes = "openid",
|
||||
nativeSessionLogin = OAuth2AuthenticationMetaData.NativeSessionLogin(
|
||||
OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", ISSUER, "$ISSUER/.well-known/openid-configuration", listOf(REDIRECT), "login", "REQUIRED"
|
||||
),
|
||||
nativeSessionLogout = OAuth2AuthenticationMetaData.NativeSessionLogout(OAuth2AuthenticationMetaData.LOGOUT_FLOW, listOf(REDIRECT))
|
||||
)
|
||||
)
|
||||
|
||||
override suspend fun exchangeNativeSession(body: ManageSessionRequest): ManageSessionResponse =
|
||||
ManageSessionResponse("session-1", mapOf("user" to mapOf("name" to "scanner-b", "email" to "scanner-b@example.invalid")))
|
||||
|
||||
override suspend fun logout() {
|
||||
calls += "logout"
|
||||
activeSession = ""
|
||||
}
|
||||
|
||||
override suspend fun logoutWithProvider(postLogoutRedirectUri: String): NativeLogoutData? {
|
||||
calls += "logoutWithProvider"
|
||||
val material = if (activeSession.isBlank()) null else NativeLogoutData(ISSUER, "$ISSUER/end-session", postLogoutRedirectUri, "hint")
|
||||
activeSession = ""
|
||||
return material
|
||||
}
|
||||
|
||||
override fun setSessionUUID(sessionUUID: String) {
|
||||
activeSession = sessionUUID
|
||||
}
|
||||
|
||||
override fun setEnvironment(environment: Environment) {}
|
||||
override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Not used")
|
||||
override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance = QInstance()
|
||||
override suspend fun getProcessMetaData(processName: String): QProcessMetaData = error("Not used")
|
||||
override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
|
||||
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
|
||||
override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult = error("Not used")
|
||||
override fun getURI(path: String): String = error("Not used")
|
||||
}
|
||||
}
|
||||
@@ -401,6 +401,14 @@ class ScannerEndToEndTest {
|
||||
val config = JSONObject(configFile.readText())
|
||||
configFile.delete()
|
||||
origin = config.getString("serverUrl").trimEnd('/')
|
||||
// Explicit fixture expectations, not production host classification: the rich-records scenario (rich=true) runs
|
||||
// against the configured EchoDemo host, which admits an approved device's captures immediately
|
||||
// (Record Files quarantineCapturedFiles=false) and so expects AVAILABLE. The generic document/process photo
|
||||
// fixtures (rich=false) expect QUARANTINED.
|
||||
val echoHost = rich
|
||||
val savedMessage = if (echoHost) "Saved photo." else "Saved photo. Awaiting server review."
|
||||
val expectedLifecycle = if (echoHost) "AVAILABLE" else "QUARANTINED"
|
||||
val expectedBadge = if (echoHost) "Available" else "Awaiting review"
|
||||
val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val bytes = ByteArrayOutputStream().use { output -> bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output); output.toByteArray() }
|
||||
bitmap.recycle()
|
||||
@@ -513,7 +521,8 @@ class ScannerEndToEndTest {
|
||||
}
|
||||
waitFor("Take photo")
|
||||
ui.onNodeWithText("Take photo").performScrollTo().performClick()
|
||||
waitFor("Saved photo. Awaiting server review.")
|
||||
waitFor(savedMessage)
|
||||
if (echoHost) ui.onNodeWithText("Saved photo. Awaiting server review.").assertDoesNotExist()
|
||||
assertEquals("Camera result must automatically upload without a second tap", 1, cameraCalls.get())
|
||||
assertTrue(directory.listFiles().orEmpty().none { it.name.startsWith("photo-") })
|
||||
val savedRows = jsonRequest("/qqq/v1/table/record_files_file/query", JSONObject()).getJSONArray("records")
|
||||
@@ -523,7 +532,7 @@ class ScannerEndToEndTest {
|
||||
assertEquals(parentId, savedFile.getString("recordId"))
|
||||
assertEquals("CAPTURE", savedFile.getString("origin"))
|
||||
assertTrue(savedFile.getString("originDetail").endsWith("source CAMERA"))
|
||||
assertEquals("QUARANTINED", savedFile.getString("lifecycle"))
|
||||
assertEquals(expectedLifecycle, savedFile.getString("lifecycle"))
|
||||
assertEquals(bytes.size.toLong(), savedFile.getLong("sizeBytes"))
|
||||
assertTrue(savedFile.getLong("attachmentId") > 0)
|
||||
val associations = jsonRequest("/qqq/v1/table/record_files_collection_item/query", JSONObject()).getJSONArray("records")
|
||||
@@ -536,13 +545,14 @@ class ScannerEndToEndTest {
|
||||
waitFor("Photo ready to upload.")
|
||||
assertEquals(1, galleryCalls.get())
|
||||
ui.onNodeWithText("Upload photo").performScrollTo().performClick()
|
||||
waitFor("Saved photo. Awaiting server review.")
|
||||
waitFor(savedMessage)
|
||||
val galleryRows = jsonRequest("/qqq/v1/table/record_files_file/query", JSONObject()).getJSONArray("records")
|
||||
val gallerySaved = (0 until galleryRows.length()).map { galleryRows.getJSONObject(it).getJSONObject("values") }
|
||||
.single { it.optString("deviceId") == device.deviceId && it.optString("originDetail").endsWith("source GALLERY") }
|
||||
assertEquals(parentTable, gallerySaved.getString("tableName"))
|
||||
assertEquals(parentId, gallerySaved.getString("recordId"))
|
||||
assertEquals(RecordFilesApi.sha256(bytes), gallerySaved.getString("sha256"))
|
||||
assertEquals(expectedLifecycle, gallerySaved.getString("lifecycle"))
|
||||
assertTrue(gallerySaved.getLong("attachmentId") > 0)
|
||||
stage("native-photo-gallery-saved")
|
||||
if (fromRecord) {
|
||||
@@ -551,8 +561,12 @@ class ScannerEndToEndTest {
|
||||
waitFor(if (rich) "3 photos" else "2 photos")
|
||||
if (rich) {
|
||||
ui.onNodeWithText("Preview example 27.jpg").assertDoesNotExist()
|
||||
waitForTag("photo.image:256")
|
||||
ui.onNodeWithText("Preview photo").performClick()
|
||||
// Seeded, camera and gallery photos are all AVAILABLE, so scope thumbnail and Preview to the newly captured camera row.
|
||||
val cameraRow = "record.photo:${savedFile.getString("id")}"
|
||||
ui.onNodeWithTag("record.photos.list").performScrollToNode(hasTestTag(cameraRow))
|
||||
val cameraThumbnail = hasTestTag("photo.image:256") and hasAnyAncestor(hasTestTag(cameraRow))
|
||||
ui.waitUntil(timeoutMillis = 60_000) { runCatching { ui.onNode(cameraThumbnail).fetchSemanticsNode() }.isSuccess }
|
||||
ui.onNode(hasText("Preview photo") and hasAnyAncestor(hasTestTag(cameraRow))).performClick()
|
||||
waitFor("Photo preview")
|
||||
waitForTag("photo.image:1600")
|
||||
ui.onNodeWithText("Close preview").performClick()
|
||||
@@ -561,7 +575,7 @@ class ScannerEndToEndTest {
|
||||
listOf(savedFile, gallerySaved).forEach { file ->
|
||||
val row = "record.photo:${file.getString("id")}"
|
||||
ui.onNodeWithTag("record.photos.list").performScrollToNode(hasTestTag(row))
|
||||
ui.onNode(hasText("Awaiting review") and hasAnyAncestor(hasTestTag(row))).assertIsDisplayed()
|
||||
ui.onNode(hasText(expectedBadge) and hasAnyAncestor(hasTestTag(row))).assertIsDisplayed()
|
||||
}
|
||||
stage("native-photo-gallery-parent-scoped")
|
||||
ui.onNodeWithText("Actions", substring = false).performClick()
|
||||
|
||||
@@ -60,6 +60,54 @@ class PhotoUploadViewModelTest {
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that does not opt in (generic servers) commits QUARANTINED: the saved photo
|
||||
** is reported as awaiting server review, never as available.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun quarantinedReceiptOnOtherHostsReportsAwaitingReview() = runTest()
|
||||
{
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
Dispatchers.setMain(dispatcher)
|
||||
try {
|
||||
val transport = FakeTransport().apply { lifecycle = "QUARANTINED" }
|
||||
val model = PhotoUploadViewModel(transport, "session", target, files.newFolder(), dispatcher) { true }
|
||||
advanceUntilIdle()
|
||||
model.prepareCamera().writeBytes(original)
|
||||
model.cameraResult(true)
|
||||
advanceUntilIdle()
|
||||
assertTrue(model.saved)
|
||||
assertEquals("Saved photo. Awaiting server review.", model.message)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An Echo host that admits approved-device captures commits AVAILABLE: the same unchanged
|
||||
** receipt flow reports a plain saved photo, with the exact original still verified.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun availableReceiptOnEchoHostReportsSavedWithoutReviewWait() = runTest()
|
||||
{
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
Dispatchers.setMain(dispatcher)
|
||||
try {
|
||||
val transport = FakeTransport().apply { lifecycle = "AVAILABLE" }
|
||||
val model = PhotoUploadViewModel(transport, "session", target, files.newFolder(), dispatcher) { true }
|
||||
advanceUntilIdle()
|
||||
model.prepareCamera().writeBytes(original)
|
||||
model.cameraResult(true)
|
||||
advanceUntilIdle()
|
||||
assertTrue(model.saved)
|
||||
assertEquals("Saved photo.", model.message)
|
||||
assertArrayEquals(original, transport.uploaded)
|
||||
assertEquals("CAMERA", model.intent!!.origin)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clearing the process cancels in-flight transport before removing only the app-owned temporary original.
|
||||
*******************************************************************************/
|
||||
@@ -215,6 +263,7 @@ class PhotoUploadViewModelTest {
|
||||
var finalizes = 0
|
||||
var cancels = 0
|
||||
var statusCalls = 0
|
||||
var lifecycle = "QUARANTINED"
|
||||
var loseCreate = false
|
||||
var loseFinalize = false
|
||||
var chunkGate: CompletableDeferred<Unit>? = null
|
||||
@@ -282,7 +331,7 @@ class PhotoUploadViewModelTest {
|
||||
"items", JSONArray().put(
|
||||
JSONObject()
|
||||
.put("itemId", intent.itemId).put("position", 0).put("fileId", "file").put("versionId", 2).put("attachmentId", 3)
|
||||
.put("sha256", intent.sha256).put("sizeBytes", intent.size).put("lifecycleAtCommit", "QUARANTINED")
|
||||
.put("sha256", intent.sha256).put("sizeBytes", intent.size).put("lifecycleAtCommit", lifecycle)
|
||||
)
|
||||
) else JSONObject.NULL
|
||||
return JSONObject().put("uploadId", id).put("state", state).put("receipt", receipt)
|
||||
|
||||
Reference in new issue
Block a user