Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9208416fa4
|
||
|
|
778ac58612
|
||
|
|
e8b6be00e8
|
||
|
|
089f57f759
|
||
|
|
1d3847d070 | ||
|
|
dc43a6a747
|
||
|
|
21c952ea81 | ||
|
|
3bfafa5bac | ||
|
|
8e64d9a83c | ||
|
|
af38def2d2 | ||
|
|
f739a96f7a
|
||
|
|
a2f8f2b5fd
|
||
|
|
5ddd5a3317
|
||
|
|
cf9d53d786
|
||
|
|
eeb9f63322
|
||
|
|
f39f7a37d3
|
||
|
|
f733a9906c
|
||
|
|
cf82dd5d78
|
||
|
|
3325bd6061
|
||
|
|
9e06e1f3e9
|
||
|
|
c782aaaaba
|
||
|
|
ab6e44cddd
|
||
|
|
476210ae22
|
||
|
|
464814f922
|
||
|
|
c555d9a9ec
|
||
|
|
3eef6431ca
|
||
|
|
9418c03c4b
|
||
|
|
bcbd1b4e98
|
||
|
|
f58e28b0c2
|
||
|
|
0ae2101f08
|
||
|
|
1f5fd2ef95
|
||
|
|
b75fdac881
|
||
|
|
6980c4f7c3
|
No files matched your search
@@ -1,14 +0,0 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /.*/
|
||||
@@ -0,0 +1,19 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
android:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/android.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
node-version: '24.11.1'
|
||||
commands: |
|
||||
node scripts/ci/check-source-standards.mjs
|
||||
node --test scripts/ci/scanner-echo-guards.test.mjs
|
||||
tasks: |
|
||||
testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleDebug
|
||||
-PscannerTestBuildType=qa :scanner-host:assembleQa :scanner-host:assembleQaAndroidTest
|
||||
emulator-tasks: connectedDebugAndroidTest
|
||||
publish-tasks: :qqq:android:core:publishAllPublicationsToGiteaRepository :qqq:android:mobileapp:publishAllPublicationsToGiteaRepository
|
||||
@@ -1,138 +0,0 @@
|
||||
name: Release scanner APK
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['scanner-v*']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: scanner-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
uses: ./.github/workflows/scanner.yml
|
||||
|
||||
package:
|
||||
needs: verify
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
environment: scanner-release
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Require main ancestry and a strictly newer Android version
|
||||
id: release-version
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
SCANNER_VERSION_CODE: ${{ vars.SCANNER_VERSION_CODE }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main
|
||||
[[ "$SCANNER_VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]] || exit 1
|
||||
(( SCANNER_VERSION_CODE <= 2100000000 )) || exit 1
|
||||
gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
|
||||
--jq '.[] | select(.draft == false) | select(.tag_name | startswith("scanner-v")) | .tag_name' > "$RUNNER_TEMP/scanner-prior-tags.txt"
|
||||
while IFS= read -r tag; do
|
||||
[[ "$tag" =~ ^scanner-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]] || exit 1
|
||||
prior_directory=$(mktemp -d "$RUNNER_TEMP/scanner-prior.XXXXXX")
|
||||
gh release download "$tag" --pattern BUILD.txt --dir "$prior_directory"
|
||||
previous=$(sed -n 's/^Version code: //p' "$prior_directory/BUILD.txt")
|
||||
[[ "$previous" =~ ^[1-9][0-9]{0,9}$ ]] || exit 1
|
||||
(( SCANNER_VERSION_CODE > previous )) || { echo 'Version code must exceed every published scanner release'; exit 1; }
|
||||
done < "$RUNNER_TEMP/scanner-prior-tags.txt"
|
||||
printf 'code=%s\n' "$SCANNER_VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools"
|
||||
- name: Build and verify signed release
|
||||
env:
|
||||
SCANNER_KEYSTORE_BASE64: ${{ secrets.SCANNER_KEYSTORE_BASE64 }}
|
||||
SCANNER_STORE_PASSWORD: ${{ secrets.SCANNER_STORE_PASSWORD }}
|
||||
SCANNER_KEY_ALIAS: ${{ secrets.SCANNER_KEY_ALIAS }}
|
||||
SCANNER_KEY_PASSWORD: ${{ secrets.SCANNER_KEY_PASSWORD }}
|
||||
SCANNER_CERTIFICATE_SHA256: ${{ vars.SCANNER_CERTIFICATE_SHA256 }}
|
||||
SCANNER_VERSION_CODE: ${{ steps.release-version.outputs.code }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
version="${GITHUB_REF_NAME#scanner-v}"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]] || exit 1
|
||||
[[ "$SCANNER_VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]] || exit 1
|
||||
(( SCANNER_VERSION_CODE <= 2100000000 )) || exit 1
|
||||
[[ "$SCANNER_CERTIFICATE_SHA256" =~ ^[A-Fa-f0-9]{64}$ ]] || exit 1
|
||||
for name in SCANNER_KEYSTORE_BASE64 SCANNER_STORE_PASSWORD SCANNER_KEY_ALIAS SCANNER_KEY_PASSWORD; do
|
||||
[[ -n "${!name}" ]] || { echo "Missing release signing configuration: $name"; exit 1; }
|
||||
done
|
||||
export SCANNER_KEYSTORE="$RUNNER_TEMP/scanner-release.jks"
|
||||
trap 'rm -f -- "$SCANNER_KEYSTORE"' EXIT
|
||||
printf '%s' "$SCANNER_KEYSTORE_BASE64" | base64 --decode > "$SCANNER_KEYSTORE"
|
||||
unset SCANNER_KEYSTORE_BASE64
|
||||
./gradlew --no-daemon --console=plain :scanner-host:lintRelease :scanner-host:assembleRelease \
|
||||
"-PscannerVersionName=$version" "-PscannerVersionCode=$SCANNER_VERSION_CODE"
|
||||
mkdir -p release
|
||||
cp scanner-host/build/outputs/apk/release/scanner-host-release.apk "release/qqq-mobile-$version.apk"
|
||||
cp scanner-host/build/reports/third-party/release/{aboutlibraries.json,LICENSE,NOTICE} release/
|
||||
"$ANDROID_HOME/build-tools/35.0.0/apksigner" verify --verbose --print-certs "release/qqq-mobile-$version.apk" > release/signature.txt
|
||||
certificate=$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' release/signature.txt)
|
||||
[[ "${certificate,,}" == "${SCANNER_CERTIFICATE_SHA256,,}" ]] || { echo 'Unexpected signing certificate'; exit 1; }
|
||||
"$ANDROID_HOME/build-tools/35.0.0/aapt" dump badging "release/qqq-mobile-$version.apk" > "$RUNNER_TEMP/scanner-manifest.txt"
|
||||
grep -F "package: name='io.qrun.mobile.scanner' versionCode='$SCANNER_VERSION_CODE' versionName='$version'" "$RUNNER_TEMP/scanner-manifest.txt"
|
||||
if grep -q 'application-debuggable' "$RUNNER_TEMP/scanner-manifest.txt"; then
|
||||
echo 'Refusing a debuggable release'; exit 1
|
||||
fi
|
||||
printf 'Source: %s\nVersion: %s\nVersion code: %s\n' "$GITHUB_SHA" "$version" "$SCANNER_VERSION_CODE" > release/BUILD.txt
|
||||
(cd release && sha256sum -- *.apk signature.txt BUILD.txt aboutlibraries.json LICENSE NOTICE > SHA256SUMS)
|
||||
- name: Retain signed deliverable
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: scanner-release-${{ github.sha }}
|
||||
path: release/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
publish:
|
||||
needs: package
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: scanner-release-${{ github.sha }}
|
||||
path: release
|
||||
- name: Publish immutable-tag APK assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd release && sha256sum --check SHA256SUMS)
|
||||
prerelease=()
|
||||
version="${GITHUB_REF_NAME#scanner-v}"
|
||||
tag_object=$(gh api "repos/$GH_REPO/git/ref/tags/$GITHUB_REF_NAME" --jq '.object.sha')
|
||||
tag_type=$(gh api "repos/$GH_REPO/git/ref/tags/$GITHUB_REF_NAME" --jq '.object.type')
|
||||
for ((attempt=0; attempt<5; attempt++)); do
|
||||
if [[ "$tag_type" == commit ]]; then break; fi
|
||||
[[ "$tag_type" == tag ]] || exit 1
|
||||
tag_json=$(gh api "repos/$GH_REPO/git/tags/$tag_object")
|
||||
tag_object=$(jq -r '.object.sha' <<< "$tag_json")
|
||||
tag_type=$(jq -r '.object.type' <<< "$tag_json")
|
||||
done
|
||||
[[ "$tag_type" == commit && "$tag_object" == "$GITHUB_SHA" ]] || { echo 'Release tag no longer identifies the verified source'; exit 1; }
|
||||
if [[ "$version" == *-* ]]; then prerelease=(--prerelease); fi
|
||||
gh release create "$GITHUB_REF_NAME" --verify-tag --title "QQQ Mobile $version" \
|
||||
--generate-notes "${prerelease[@]}" release/*
|
||||
@@ -1,163 +0,0 @@
|
||||
name: Scanner Android
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'feature/**', 'fix/**', 'release/**', 'hotfix/**']
|
||||
pull_request:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
emulator:
|
||||
description: Run native UI and real-server scanner end-to-end tests
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: scanner-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Tests, lint, and debug APK
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
|
||||
with:
|
||||
cache-read-only: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Verify source headers, whitespace, imports and JavaScript syntax
|
||||
run: node scripts/ci/check-source-standards.mjs
|
||||
- name: Run no-device Echo fixture guard tests
|
||||
run: node --test scripts/ci/scanner-echo-guards.test.mjs
|
||||
- name: Install required Android SDK packages
|
||||
run: |
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools"
|
||||
- name: Verify shared libraries and scanner host
|
||||
run: >-
|
||||
./gradlew --no-daemon --console=plain
|
||||
:qqq:android:core:testDebugUnitTest
|
||||
:qqq:android:mobileapp:testDebugUnitTest
|
||||
:scanner-host:testDebugUnitTest
|
||||
:scanner-host:lintDebug
|
||||
:scanner-host:assembleDebug
|
||||
- name: Compile opt-in physical QA acceptance
|
||||
run: >-
|
||||
./gradlew --no-daemon --console=plain -PscannerTestBuildType=qa
|
||||
:scanner-host:assembleQa :scanner-host:assembleQaAndroidTest
|
||||
- name: Hash debug APK
|
||||
run: |
|
||||
mkdir -p scanner-host/build/outputs/apk/debug/notices
|
||||
cp scanner-host/build/reports/third-party/debug/{aboutlibraries.json,LICENSE,NOTICE} scanner-host/build/outputs/apk/debug/notices/
|
||||
(cd scanner-host/build/outputs/apk/debug && sha256sum scanner-host-debug.apk notices/* > SHA256SUMS)
|
||||
- name: Upload installable development APK
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: scanner-debug-${{ github.sha }}
|
||||
path: scanner-host/build/outputs/apk/debug/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
- name: Upload test and lint evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: scanner-checks-${{ github.sha }}
|
||||
path: |
|
||||
qqq/android/core/build/test-results/
|
||||
qqq/android/mobileapp/build/test-results/
|
||||
scanner-host/build/test-results/
|
||||
scanner-host/build/reports/
|
||||
retention-days: 14
|
||||
|
||||
emulator:
|
||||
name: Native UI and real-server scanner E2E
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.emulator
|
||||
needs: verify
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
QQQ_CORE_SHA: ecf0f550a0357cec6387c2b54efca128a405a629
|
||||
SCANNER_SERVER_SHA: 10416938724c9663d66f7848ff2b1a63fe635794
|
||||
MAVEN_OPTS: -XX:ActiveProcessorCount=2 -Xmx768m
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
|
||||
- name: Check out the pinned QQQ core source
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: QRun-IO/qqq
|
||||
ref: ${{ env.QQQ_CORE_SHA }}
|
||||
path: .e2e-core
|
||||
persist-credentials: false
|
||||
- name: Check out the pinned real QQQ demo
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: QRun-IO/qbit-mobile-scanners
|
||||
ref: ${{ env.SCANNER_SERVER_SHA }}
|
||||
path: .e2e-server
|
||||
persist-credentials: false
|
||||
- name: Verify and build the isolated source-pinned server fixture
|
||||
env:
|
||||
JAVA_TOOL_OPTIONS: -XX:ActiveProcessorCount=2 -Xmx768m
|
||||
run: |
|
||||
test "$(git -C .e2e-core rev-parse HEAD)" = "$QQQ_CORE_SHA"
|
||||
test "$(git -C .e2e-server rev-parse HEAD)" = "$SCANNER_SERVER_SHA"
|
||||
fixture_m2=$(mktemp -d "$RUNNER_TEMP/scanner-fixture-m2.XXXXXX")
|
||||
core_revision="4.1.0-ci.$QQQ_CORE_SHA"
|
||||
(cd .e2e-core && ./mvnw --batch-mode --no-transfer-progress \
|
||||
-Dmaven.repo.local="$fixture_m2" -Drevision="$core_revision" \
|
||||
-pl qqq-bom,qqq-middleware-api -am clean install)
|
||||
(cd .e2e-server && ./mvnw --batch-mode --no-transfer-progress \
|
||||
-Dmaven.repo.local="$fixture_m2" -Dqqq.version="$core_revision" clean install)
|
||||
(cd .e2e-server && ./mvnw --batch-mode --no-transfer-progress -f demo/pom.xml \
|
||||
-Dmaven.repo.local="$fixture_m2" -Dqqq.version="$core_revision" clean verify \
|
||||
dependency:build-classpath -DincludeScope=runtime -Dmdep.outputFile=target/e2e-classpath.txt \
|
||||
dependency:tree -Dscope=runtime -DoutputFile=target/fixture-dependencies.txt \
|
||||
help:effective-pom -Doutput=target/fixture-effective-pom.xml)
|
||||
node scripts/ci/verify-fixture-provenance.mjs .e2e-server "$fixture_m2" "$QQQ_CORE_SHA" "$SCANNER_SERVER_SHA"
|
||||
- name: Enable hosted-runner hardware acceleration
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
- name: Run emulator checks
|
||||
uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
disable-animations: true
|
||||
script: |
|
||||
./gradlew --no-daemon --console=plain :qqqsampleandroidmobileapp:connectedDebugAndroidTest :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest
|
||||
node scripts/ci/scanner-e2e.mjs .e2e-server
|
||||
- name: Upload emulator evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: scanner-emulator-${{ github.sha }}
|
||||
include-hidden-files: true
|
||||
path: |
|
||||
qqqsampleandroidmobileapp/build/reports/androidTests/
|
||||
qqqsampleandroidmobileapp/build/outputs/androidTest-results/
|
||||
scanner-host/build/native-e2e/
|
||||
.e2e-server/demo/target/fixture-provenance.json
|
||||
.e2e-server/demo/target/fixture-dependencies.txt
|
||||
.e2e-server/demo/target/fixture-effective-pom.xml
|
||||
.e2e-core/**/target/surefire-reports/TEST-*.xml
|
||||
.e2e-server/**/target/surefire-reports/TEST-*.xml
|
||||
retention-days: 14
|
||||
@@ -1,3 +0,0 @@
|
||||
profile: android
|
||||
orb_version: '0.8'
|
||||
default_branch: main
|
||||
@@ -1,6 +1,6 @@
|
||||
# Repository guidance
|
||||
|
||||
Read `README.md`, `docs/SESSION-STATE.md`, and `docs/TODO.md` for current scope. Preserve unrelated changes and follow the user's current authorization for commits, publication, and deployment.
|
||||
Read `README.md`, `docs/SESSION-STATE.md`, and `docs/TODO.md` for current scope; `docs/history/` is historical only. Preserve unrelated changes and follow the user's current authorization for commits, publication, and deployment.
|
||||
|
||||
## Build and tests
|
||||
|
||||
@@ -24,4 +24,4 @@ Never log or place passwords, session cookies, or device tokens in process value
|
||||
|
||||
## Repository automation
|
||||
|
||||
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive, so make no ordinary GitHub writes. Protected `main` on Gitea has `.gitea/workflows/qrun-android-release.yaml` calling the shared `KofTwentyTwo/platform-ci` `android.yaml@qrun-release-v1` (not present in this feature working tree); pull-request checks come from central Gitea policy, not from that release workflow. CircleCI is managed by `kingsrook/qqq-orb` using `.qqq-ci.yml`; preserve that lane and its main-only library publication. The legacy `.github` `Scanner Android` workflow builds a debug APK artifact and never publishes libraries or production releases; it and the scanner release workflow are not a selected release path. Production signing/distribution is deferred: create no keys, tags or releases. Do not replace managed CI with a stale local template.
|
||||
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive, so make no ordinary GitHub writes. `.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. It is not a scanner-APK signing pipeline, and no automatic signing CI is configured. The owner has authorized production signing with the QRun-owned key `qqq-mobile-release` (details and fingerprint in `docs/RELEASING.md`) for signed-APK/MDM distribution. Do not create replacement or additional signing keys, or put key material, passwords or vault item details in the repository; create no tags or releases without current authorization.
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.2 (versionCode 4), candidate
|
||||
|
||||
Not yet built, signed, tagged or published (prepared 2026-11-06). Supersedes the unpublished signed 1.0.1 (versionCode 3) candidate and updates it in place with the same permanent signer.
|
||||
|
||||
- Company sign-out now completes: the provider logout request runs before the scanner host ends its device session, so the browser end-session page opens. Previously the device-session end ran first, removed the QQQ session server-side, and `nativeLogoutData` was never returned; observed on the MC9400 with the production APK against the real Authentik server. (PR 17)
|
||||
- The signed-in user's name is shown after browser (OAuth) sign-in instead of a blank current user. (PR 17)
|
||||
- The scanner host reports the installed app and OS version on every session heartbeat, so scan receipts show the new version after an in-place update. Effective with mobile-scanners server 1.6.0; older servers ignore the keys. (PR 18)
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add the QQQ Mobile scanner host for metadata-defined native apps and processes.
|
||||
|
||||
@@ -23,9 +23,9 @@ The smoke script clears application data only after verifying the target is an e
|
||||
|
||||
## CI and delivery
|
||||
|
||||
Managed CircleCI remains the shared-library test and Maven publication lane. Its Android profile publishes libraries only from `main`, after its checks. Feature branches do not publish.
|
||||
`.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. Feature branches never publish.
|
||||
|
||||
The GitHub **Scanner Android** workflow runs shared-library/scanner JVM tests, scanner lint, and debug APK assembly for pull requests and supported branches. It uploads the development APK with SHA256 and verification reports. **Run workflow** optionally adds native emulator tests and a scanner launch screenshot. This workflow never publishes Maven artifacts, signs a production APK, or deploys to a device/store.
|
||||
The emulator scanner end-to-end suite against a real QQQ server (`scripts/ci/scanner-e2e.mjs`) is not part of CI; run it locally as described in [emulator testing](docs/EMULATOR.md).
|
||||
|
||||
Production signing and distribution require a separately configured release procedure. Do not treat the development APK as a production release.
|
||||
|
||||
|
||||
@@ -1,79 +1,96 @@
|
||||
# QQQ Android
|
||||
|
||||
Native Kotlin/Compose clients for QQQ. The new `scanner-host` application runs server-defined QQQ apps and processes on Android handhelds, initially Zebra TC22.
|
||||
Native Kotlin/Compose clients for QQQ. The `scanner-host` application ("QQQ Mobile", `io.qrun.mobile.scanner`) runs server-defined QQQ apps and processes on Android handhelds, currently Zebra TC22 and MC9400 class devices. It is online only.
|
||||
|
||||
Current progress and open items: [status](docs/SESSION-STATE.md), [checklist](docs/TODO.md), [1.0 finish ledger](docs/FINISH-1.0.md). Superseded status notes are kept under [docs/history](docs/history/README.md).
|
||||
|
||||
## Modules
|
||||
|
||||
- `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 Zebra EMDK barcode capture.
|
||||
- `qqqsampleandroidmobileapp`: historical example application.
|
||||
| Module | Purpose |
|
||||
| --- | --- |
|
||||
| `qqq/android/core` | Metadata and process HTTP client (`QQQRepository` / `NetworkQQQRepository`). |
|
||||
| `qqq/android/mobileapp` | Native app picker, authentication, process runner and widget renderer. |
|
||||
| `scanner-host` | The scanner application: server configuration, device registration/approval gate, Zebra EMDK barcode capture, branding, diagnostics, settings. |
|
||||
| `qqqsampleandroidmobileapp` | Historical example application and the shared native test host. |
|
||||
|
||||
The client supports metadata and process execution. It does not implement the record CRUD/fluent API previously described by this README.
|
||||
The libraries support metadata and process execution. They do not implement a record CRUD/fluent API.
|
||||
|
||||
The scanner host uses the versioned semantic UI API for app discovery, native process screens and actions, plus the registered `mobile-scanners` extension for device enrollment, branding, connectivity and session administration. It requires extension 1.1 or later within major 1 and checks compatibility before login. See [the client contract and supported components](docs/UI-API.md); full web-client feature coverage is not implied.
|
||||
## What the scanner host does
|
||||
|
||||
## First scanner application
|
||||
- **Semantic API only.** All operations use the versioned UI API plus the registered `mobile-scanners` extension (device enrollment, branding, status, sessions). The host requires extension 1.1 or later within major 1 and checks `/qqq/v1/version` compatibility before sending credentials. Optional capabilities (for example raw scan bytes, mobile 1.5) are used only when the same server advertises them. See [UI API contract and supported components](docs/UI-API.md); web-client parity is not implied.
|
||||
- **Server-defined screens.** The server supplies the app, native process steps and the `SCANNER` widget; the APK hard-codes no process name. Unknown required controls stop submission explicitly.
|
||||
- **Device approval.** Each installation enrolls a device that a QQQ administrator must approve; the server enforces approval on every scan.
|
||||
- **Zebra scanning.** Uses the device's EMDK service, acquired only while a scanner widget is visible and resumed, and released on leave. No DataWedge profile setup is needed and DataWedge settings are not persistently modified. Other Android devices use explicit manual entry. See [Zebra scanning](docs/ZEBRA-SCANNING.md).
|
||||
- **Runtime branding.** The shared launcher stays "QQQ Mobile"; after connecting, the host reads public `GET /qqq/v1/qbit/mobile-scanners/branding` (`displayName` up to 80 printable characters, `primaryColor` as `#RRGGBB`, optional HTTPS `logoUrl`). Malformed values fall back to QQQ defaults; logos are fetched without session/device headers; branding resets when the server changes. No per-customer build.
|
||||
- **Connection footer and diagnostics.** The footer shows Checking, Connected or Disconnected from `GET /qqq/v1/qbit/mobile-scanners/status` (reachability only, not sign-in or approval). Tap it, or use the menu, for a full-screen view of the latest 100 sanitized events with All/Problems filters, pause, copy/share/clear. The log holds fixed event codes, HTTP status and latency, never credentials, scan values, paths or response text.
|
||||
- **Echo (local print workflow).** Echo is a server-side semantic process (printer, label size, document and optional copies per scan 1–100, chosen once per run). The client uses only generic native controls; printing happens on the server side and nothing printer-specific is installed on the scanner.
|
||||
- **Settings.** Orientation (portrait by default), foreground location reporting (default on, Android permission required, scanning works without it), optional status wallpaper chooser, and About QQQ Mobile (version, public links, bundled open-source licenses).
|
||||
- **Managed configuration and wallpaper.** The app declares Android app restrictions (`qqq_server_url`, `qqq_orientation`) and an optional live status wallpaper. See [device provisioning](docs/DEVICE-PROVISIONING.md); no company profile or MDM rollout has been verified.
|
||||
|
||||
Use the companion `qbit-mobile-scanners` server/demo with its current QQQ source default (`4.1.0-RC.21`; see that repository's POM). A local server may run an older qualified core; the host checks the advertised UI API and extension versions before login. The demo uses QQQ TABLE_BASED username/password authentication. Credentials are exchanged through the native QQQ session endpoint; the password is not stored.
|
||||
### Online-only behavior
|
||||
|
||||
1. Start the companion demo and install the debug APK.
|
||||
2. Run `adb reverse tcp:8080 tcp:8080` for a USB-connected TC22.
|
||||
3. Open QQQ Mobile, use `http://127.0.0.1:8080/`, and sign in.
|
||||
4. An administrator approves the displayed device ID in QQQ.
|
||||
5. Tap **Check approval**, select the server-provided mobile app, and open **Scan capture**.
|
||||
6. Scan a barcode and wait for **Saved scan** before scanning again. Manual entry is available for development and non-Zebra devices.
|
||||
There is no offline queue. One submitted scan is held in memory until a complete receipt returns the matching `acknowledgedEventId`, `scanSaved: true` and a nonblank `scanId`. After an uncertain response, **Retry same request** keeps the event ID and original timestamp and the server deduplicates; further scans wait for confirmation or explicit abandonment. Leaving then warns that the scan may already be saved and attempts server cancellation, with **Leave anyway** as the explicit fallback. Sign-out clears the pending request; app termination loses in-memory state.
|
||||
|
||||
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.
|
||||
Administrators can inspect sessions, end them and request live viewing of the QQQ app. Viewing shows an informational indicator on the handheld; password screens and background apps cannot be mirrored; whole-device sharing needs Android consent. See [session behavior and limits](docs/UI-API.md#session-activity-and-screen-viewing).
|
||||
|
||||
## Zebra hardware scanning
|
||||
Enrollment credentials are encrypted with the Android Keystore under `noBackupFilesDir`, scoped to the server, and backup is disabled. Redirects are disabled. **Release builds reject HTTP; only development builds allow it for local testing.**
|
||||
|
||||
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.
|
||||
## Prerequisites
|
||||
|
||||
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.
|
||||
- Java 21, Android SDK platform 35 with build tools 34/35, and the included Gradle 8.7 wrapper. Set `ANDROID_HOME` or an ignored `local.properties`.
|
||||
- A QQQ server exposing the semantic UI API (core 1.8 or later within major 1; OAuth2 sign-in needs 1.10) and the `mobile-scanners` extension. The companion server/demo is `qbit-mobile-scanners` (its POM pins the QQQ source default, currently `4.1.0-RC.21`; a local server may run an older qualified core, which the host checks before login). Local demo authentication is TABLE_BASED username/password, exchanged through the native QQQ session endpoint; the password is not stored.
|
||||
|
||||
## Customer branding and diagnostics
|
||||
## Installing on a device
|
||||
|
||||
The shared launcher name/icon remain QQQ Mobile. On connecting to a server, the host reads public `GET /qqq/v1/qbit/mobile-scanners/branding` (`displayName`, `primaryColor`, optional `logoUrl`). The name and Material theme apply to login, device approval, and native app screens. Names are limited to 80 printable characters and colors to `#RRGGBB`; malformed values fall back to QQQ defaults. Button foregrounds choose readable black/white contrast. Optional logos must use HTTPS, are fetched without session/device headers or cookies, and cannot prevent login if unavailable. Responses are size-limited, redirects are disabled, and branding is reset when changing servers. Reconnect to pick up server branding changes; no APK rebuild is needed.
|
||||
Two distinct paths. Do not mix their APKs or assumptions.
|
||||
|
||||
The persistent footer shows **Checking**, **Connected**, or **Disconnected** with the last check time. It checks `GET /qqq/v1/qbit/mobile-scanners/status` every five seconds while foregrounded and on network changes; `Connected` means a valid `{ "status": "ok" }` response, not sign-in or device approval. Tap the footer, or use the Diagnostics entry in the menu, to open a full-screen diagnostics view with a server connection summary (reachability only), the recent activity newest first in local time, **All** and **Problems** filters, **Pause updates** and **Resume live updates**, **Copy**, **Share** and **Clear**, and **Back**. While it is open, process input stays blocked. The in-memory log holds the latest 100 fixed event codes with timestamps, HTTP status, and latency for branding, connection checks, native QQQ calls (including login/process requests), and device enrollment/status requests. A transport failure has no HTTP status; an HTTP response retains its status, including 401/403/500 errors. The log contains no credentials, scan values, request paths, queries, raw response text, or exception messages. Changing servers clears the displayed diagnostics. Monitoring never queues or retries a scan.
|
||||
### Local demo (development-signed debug APK, HTTP, same workstation)
|
||||
|
||||
## Online-only behavior
|
||||
For trying the scanner against a local server. The debug APK uses the development certificate and is not a release.
|
||||
|
||||
QQQ administrators can inspect session activity, end sessions, and request live viewing of the QQQ app from a session record. Viewing displays an informational indicator on the handheld; the app has no operator pause or stop control. Password/login screens and background apps cannot be mirrored. Optional whole-device sharing still requires Android consent and honors Android's system stop controls. Foreground location reporting defaults on and can be disabled in Settings. It requires Android permission and enabled device location providers; scanning works without it. See [session behavior and limits](docs/UI-API.md#session-activity-and-screen-viewing).
|
||||
1. Start the companion demo server and build the debug APK (below).
|
||||
2. Install it with `adb install -r`, naming the device with `-s`.
|
||||
3. Forward the device port to the server: `adb -s <device> reverse tcp:8080 tcp:<server port>`. This works over USB or paired wireless ADB and must be repeated after the connection drops; it is a workstation-attached demo, not autonomous LAN rollout.
|
||||
4. Open QQQ Mobile, connect to `http://127.0.0.1:8080/`, and sign in.
|
||||
5. An administrator approves the displayed device in QQQ (the mobile device approval process); tap **Check approval**, choose the server's mobile app, and open **Scan capture** or Echo.
|
||||
6. Wait for **Saved scan** (or Echo's acknowledgment) before scanning again.
|
||||
|
||||
Open **Settings → About QQQ Mobile** for the app version, Query mascot, [Dogfooding](https://www.qrun.io/dogfooding), [Query](https://www.qrun.io/query) and bundled open-source licenses. Public website links open in the external browser without adding session or device information.
|
||||
See [emulator testing](docs/EMULATOR.md) for an emulator demo, synthetic scans and the disposable E2E variant.
|
||||
|
||||
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.
|
||||
### Signed APK (direct install / MDM)
|
||||
|
||||
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.
|
||||
Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). The published download remains the [signed 1.0.0 preview](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease, forge sign-in required). The signed 1.0.1 candidate has passed trusted-HTTPS enrollment, physical scanning, navigation and an in-place update on the MC9400, preserving login and enrollment. Its publication, native Authentik qualification and final dependency-cohort checks remain open; a 1.0.2 (versionCode 4) candidate with company sign-out, user-name and heartbeat-version fixes is prepared but not yet built (see [CHANGELOG](CHANGELOG.md)). Development-signed installations require an explicit migration to the production signer.
|
||||
|
||||
## Build
|
||||
|
||||
Requirements: Java 21, Android SDK platform35, build tools34/35, and the included Gradle8.7 wrapper. Configure `ANDROID_HOME` or ignored `local.properties`.
|
||||
## Build and test
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat :scanner-host:testDebugUnitTest :scanner-host:assembleDebug :scanner-host:lintDebug
|
||||
.\gradlew.bat :qqq:android:core:testDebugUnitTest :qqq:android:mobileapp:testDebugUnitTest :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleDebug
|
||||
```
|
||||
|
||||
APK: `scanner-host/build/outputs/apk/debug/scanner-host-debug.apk`.
|
||||
Debug APK: `scanner-host/build/outputs/apk/debug/scanner-host-debug.apk`. `connectedDebugAndroidTest` needs a device or emulator. Scanner flow changes also use the separate E2E variant (`:scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest`, then `node scripts/ci/scanner-e2e.mjs ../qbit-mobile-scanners`) as described in [AGENTS.md](AGENTS.md) and [emulator testing](docs/EMULATOR.md). Device-level acceptance evidence is described in [device testing](docs/DEVICE-TESTING.md).
|
||||
|
||||
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.
|
||||
Limits: JVM, emulator and synthetic-scan tests do not prove physical trigger, lifecycle, label printing or provider behavior; those need hardware. Version-one scope excludes offline queueing and background synchronization.
|
||||
|
||||
See [emulator testing](docs/EMULATOR.md) for a local interactive demo, synthetic scanner broadcasts, and the disposable-emulator smoke check.
|
||||
## CI and automation
|
||||
|
||||
## CI and delivery
|
||||
|
||||
The primary forge for source, reviews and checks is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive with no ordinary writes. The protected `main` branch on Gitea contains `.gitea/workflows/qrun-android-release.yaml`, which calls the shared `KofTwentyTwo/platform-ci` Android workflow (`android.yaml@qrun-release-v1`); that file is not in this feature working tree. Pull-request checks come from central Gitea policy, not from that release workflow. Legacy CircleCI and `.github` workflow files remain in the tree and are not all retired; the managed CircleCI profile still describes shared-library verification and its main-only Maven publication. Run the build commands above locally. See [emulator testing](docs/EMULATOR.md) for the isolated test package and contract checks, and [FINISH-1.0](docs/FINISH-1.0.md) for current progress.
|
||||
|
||||
Scanner APK signing and distribution are deliberately deferred and no distribution channel is selected; the retained CircleCI lane keeps its existing library publication; the `.github` scanner release automation is a candidate only (see [releasing](docs/RELEASING.md)). See [Contributing](CONTRIBUTING.md), [Security](SECURITY.md), and [Changelog](CHANGELOG.md).
|
||||
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive and receives no ordinary writes. `.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. It is not a scanner-APK signing or release pipeline. Shared CI design is out of scope for this repository's local use: the demo above needs none of it.
|
||||
|
||||
## Extension points
|
||||
|
||||
`LocalAuthenticatedContent` allows a host to gate authenticated QQQ content. `LocalCustomWidgetRenderer` lets the host render additional metadata-defined widgets without replacing the standard renderer. `NetworkQQQRepository` accepts a server-scoped header provider; credentials never enter process values.
|
||||
`LocalAuthenticatedContent` lets a host gate authenticated QQQ content. `LocalCustomWidgetRenderer` lets the host render additional metadata-defined widgets without replacing the standard renderer. `NetworkQQQRepository` accepts a server-scoped header provider; credentials never enter process values.
|
||||
|
||||
The scanner host supports TABLE_BASED authentication and server-advertised OAuth2 browser sign-in, including Authentik. Updated OAuth2 requires UI API1.10 native-session login and nonce-validation capabilities, the explicit REQUIRED nonce policy and callback registration; it is being qualified and does not infer support from the provider name. Unsupported servers cannot downgrade browser login; TABLE sign-in is unchanged. See [authentication setup and verification](docs/AUTHENTICATION.md). Existing shared-library Auth0 support remains intact.
|
||||
The scanner host supports TABLE_BASED authentication and server-advertised OAuth2 browser sign-in, including Authentik. OAuth2 needs UI API 1.10 native-session login and nonce-validation capabilities, the REQUIRED nonce policy and callback registration, and never infers support from the provider name; unsupported servers cannot downgrade. Real desktop Authentik acceptance has passed; native on-device provider acceptance is still open. See [authentication setup and verification](docs/AUTHENTICATION.md). Existing shared-library Auth0 support remains intact.
|
||||
|
||||
## Source map
|
||||
|
||||
- Managed configuration: [`ManagedConfiguration.kt`](scanner-host/src/main/java/io/qrun/mobile/scanner/ManagedConfiguration.kt), [`app_restrictions.xml`](scanner-host/src/main/res/xml/app_restrictions.xml)
|
||||
- Status wallpaper: [`QqqStatusWallpaperService.kt`](scanner-host/src/main/java/io/qrun/mobile/scanner/QqqStatusWallpaperService.kt)
|
||||
- Orientation policy: [`OrientationPolicy.kt`](scanner-host/src/main/java/io/qrun/mobile/scanner/OrientationPolicy.kt)
|
||||
- Manifest: [`AndroidManifest.xml`](scanner-host/src/main/AndroidManifest.xml)
|
||||
- Company profile generator: [`deployment/company-profile`](deployment/company-profile/), [COMPANY-PROFILE.md](docs/COMPANY-PROFILE.md)
|
||||
|
||||
See also [Contributing](CONTRIBUTING.md), [Security](SECURITY.md) and [Changelog](CHANGELOG.md).
|
||||
|
||||
## License
|
||||
|
||||
Licensed under Apache-2.0; see [LICENSE](LICENSE) and [NOTICE](NOTICE). Historical project-owned source headers and the IDE copyright template have been reconciled with the repository's January 2026 license migration. Existing copyright ownership and third-party notices are preserved.
|
||||
Licensed under Apache-2.0; see [LICENSE](LICENSE) and [NOTICE](NOTICE). Existing copyright ownership and third-party notices are preserved.
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Company provisioning for Zebra handhelds
|
||||
|
||||
Research checked 2026-10-31. Target: Zebra TC22, Android 14, DataWedge 13.0.325, QQQ Mobile package `io.qrun.mobile.scanner`. This is a design recommendation, not an installed profile or a completed hardware qualification. No device configuration, enrollment, reset or runtime changes were made for this research. Production signing and distribution remain deferred.
|
||||
Research checked 2026-10-31. Target: Zebra TC22, Android 14, DataWedge 13.0.325, QQQ Mobile package `io.qrun.mobile.scanner`. This is a design recommendation, not an installed profile or a completed hardware qualification. No device configuration, enrollment, reset or runtime changes were made for this research. No company profile, EHS, OEMConfig or MDM configuration has been applied to or verified on any device, and no fleet rollout is claimed. The scanner application itself is current source; see [Implemented in the app](#implemented-in-the-app). The signed-APK/MDM channel is chosen but not rolled out ([RELEASING](RELEASING.md)).
|
||||
|
||||
## Recommended approach
|
||||
|
||||
@@ -71,14 +71,35 @@ These are design inputs, not a finalized XML schema or implemented managed-confi
|
||||
| Profile identity | Customer/profile ID, revision, target model/Android/MX range, rollout ring, rollback revision |
|
||||
| Device identity | Assigned short name, description, asset ID; explicitly map Android device name and QQQ device record |
|
||||
| Network/trust | Network-policy reference, certificate/credential delivery reference, proxy/DNS/time policy; no embedded passwords in repository fixtures |
|
||||
| Apps | Package IDs, approved version/channel, artifact reference/checksum, signing identity once decided, required companion activities |
|
||||
| Apps | Package IDs, approved version/channel, artifact reference/checksum, signing certificate fingerprint (see [RELEASING](RELEASING.md)), required companion activities |
|
||||
| Launcher | Title, logo/wallpaper asset references, ordered app list/folders, multi-app or kiosk policy, admin recovery reference |
|
||||
| Scanner | DataWedge profile association, expected intent/output contract, required decoders; preserve the verified QQQ scan transport |
|
||||
| App bootstrap | HTTPS QQQ server URL, tenant/branding discovery hints, supported configuration revision, optional orientation policy |
|
||||
| App bootstrap | HTTPS QQQ server URL (`qqq_server_url`) and optional orientation policy (`qqq_orientation`) are implemented; tenant/branding hints and a configuration revision are not |
|
||||
| Capabilities | Camera/location policy, diagnostics visibility, screen-sharing policy, required permission decisions; no blanket grants |
|
||||
| Operations | Update window, reporting/retention policy, support contact, rollback and recovery procedure references |
|
||||
|
||||
QQQ can support Android managed app configurations without customer-specific builds, but the app must declare and consume the configuration schema. Source inspection found no RestrictionsManager/app-restrictions declaration or wallpaper component in the current scanner host. This is proposed work. Managed settings should supply bootstrap/policy only; business screens and processes remain on the versioned QQQ semantic API. Reconcile admin-managed values with user preferences explicitly. [Android managed configurations](https://developer.android.com/work/managed-configurations).
|
||||
Managed settings supply bootstrap/policy only; business screens and processes remain on the versioned QQQ semantic API. Admin-managed values are reconciled with user preferences explicitly (see below). [Android managed configurations](https://developer.android.com/work/managed-configurations).
|
||||
|
||||
## Implemented in the app
|
||||
|
||||
The scanner host source now includes the pieces below. They are implemented and unit-tested; none has been delivered through an EMM/MDM or StageNow profile on a device.
|
||||
|
||||
**Managed configuration** ([`app_restrictions.xml`](../scanner-host/src/main/res/xml/app_restrictions.xml), declared in the manifest through `android.content.APP_RESTRICTIONS`; logic in [`ManagedConfiguration.kt`](../scanner-host/src/main/java/io/qrun/mobile/scanner/ManagedConfiguration.kt)). Exactly two keys:
|
||||
|
||||
| Key | Type | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `qqq_server_url` | string | Must be an HTTPS origin with no credentials, path, query or fragment, at most 2048 characters; HTTP is never accepted, even in debug builds. With no saved server it prefills the connect form. A different saved server is never switched silently: Settings offers "Use administrator's server", which runs the existing confirm, end-session and sign-out path. |
|
||||
| `qqq_orientation` | choice | One of `SYSTEM`, `PORTRAIT`, `LANDSCAPE`, `REVERSE_PORTRAIT`, `REVERSE_LANDSCAPE`. Applied as a default only when the managed value changes; a later local choice stands until the managed value changes again. |
|
||||
|
||||
Invalid values are rejected, not defaulted, and only the key name is recorded. The keys must stay in step with the [company profile generator](../deployment/company-profile/) and [COMPANY-PROFILE](COMPANY-PROFILE.md).
|
||||
|
||||
**Optional status wallpaper** ([`QqqStatusWallpaperService.kt`](../scanner-host/src/main/java/io/qrun/mobile/scanner/QqqStatusWallpaperService.kt), with a Settings entry and preview). A live wallpaper service bound only by the system (`BIND_WALLPAPER`). It is inactive until a person confirms it in Android's live-wallpaper chooser; nothing is applied silently and no wallpaper permission is requested. It paints the company name and, only on the unlocked home screen, the approved session display name, the current network addresses and a time; the lock screen, preview, screen-off and invisible states show brand only. The snapshot is in memory only and withdrawn when it ages out. EHS compatibility with this wallpaper is unproven. Verified on a TC22 only as the brand-only system preview; it was never applied there.
|
||||
|
||||
## Current limits
|
||||
|
||||
- Whether a given EMM/OEMConfig can deliver these two restriction keys, and the EHS/StageNow/MDM profiles themselves, are unverified.
|
||||
- A static company wallpaper plus the in-app status remains the recommended first step; the live wallpaper is optional.
|
||||
- Device-owner enrollment, kiosk mode and rollout remain explicit future actions.
|
||||
|
||||
## Safe qualification and rollout
|
||||
|
||||
@@ -86,4 +107,4 @@ QQQ can support Android managed app configurations without customer-specific bui
|
||||
2. Produce a versioned, customer-parameterized profile with no repository secrets. Review settings and exclusions before generating installable artifacts. Keep development debugging available on a development-only profile.
|
||||
3. Test on a spare TC22 first: network/trust, installation, app approval, scan delivery, camera uploads, location behavior, Home/Back/menu/logout and orientation. Check reboot and network-change behavior, plus management connectivity loss.
|
||||
4. Verify restrictions and recovery together: app allowlist, settings access, permission denial, policy update, rollback and retained data. Test the dynamic status option separately, including logout/remote revocation, stale state and lock-screen visibility.
|
||||
5. After signing/distribution decisions, qualify signed upgrades and management deployment in a small pilot ring. Expand only after documented acceptance. Device-owner enrollment/reset and production kiosk activation remain explicit rollout actions, not part of this research.
|
||||
5. With the signed-APK/MDM channel chosen, qualify signed upgrades and management deployment in a small pilot ring. Expand only after documented acceptance. Device-owner enrollment/reset and production kiosk activation remain explicit rollout actions, not part of this research.
|
||||
@@ -1,34 +1,19 @@
|
||||
# Finish ledger for 1.0 (current: November 3, 2026)
|
||||
# Finish ledger for 1.0
|
||||
|
||||
Nothing here is a 1.0 release. History is in SESSION-STATE.md.
|
||||
As of 2026-11-04. A signed 1.0.0 preview is published (prerelease `scanner-v1.0.0`); there is no stable or fully qualified 1.0. Older ledgers are in [docs/history](history/README.md); the checklist is [TODO](TODO.md).
|
||||
|
||||
## Source published (not merged, not released)
|
||||
|
||||
- Android `f72b90d08cf29d2785cef1d5c5e7eeaf75f9afd2`: signed application code published in PR 10 (read back over SSH before this documentation update). Gitea CI 3480 succeeded: unit-test, device-test, package, secrets, gate. A clean git merge-tree with `main` b75fdac was verified; nothing was merged. Pull requests #8 and #10 remain open. This documentation update leaves runtime code unchanged.
|
||||
- Server `c0e27b36fd16c00eaca3451af3df43f4deedada1`: signed and published in Gitea PR 7, including the verified QQQ 4.1.0-RC.21 dependency update and Record Files test fix.
|
||||
- Generation copies: signed `aac68907e67906aeef4e0cd687d28bc98b738f8c` on `feature/echo-generation-copies` (base PR 343 head `8f473fa`), 13 paths, 21/21 manifest match, real PostgreSQL 17 migration passes. Owner integration and publication are not acknowledged.
|
||||
|
||||
## Implemented and installed (normal TC22 and local server 57832)
|
||||
|
||||
- APK `4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E`: same signer, no data clear. Connected, Apps/Settings, portrait, location on and the brand-only wallpaper preview were verified. No wallpaper or company profile was applied. The device shows Echo setup, Copies 1.
|
||||
- Server on 57832: UI API 1.10, mobile-scanners 1.5, printing 2.3, mobile-record-files 1.0, with the original database, accounts, approval and printer. Echo Copies per scan (default 1, range 1-100, one job per scan). Its core is not upgraded to RC.21.
|
||||
|
||||
## Tested
|
||||
|
||||
- Android: 222 JVM tests, lint 0 errors/0 warnings (2 info), debug/E2E/test APK builds, 23 Node tests, 255-file standards.
|
||||
- Disposable TC22 Echo ten stages, 109/109 semantic requests; inputs and printing are simulated.
|
||||
- Server RC.21 gate PASS: 377 unique tests (Generation 179, Printing bridge 16, scanner 53, demo 8, mobile Record Files 18, Echo 68, Echo demo 35), zero failures/errors/skips, Checkstyle PASS, plus 15 real admin-browser E2E on public RC.21. Earlier MySQL timeouts did not recur. Evidence: `C:/Users/james/.cache/qqq-rc21-copies-gate-076d059d8d0641d9a85874ec64008def/verified-summary.json`.
|
||||
- Honest scope: this is public core RC.21 plus qualified owner inputs. The Printing-bridge tests still resolve three private `pu507` libraries through the owner Printing POM, and other owner qbits are private inputs. It is not a fully public or released cohort.
|
||||
- Real Authentik desktop acceptance passes with strict TLS (nonce, A, logout, fresh B). The native harness is compiled with 8 launcher guards passing.
|
||||
|
||||
## Still open (none of these is claimed done)
|
||||
|
||||
- Generation: canonical owner integration, then public reproducible inputs for the owner qbits and a coherent published cohort.
|
||||
- Source: primary CI on the final source, merge, review completion and release.
|
||||
- Manual on the TC22 (operator remote): physical trigger, scan value/type, AIM, label count, rescan, photo capture/upload, GPS, projection, company profile/wallpaper deployment.
|
||||
- Native Authentik on the device: temporary CA installation, PIN interaction and the real callback. No trust bypass.
|
||||
- Signing and distribution: explicitly last, by user decision.
|
||||
| Area | State | Evidence / limit |
|
||||
| --- | --- | --- |
|
||||
| Source | Merged to protected `main` | Application source for the tested APKs; Gitea workflow present |
|
||||
| Local demo | Accepted | Server on port 57832; TC22 development APK; operator-reported manual acceptance ALL PASS 2026-11-04 incl. label count/fidelity, camera, GPS, screen viewing (not instrumented, no per-case artifacts) |
|
||||
| Echo | Local semantic flow qualified | Printing and inputs simulated in disposable runs; physical label count/fidelity operator-reported pass (not instrumented) |
|
||||
| MC9400 | Demo-configured and approved | Physical trigger scan to backend not yet verified |
|
||||
| Signed APK | Published as signed preview | [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), 1.0.0 / versionCode 2; APK and `SHA256SUMS` only, download-verified; installed and launched once on an MC9400 before the demo switch; no end-to-end qualification |
|
||||
| Release | Preview only, not stable | Needs HTTPS server and device qualification (enrollment, scanning, in-place update) before stable; no signing CI |
|
||||
| Authentik | Desktop passes; on-device callback unverified | No separate native provider/callback evidence |
|
||||
| Dependencies | Qualified inputs, not a public cohort | Generation, Record Files, Scripting owner inputs behind the local server |
|
||||
| MDM / company profile | Prepared, unverified | Nothing applied on a device |
|
||||
|
||||
## Pointers
|
||||
|
||||
Runtime `C:/Users/james/.cache/qqq-local-all-qbits-current.txt`; native proof `scanner-host/build/native-device-e2e/echo-proof.json`; Generation handoff `C:/Users/james/.cache/qqq-generation-copies-handoff.md`; Authentik `docs/REAL-AUTHENTIK-ACCEPTANCE.md`.
|
||||
Release procedure and key custody (no secrets): [RELEASING](RELEASING.md). Installation: [INSTALLATION](INSTALLATION.md). Authentik: [REAL-AUTHENTIK-ACCEPTANCE](REAL-AUTHENTIK-ACCEPTANCE.md). Physical scanner evidence: [PHYSICAL-TC22-ACCEPTANCE](PHYSICAL-TC22-ACCEPTANCE.md).
|
||||
@@ -0,0 +1,84 @@
|
||||
# Installing QQQ Mobile directly
|
||||
|
||||
Use this for the **production-signed** APK on a small number of devices. Move to MDM later (see [RELEASING](RELEASING.md)). Automated scanner-APK signing/publication is not configured; that is not a blocker to direct installation.
|
||||
|
||||
This guide is not the local demo. The demo uses the development-signed debug APK, plain HTTP and `adb reverse` port forwarding to a workstation server; see [README](../README.md#installing-on-a-device). The two builds have different signers and cannot update each other.
|
||||
|
||||
**Current release status (November 5, 2026):** the only published release is the signed preview 1.0.0 below. A signed **1.0.1 (versionCode 3) candidate** exists but is **not published and has no download link**; this guide does not point to one. Use the table below for what is actually hosted. It is superseded by the planned **1.0.2 (versionCode 4)** candidate (not yet built; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4)). When a newer version is published, use the file, checksum and link given by the release owner, and verify the same signer.
|
||||
|
||||
| Candidate (not published) | Value |
|
||||
| --- | --- |
|
||||
| Version | 1.0.1, versionCode 3 |
|
||||
| APK SHA-256 | `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F` (15015419 bytes) |
|
||||
| Signing certificate SHA-256 | Same as below (`BDDB2D2F...5B17A9FF`) |
|
||||
|
||||
Details and qualification status: [RELEASING](RELEASING.md).
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Source | [Signed preview release scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease; forge sign-in required) |
|
||||
| File | `QQQ-Mobile-1.0.0.apk`, with `SHA256SUMS` on the same release page |
|
||||
| Application ID | `io.qrun.mobile.scanner` |
|
||||
| Version | 1.0.0, versionCode 2 |
|
||||
| APK SHA-256 | `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51` |
|
||||
| Signing certificate SHA-256 | `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF` |
|
||||
|
||||
The same APK is used for every customer; branding comes from the server at runtime. 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.
|
||||
|
||||
**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 a customer **HTTPS** QQQ server URL with a trusted certificate that the device can reach. Release builds reject HTTP, and certificate checks must not be weakened. Use the address you are given (`https://<your-server>`); do not guess one. Local-network qualification used a temporary private network and is not a customer deployment.
|
||||
- You have the QQQ credentials for the sign-in the server offers. None are in this guide.
|
||||
|
||||
## Install: copy the file (recommended)
|
||||
|
||||
1. Copy the verified `QQQ-Mobile-1.0.0.apk` to the device's **Downloads** folder.
|
||||
2. Open **Files**, go to Downloads, and tap the APK.
|
||||
3. If Android asks, allow that Files app to install apps, then continue. You can turn the permission off again afterward.
|
||||
|
||||
## Install: USB debugging (alternative)
|
||||
|
||||
Requires USB debugging enabled on the device, a computer the device has been asked to trust, and [Android Platform Tools](https://developer.android.com/tools/adb).
|
||||
|
||||
```powershell
|
||||
adb devices
|
||||
adb -s DEVICE_SERIAL install -r QQQ-Mobile-1.0.0.apk
|
||||
```
|
||||
|
||||
Replace `DEVICE_SERIAL` with the serial of the device you chose from `adb devices`. Always name the target with `-s`. Do not add `-d` (downgrade) or `-t`.
|
||||
|
||||
## First launch and enrollment
|
||||
|
||||
1. Open **QQQ Mobile** and enter the HTTPS server URL, then tap **Connect**.
|
||||
2. Sign in with the method the server shows.
|
||||
3. The device enrolls and shows a **Device ID** with status pending.
|
||||
4. A QQQ administrator reviews the device, sets its name and approves it in QQQ.
|
||||
5. On the device, tap **Check approval**, then open the server's apps (**Apps**).
|
||||
|
||||
Grant location when Android asks; scanning works without it. Zebra devices with EMDK need no DataWedge profile set up by hand ([Zebra scanning](ZEBRA-SCANNING.md)).
|
||||
|
||||
## Confirm the first new install
|
||||
|
||||
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**).
|
||||
- Navigation to **Apps** and back works.
|
||||
- Echo: choose the printer, document and copies once. The server does the printing.
|
||||
|
||||
Printer clients belong on the server side. No printer driver goes on the scanner.
|
||||
|
||||
## Updates
|
||||
|
||||
For a later build signed by the same key with a higher versionCode, run the same `adb -s DEVICE_SERIAL install -r QQQ-Mobile-<version>.apk` command. Data is preserved.
|
||||
|
||||
## Devices running the development-signed build
|
||||
|
||||
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
|
||||
|
||||
- Android Debug Bridge, [installing and updating apps](https://developer.android.com/tools/adb#move) and [enabling USB debugging](https://developer.android.com/tools/adb#Enabling): <https://developer.android.com/tools/adb>
|
||||
@@ -0,0 +1,46 @@
|
||||
# Record browsing and photo UX — November 5
|
||||
|
||||
The existing scanner application now supports permitted tables, grouped record details, related records and record-linked photo capture through standard semantic APIs. The companion Echo demo supplies 1,000 synthetic orders, 12,000 items and 3,000 history entries.
|
||||
|
||||
Source review: [Android PR 15](https://git.kof22.com/QRun-IO/qqq-android/pulls/15), paired with [scanner/server PR 19](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/pulls/19).
|
||||
|
||||
## User flow
|
||||
|
||||
Open **Test Data → Test Orders**, search or filter, and select a record. Back preserves page/search/filter context. A fixed identity heading sits above Details, Items, History and Photos. Groups collapse and can be selected from Sections. Related items show concise reference/quantity/SKU labels; timestamps include timezone context and server currency/units are preserved.
|
||||
|
||||
Use **Actions → Add photo → Take photo / Choose photo**. The selected record remains visible in the chooser. Saved attachments appear under Photos with review status. Available images have thumbnails and previews; quarantined originals remain unavailable. The client follows the server's receipt: a host that admits approved-device captures immediately returns `AVAILABLE`, so the app shows "Saved photo."; hosts that retain quarantine return `QUARANTINED` ("Awaiting server review."). The maintained Echo host now uses Record Files' scoped `quarantineCapturedFiles` setting. The app has no admission policy of its own and no client change was needed. Photos already uploaded stay quarantined until the server releases them; the three earlier demo captures were released through the normal audited admin process. Photo transfer remains online-only, idempotent and protected by parent permissions and device/session authorization.
|
||||
|
||||
The signed MC9400 app's gallery upload returned "Saved photo." for a new synthetic PNG; server readback verified `AVAILABLE`, the intended parent, media type, byte count and SHA256. Updated native acceptance on the TC22 also passed synthetic camera/gallery uploads, the newly captured photo's thumbnail and full preview, parent scoping, rotation and forced-logout cleanup. All requests passed semantic API checks; the disposable app was removed afterward. This is not a camera-hardware qualification. The captured-photo policy still needs its public dependency release.
|
||||
|
||||
## Verification
|
||||
|
||||
- 232 unit tests: core 52, mobileapp 57, host 123; zero failures/errors/skips. Scanner lint: zero errors/warnings. Debug, E2E and instrumentation APK builds passed; eight runner guard tests passed.
|
||||
- Isolated MC9400 rich-record native acceptance passed paging, groups, related queries/navigation, search/filter/Back preservation, camera/gallery persistence, approved preview, parent-scoped gallery, rotation, pending-photo navigation protection and forced-logout cleanup. All HTTP requests matched the semantic API. Existing process-photo acceptance also passed.
|
||||
- The final date fallback handles default `%s` metadata and differing raw/display JSON values; its unit/lint/debug-build gate passed. Actual TC22 inspection confirmed localized dates/timezones, currency, Actions/capture chooser, direct tabs and order 26 → Android Back → page two. The full native suite preceded this date-only follow-up.
|
||||
- Updated the ordinary TC22 app in place with matching signer and installed bytes verified: SHA-256 `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`. No app-data reset, replacement login or separate manual QA app was needed.
|
||||
|
||||
Native camera/gallery inputs in automated acceptance were synthetic. This visual pass did not capture an operator photo, print a label or qualify physical GS1 reproduction. The November 4 operator report remains accepted separately.
|
||||
|
||||
## Actual TC22 screenshots
|
||||
|
||||
All displayed records are synthetic. These are screenshots of the installed app, not mockups.
|
||||
|
||||
| Record and actions | Capture chooser |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| Related items | Related history |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| Saved-photo empty state | Table after Android Back |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## Implementation boundaries
|
||||
|
||||
Record Files intentionally denies aggregate counts; photo pages use bounded queries with one lookahead row. Downloads use the owner's authenticated tracked-delivery endpoint, exact size/hash verification, same-origin credentials, no redirects, bounded bitmap decoding and no image disk cache. Hidden fields stay out of native summaries/search.
|
||||
|
||||
Pending-upload Back handling is registered inside the record destination, after NavHost's callback. Configuration disposal does not clear retained upload guards; authentication teardown does. Tests use a disposable application ID, fresh temporary database and isolated accounts, preserving normal device/demo data.
|
||||
|
||||
No new business routes, generic screen framework, offline queue, raw record editing, production signing or artifact publication is introduced. Remaining qualification is tracked in [TODO](TODO.md).
|
||||
@@ -1,43 +1,99 @@
|
||||
# Scanner APK releases
|
||||
# Scanner APK release readiness
|
||||
|
||||
Status: **no distribution channel is selected.** Everything below describes a candidate GitHub-based APK workflow that is prepared but not chosen; the primary forge is Gitea (`git.kof22.com/QRun-IO`) and GitHub is a shadow/archive, so treat the GitHub environment, tag and release steps as design notes to be re-decided, not instructions to follow now. The signing identity, complete native acceptance, final security review and first release are not yet qualified. Current installed demo APKs use a development certificate. Do not label them production releases.
|
||||
## Current status (November 5, 2026)
|
||||
|
||||
The [November 1 independent automation review](RELEASE-AUTOMATION-REVIEW.md) found no actionable finding in the exact reviewed workflow/build files. External GitHub protections, the production identity and actual signed-device acceptance remain unverified.
|
||||
- **Published:** the only published release is still [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), a **signed preview** (Gitea prerelease, not a stable release). It is unchanged.
|
||||
- **Candidate, not published:** QQQ Mobile **1.0.1 (versionCode 3)** is a signed candidate. Its release qualification is incomplete, and no tag or download link is available yet.
|
||||
- **Signer:** the candidate is signed with the existing permanent QRun key (certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`). No new or replacement key was created; the existing recovery backup was verified earlier.
|
||||
- **Candidate APK:** SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes.
|
||||
- **Source:** Android PR 15 `ab6e44c`, merged as `c782aaaaba3f62422232436a9000af9d4b38fc82` with the same app-source tree. Installed APK bytes, signature, non-debuggable flag and 16 KiB alignment were verified. `lintRelease` reported 0 errors, 0 warnings and 5 informational findings; the release build passed.
|
||||
- **Distribution:** maintainers use the existing manual signing path for direct APK/MDM distribution. Automated signing CI is optional.
|
||||
- **Next candidate:** **1.0.2 (versionCode 4)** is prepared but not built; see [Next candidate](#next-candidate-102-versioncode-4).
|
||||
- **Open:** see [Qualification status](#qualification-status) and [TODO](TODO.md).
|
||||
|
||||
Owner decision, October 31: leave signing and distribution until the final step because the distribution channel has not been chosen. Do not generate signing keys, populate production signing secrets, create release tags or treat the candidate GitHub APK workflow as a selected distribution channel. Complete implementation, audits, tests, documentation and ordinary CI first; then return to the owner for the distribution decision.
|
||||
## Next candidate: 1.0.2 (versionCode 4)
|
||||
|
||||
The shared library keeps its existing main-only CircleCI publication. The candidate scanner workflow uses a separate `scanner-v<semantic-version>` tag and GitHub release (not selected). One APK serves customers through the selected server's runtime branding. The application ID and launcher identity remain `io.qrun.mobile.scanner` / QQQ Mobile.
|
||||
Prepared on 2026-11-06 on branch `release/scanner-1.0.2` (Android PRs 17 and 18 merged locally plus these notes). **Nothing is built, signed, tagged, installed or published for 1.0.2.** The signed 1.0.1 candidate stays unpublished and is superseded; 1.0.2 updates it in place with the same signer and a higher `versionCode`.
|
||||
|
||||
## Release prerequisites
|
||||
Changes since 1.0.1 (details in [CHANGELOG](../CHANGELOG.md#102-versioncode-4-candidate)): company sign-out now completes (the device-session end ran before the provider logout, so no `nativeLogoutData` came back and the browser never opened on the MC9400); the user name is shown after browser sign-in; app/OS version are reported on every heartbeat (effective with mobile-scanners server 1.6.0).
|
||||
|
||||
- Merge only after the exact source passes the shared library, host, real-server native and companion browser tests, plus the independent security review. Update the immutable companion-server revision in `scanner.yml` to the qualified server commit first.
|
||||
- Complete the navigation and physical TC22 acceptance matrix, camera/Record Files integration qualification, dependency attribution and production HTTPS/authentication/storage checks documented in the project TODO and companion readiness report.
|
||||
- Establish an organization-owned release keystore and backed-up recovery process outside Git. Keep that signing identity for upgrades. Configure the `scanner-release` GitHub environment before adding secrets: restrict deployment tags to `scanner-v*`, protect release-tag creation/update/deletion with repository rules, and limit authorized release maintainers. Store `SCANNER_KEYSTORE_BASE64`, `SCANNER_STORE_PASSWORD`, `SCANNER_KEY_ALIAS`, and `SCANNER_KEY_PASSWORD` in that environment, not repository-wide secrets. A workflow environment declaration alone does not create these protection policies; actual GitHub configuration must be reviewed before loading production keys. Never place secrets in Gradle properties committed to source or pass passwords on the command line.
|
||||
- Set repository variable `SCANNER_CERTIFICATE_SHA256` to the intended certificate's SHA-256 digest, 64 hex characters without separators. The build compares the resulting APK signer to this independently configured public fingerprint.
|
||||
- Set repository variable `SCANNER_VERSION_CODE` to a positive integer greater than every previously distributed scanner version code. Android's maximum is 2100000000. Release workflows are serialized across tags and compare the selected code with every published `scanner-v*` release's `BUILD.txt`, including prereleases. Missing or malformed prior build evidence fails closed. Account for any APK distributed outside GitHub manually as well; changing the display version alone is insufficient.
|
||||
Build only after PRs 17 and 18 are merged to `main` and this branch has been rebased onto that `main`, from the exact merged commit, with no `SCANNER_*` signing variables set:
|
||||
|
||||
## Build and publication
|
||||
|
||||
Create the immutable `scanner-v1.0.0` tag only after the prerequisites pass. Use a prerelease suffix for a qualified release candidate, for example `scanner-v1.0.0-rc.1`. Do not move a published tag or replace released APK bytes.
|
||||
|
||||
Push one release tag at a time and wait for its workflow to finish. GitHub's default concurrency group serializes execution but retains only one pending run; it is not a durable multi-release queue. The workflow reuses the validated version code during signing and rechecks the remote tag's commit immediately before publication. Immutable tag protection remains necessary to close concurrent tag-change races.
|
||||
|
||||
The tag workflow first calls the existing scanner verification workflow, including native emulator and real-server tests. Before signing, it verifies the tag's commit is an ancestor of protected `main` and enforces the increasing version code. It then builds a non-debuggable release APK, runs release lint, verifies its signature against the expected certificate, verifies package/version metadata and writes checksums and the source commit. Only the final publication job receives repository write permission. A failed verification or absent signing configuration prevents publication. The temporary keystore is removed at the end of the signing step. Signing passwords stay in masked environment variables and never become workflow outputs or artifacts. Repository/environment protection is essential because a maintainer able to change trusted workflow code could otherwise remove its in-workflow checks.
|
||||
|
||||
The candidate APK release contains `qqq-mobile-<version>.apk`, `SHA256SUMS`, `signature.txt`, `BUILD.txt`, `aboutlibraries.json`, `LICENSE` and `NOTICE`. The notices are generated from the release runtime and also packaged for offline access in Settings. Download and verify these assets, then qualify the actual signed bytes on a device before customer rollout. Checksums establish artifact integrity; they do not replace signer verification or functional acceptance. Signing/distribution remains deferred by the owner until the final step.
|
||||
|
||||
For local release builds, supply the four `SCANNER_*` signing environment variables (use `SCANNER_KEYSTORE` for the local keystore path, not the CI-only base64 variable), then run:
|
||||
|
||||
```text
|
||||
./gradlew :scanner-host:lintRelease :scanner-host:assembleRelease -PscannerVersionName=1.0.0-rc.1 -PscannerVersionCode=2
|
||||
```
|
||||
./gradlew --no-daemon --console=plain :scanner-host:lintRelease :scanner-host:assembleRelease -PscannerVersionName=1.0.2 -PscannerVersionCode=4
|
||||
```
|
||||
|
||||
Without signing variables, local release assembly produces an unsigned APK for inspection. Partial signing configuration fails immediately. Debug/E2E builds retain their existing development signing and default version unless explicitly overridden. Unsigned and debug APKs must not be distributed as production releases.
|
||||
Then sign `scanner-host/build/outputs/apk/release/scanner-host-release-unsigned.apk` in isolation with `apksigner` using the existing permanent key (certificate SHA-256 `BDDB2D2F...5B17A9FF`), verify v2+v3 with exactly one signer, 16 KiB alignment, `versionCode='4' versionName='1.0.2'` and non-debuggable, and write `QQQ-Mobile-1.0.2.apk`, `SHA256SUMS`, `signature-verification.txt`, `apk-metadata.txt` and `release-receipt.json` next to each other as for 1.0.1. Record the resulting hashes here, in [INSTALLATION](INSTALLATION.md) and in [SESSION-STATE](SESSION-STATE.md) before any tag or release.
|
||||
|
||||
## Upgrade and Zebra rollout
|
||||
## Historical status (November 4, 2026, superseded where noted)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
## Decisions (made)
|
||||
|
||||
If a delivery path is later chosen, an APK from it could support 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.
|
||||
- **Channel:** signed APK distributed directly / through MDM. Google Play is not part of this channel, and no purchased or public-CA certificate is required.
|
||||
- **Key:** a new QRun-owned Android signing key, organization identity QRunIO, application ID `io.qrun.mobile.scanner`. Alias `qqq-mobile-release`, PKCS12, RSA 4096, valid through 2056-10-27.
|
||||
- **Certificate SHA-256:** `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`. This is the fingerprint every distributed APK must show.
|
||||
- **Storage and backup:** the keystore and its credentials are stored as a keystore Document (QQQ Mobile Android release keystore) and a Password item (QQQ Mobile Android release signing credentials) in the owner's Personal 1Password account, vault `QRun`. Recovery was verified on 2026-11-04: both items were recovered from the vault, the recovered file's SHA-256 matched, and the recovered keystore opened with a certificate matching the one above. Keys, passwords, item IDs and local private paths never belong in this repository.
|
||||
- **Signing path:** build an unsigned APK, then sign it in isolation with `apksigner`, so the key is not passed to the application build. The Gradle environment-variable signing below remains supported but is not the selected local path.
|
||||
- **CI:** no signing secrets or CI signing activation exist. Shared CI/CD redesign is owned elsewhere; no scanner-APK Gitea release workflow exists and none is being added here. Automated scanner-APK signing/publication is not configured and is not needed for direct installation or the local demo.
|
||||
- **Direct install:** see the [installation guide](INSTALLATION.md). Local signing and manual distribution do not depend on the shared CI work.
|
||||
|
||||
## What exists today
|
||||
|
||||
- **Gitea CI (actual).** `.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. It is **not** a scanner-APK signing or release pipeline: it uses no scanner signing secrets and produces no production-signed scanner APK.
|
||||
- **Gradle signing (supported, not selected).** `scanner-host/build.gradle.kts` reads four environment variables: `SCANNER_KEYSTORE`, `SCANNER_STORE_PASSWORD`, `SCANNER_KEY_ALIAS`, `SCANNER_KEY_PASSWORD`.
|
||||
- All four set: the `release` build type is signed with them.
|
||||
- None set: `assembleRelease` produces an **unsigned** APK, which is what the isolated signing step consumes.
|
||||
- Some but not all set: the build fails immediately.
|
||||
- Debug, `e2e` and `qa` builds keep the development debug signing.
|
||||
- Version comes from `-PscannerVersionName` (semantic version, default `0.1.0`) and `-PscannerVersionCode` (1 to 2100000000, default `1`).
|
||||
- **Identity.** One APK serves all customers; branding is applied at runtime from the selected server's metadata, so no per-customer key or build is needed.
|
||||
- **Legacy GitHub workflows (removed).** `.github/workflows/release-scanner.yml` and `scanner.yml` predated Gitea as primary forge and were removed with the CircleCI configuration. The [November 1 review](RELEASE-AUTOMATION-REVIEW.md) covered those files only.
|
||||
|
||||
## First release: 1.0.0 (versionCode 2)
|
||||
|
||||
Version `1.0.0`, `versionCode` 2 was built unsigned (`lintRelease` and `assembleRelease`: 0 errors, 0 warnings, 2 information) from source matching protected `main` `0ae2101f082c241ca6a70462dc9ec3057aa6c906`; only documentation differs. It was then signed in isolation with `apksigner` on 2026-11-04 (16:22:05 UTC).
|
||||
|
||||
**Signed artifact (local, complete):** `QQQ-Mobile-1.0.0.apk`, application ID `io.qrun.mobile.scanner`, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`. Verification passed: signature schemes v2 and v3, exactly one signer whose certificate SHA-256 is `BDDB2D2F...5B17A9FF` (the fingerprint above), 16 KiB alignment, non-debuggable. The checksum shows integrity only, not signer correctness.
|
||||
|
||||
**Published preview (2026-11-04):** release [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (Gitea ID 1361, prerelease, signed Git tag at `3eef6431`; the tag adds documentation only over app source `0ae2101f`) hosts exactly two assets: `QQQ-Mobile-1.0.0.apk` (14933499 bytes) and `SHA256SUMS` (88 bytes). Both were downloaded back and matched the same SHA-256. The forge requires sign-in to download. The local installation kit ZIP was **not** published and no bundle is hosted. Link the release page, not an individual asset URL. The APK is kept outside this repository.
|
||||
|
||||
**Device evidence (MC9400, November 4):** the signed APK was installed on a fresh MC9400 (Android 13, EMDK present), launched without a startup crash, showed the Connect screen, and its installed bytes read back with the exact hash above. It was not enrolled: release builds require HTTPS and the only server available was the local HTTP demo. The same key and signer are unchanged. The user then chose to run the MC9400 in demo mode like the TC22, so that fresh, unenrolled production install was replaced with the development debug APK and port forwarding to the local server. No signed-build enrollment, scan or navigation was tested. The TC22 and its data were untouched throughout.
|
||||
|
||||
*(The November 4 MC9400 paragraph above is historical: the fresh install was later replaced by the migration recorded below.)*
|
||||
|
||||
## Qualification status
|
||||
|
||||
Done (not to be repeated as unchecked work):
|
||||
|
||||
- **MC9400 migration:** the user explicitly authorized it. Release 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready state were observed.
|
||||
- **Physical scan:** the operator confirmed a physical Saved scan. The backend verified CODE128, AIM identifier, raw bytes, user and device. The actual barcode differed from the requested literal.
|
||||
- **TC22:** the original development-signed installation and its data are retained. The accepted November 4 manual all-pass is not rerun.
|
||||
- **Server discovery:** against the real shared Authentik-backed server, UI API 1.10 discovery with strict nonce passed for both scanner and printing channels, extensions and callbacks.
|
||||
- **Photo policy (live):** a signed MC9400 gallery upload returned Saved photo with `AVAILABLE` status and exact parent, type, size and hash; the previous three photos were released normally. Updated native acceptance on the TC22 passed synthetic camera/gallery uploads, the captured photo's thumbnail and full preview, parent scoping, rotation and forced-logout cleanup. All requests passed the semantic API checks. The disposable app was removed and the normal app reopened. This does not establish camera-hardware or native-Authentik acceptance.
|
||||
- **Local network:** the local QQQ server remains on a PC and was reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is local qualification, not a customer production deployment. Customer instructions still require a customer HTTPS URL with a trusted certificate. TLS must not be weakened, and workstation hostnames, IPs, ports, device IDs, vault IDs, private file paths and passwords are not published.
|
||||
|
||||
Open:
|
||||
|
||||
1. **Native Authentik** login, logout and account switching with the actual production APK remain open, awaiting the MC wireless-debug endpoint. No native-Authentik PASS is claimed.
|
||||
2. **Printing:** the print nonce fix (PR 11, merged `abc3d0d`, 532 hosted tests passed) postdates the published print 1.1.1; the next installer remains with the active printing owner.
|
||||
3. Public compatible Core/Collab/document/files/scripting/bridges, the final public cohort gate, GS1 physical print/rescan, and full release-asset publication and distribution.
|
||||
4. Company profiles and wallpaper are tracked separately.
|
||||
5. Build, sign and publish 1.0.2 (versionCode 4) in place of the unpublished 1.0.1 candidate after PRs 17 and 18 merge (see [Next candidate](#next-candidate-102-versioncode-4)), with its checksum and installation instructions after remaining qualification. The published download remains the 1.0.0 signed preview; automated signing CI is optional.
|
||||
|
||||
## Release requirements
|
||||
|
||||
- Keep this signer and a strictly increasing `versionCode` for every distributed build, including builds distributed outside CI.
|
||||
- Never put passwords or keystores in Git, command lines, logs or committed Gradle properties. Recover them from the vault only for the signing step and remove temporary keystore copies afterward.
|
||||
- Lint and build the release variant: `./gradlew :scanner-host:lintRelease :scanner-host:assembleRelease -PscannerVersionName=<semver> -PscannerVersionCode=<increasing integer>`, with no signing variables set for the isolated path.
|
||||
|
||||
## Updates, compatibility and recovery
|
||||
|
||||
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 (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
|
||||
|
||||
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).
|
||||
@@ -1,309 +1,50 @@
|
||||
## November 3 — verified widget-state reset fix
|
||||
# Current status
|
||||
|
||||
`ProcessViewModel.reset()` now clears `widgetState` so last scanner feedback no longer carries into a new run. The regression proves ordinary step updates retain feedback and reset clears it. Root verified all 57 mobileapp JVM tests (zero failures/errors/skips), scanner lint (zero errors/warnings), debug APK assembly and the 255-file standards gate. Prior head `9cf789c` passed hosted CI3553 in 16m22s; this fix still needs its own hosted checks and normal PR10 → PR8 → main integration. The installed APK and manual acceptance are separate gates; signing/distribution remains deferred.
|
||||
As of **2026-11-06**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is not a final-release or full-qualification claim. See [Release facts](#release-facts-2026-11-05) below.
|
||||
|
||||
## November 3 — current checkpoint (verified; supersedes all older entries below)
|
||||
## Release candidate 1.0.2 (versionCode 4), 2026-11-06
|
||||
|
||||
- **Source:** `f72b90d08cf29d2785cef1d5c5e7eeaf75f9afd2` is the signed application-code commit published in PR 10, independently read back over SSH before this documentation update. Gitea CI 3480 **succeeded** on it (unit-test, device-test, package, secrets and gate; root read the UI). Root also verified a clean git merge-tree of `f72b90d` with actual `main` b75fdac and merged nothing. Pull requests #8 and #10 remain open; no 1.0 is released. This documentation update leaves runtime code unchanged from CI-passing `f72b90d`.
|
||||
- **Installed:** the updated normal APK `4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E` is installed with the same signer and no data clear. Connected, Apps/Settings, portrait, location on and the brand-only system wallpaper preview were verified. No wallpaper or company profile was applied.
|
||||
- **Fresh verification:** 222 JVM tests, lint 0 errors/0 warnings (2 info), APK builds, 23 Node tests, 255-file source standards pass. A fresh disposable TC22 Echo ten-stage run passed 109/109 semantic requests (simulated inputs and printing).
|
||||
- **Authentik:** fresh real desktop acceptance passes (nonce rejection, A login, logout with old-cookie replay rejected, fresh B without clearing cookies, logout) against the same isolated TLS fixture. The opt-in native harness is integrated and compiled, and its 8 launcher guards pass; the on-device run still needs the temporary CA and PIN interaction. No trust bypass. The normal server on 57832 is unchanged and ready.
|
||||
- **Server side:** server `45f9cbd` is signed and pushed (Gitea PR 7); the public QQQ 4.1.0-RC.21 source update is prepared for the next signed commit. The final RC.21 gate passed: 377 unique tests (Generation 179, Printing bridge 16, scanner 53, demo 8, mobile Record Files 18, Echo 68, Echo demo 35), zero failures/errors/skips, Checkstyle PASS, plus 15 real admin-browser E2E on public RC.21; the earlier MySQL timeouts did not recur. This is public core RC.21 with qualified owner inputs (three private `pu507` libraries still resolve through the owner Printing POM), not a fully public or released cohort. The normal runtime core was not upgraded.
|
||||
- **Still open (not claimed):** Generation canonical owner integration and public reproducible owner inputs; primary source CI, merge and release; manual TC22 trigger, value/type, AIM, label count, rescan, photo, GPS, projection and profile checks (operator remote); native Authentik CA, PIN and callback; signing/distribution, explicitly last. The normal TC22 shows Echo setup, Copies 1, Connected.
|
||||
- Branch `release/scanner-1.0.2` carries Android PR 17 (company sign-out ordering, OAuth user name) and PR 18 (heartbeat app/OS version) plus the [1.0.2 changelog](../CHANGELOG.md#102-versioncode-4-candidate) and build instructions in [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). It depends on both PRs merging first and must be rebased onto the resulting `main`. **Nothing is built, signed, tagged, installed or published for 1.0.2**; the signed 1.0.1 candidate stays unpublished.
|
||||
|
||||
## November 3 — final client installed; Authentik harness integrated (earlier; superseded by the checkpoint above)
|
||||
## Heartbeat version fix (2026-11-06, Android PR 18)
|
||||
|
||||
The verified debug APK 4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E is installed on the normal TC22 with the same signer and install -r. Existing sign-in, device approval and Connected status survived. Navigation, portrait setting, foreground-location toggle and the brand-only system wallpaper preview were verified; wallpaper was not applied. Feedback, managed configuration and AIM code are now installed, with physical trigger/fidelity still unqualified.
|
||||
- After the MC9400 in-place 1.0.0 to 1.0.1 update, scan receipts still reported `appVersion` 1.0.0: the host only sent `osVersion`/`appVersion` at enrollment and the server never refreshed them. `MobileSessionApi` now sends both on every heartbeat (`MobileSessionTest.networkPayloadUsesSemanticRoutesAndCarriesOnlyScreenMetadata` covers the payload); the matching server change is mobile-scanners 1.6.0 on the same-named server branch. Older servers ignore the keys; the installed 1.0.1 APK does not include this change (planned for 1.0.2).
|
||||
|
||||
Fresh verification passes 222 JVM tests, lint zero errors/warnings, debug/E2E/test APK builds, 23 Node tests and the 255-file source standard check. The updated disposable TC22 Echo suite passed all ten stages with every request matching semantic operations; simulated input/printing only. The actual Authentik desktop test passed again on November 3 with strict TLS: nonce rejection, operator A, company logout, old-cookie rejection and fresh operator B. The opt-in real Authentik Android harness is now in the repository and compiles; native execution awaits temporary CA installation/device interaction. No normal app/server trust change was made.
|
||||
## Release facts (2026-11-05)
|
||||
|
||||
Echo server source is signed and published at 45f9cbd in Gitea PR7. Generation copies now has an isolated canonical Git integration, with additive PostgreSQL migration verified on real PostgreSQL17. Shared source/dependency publication and hosted CI remain separate gates; no production 1.0 release or physical print claim. Earlier installed-APK/uncompiled-harness notes below are historical.
|
||||
- **Published:** still `scanner-v1.0.0`, a signed preview. Signed candidate **1.0.1 (versionCode 3)** is **not published**, has no tag and no download link, and is not production-complete. Signer: existing permanent QRun key (certificate SHA-256 `BDDB2D2F...5B17A9FF`); no new key. APK SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes. Details: [RELEASING](RELEASING.md).
|
||||
- **Source:** Android PR 15 `ab6e44c`, merged `c782aaaaba3f62422232436a9000af9d4b38fc82`, same app-source tree. Root verified installed APK bytes/signature/non-debuggable/16 KiB alignment; `lintRelease` 0 errors, 0 warnings, 5 informational; release build passed.
|
||||
- **Done:** MC9400 migration (user-authorized): 1.0.0 enrollment over trusted HTTPS, then in-place 1.0.1 update preserving login/enrollment; Apps/ScanCapture and EMDK Scanner-ready observed; operator-confirmed physical Saved scan verified in the backend (CODE128/AIM/raw bytes/user/device; actual barcode differed from the requested literal). Not repeat work. TC22 keeps its original development-signed installation and data; the accepted November 4 manual all-pass is not rerun.
|
||||
- **Network:** the local QQQ server stays on a PC, reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is not a customer production deployment; customer instructions require a customer HTTPS URL with a trusted certificate. TLS is not weakened; no hostnames, IPs, ports, device/vault IDs, private paths or passwords are published.
|
||||
- **Server/Authentik:** real shared Authentik server discovery verified (UI API 1.10, strict nonce, scanner and printing channels/extensions/callbacks). Production-APK native login/logout/account switching is **open**, awaiting the MC wireless-debug endpoint; no native-Authentik PASS is claimed. Print nonce fix PR 11 (merged `abc3d0d`, 532 hosted tests passed) postdates published print 1.1.1; the next installer remains with the active printing owner.
|
||||
- **Photo policy:** live. A signed MC9400 gallery synthetic PNG became a Saved photo, `AVAILABLE`, with exact parent/type/size/hash; the previous three photos were released normally. The app has no admission policy; the server receipt controls the message. The root is running the updated disposable TC22 E2E; no full new-preview or native PASS is claimed until it completes.
|
||||
- **Company sign-out bug (2026-11-05):** on the physical MC9400 with the production APK against the real Authentik fixture, **Sign out of company** never opened the browser and reported "Company sign-out did not complete". Cause: `QViewModel.performLogout` ran the host `beforeLogout` hook (scanner device-session end, which runs the core logout server-side) before the provider logout request, so `nativeLogoutData` was never returned. A fix reordering provider logout before the host hook, plus adoption of the server-reported user name after browser login (Account showed a blank "Current User"), is Android PR 17 (`cf82dd5`) with unit tests; it is **not released** and no new APK is built (planned for 1.0.2, versionCode 4). See [AUTHENTICATION](AUTHENTICATION.md#optional-company-sign-out-implementation-awaiting-qualification).
|
||||
- **Open:** public compatible Core/Collab/document/files/scripting/bridges, final public cohort gate, GS1 physical print/rescan, full release-asset publication/distribution. Company profiles/wallpaper are tracked separately; signing CI is optional and not blocking a direct APK release. Release authorizations already exist.
|
||||
|
||||
## November 2 — Echo copies live and native acceptance complete
|
||||
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").
|
||||
|
||||
Echo setup now exposes optional **Copies per scan**, default 1, range 1–100. The server pins it for the run and submits one job with that copy count for each scan. The normal TC22 is connected and left on the fresh setup screen showing 1. Its installed APK remains D8109C61E71660D2195337DA988F7F692CC4A0DBE95F61230930D6549CAB4D9B; no client upgrade was needed for this generic semantic field.
|
||||
## Record browsing and photos
|
||||
|
||||
Disposable TC22 Echo acceptance passed ten stages and 116/116 documented requests, including default 1, rejecting 0/101, selected 3 across two scans, recreation and Apps/Leave. Inputs and printing are simulated; physical label count is unconfirmed. The combined source passes 222 JVM tests, lint with zero errors/warnings, APK compilation and 15 Node tests. New scan-feedback, managed-profile/wallpaper and AIM source is not installed on the normal app.
|
||||
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.
|
||||
|
||||
The existing local server on port 57832 now advertises mobile-scanners 1.5 and mobile-record-files 1.0, retaining the original database, approval, accounts and printer settings. Server/owner gates passed 377 unique tests across the qualified module runs. Generation copies changes remain in the isolated owner handoff, awaiting canonical adoption/publication. Physical trigger/print/rescan, camera/GPS/projection and native Authentik remain open; the operator is remote and temporary CA installation is unconfirmed. Signing/distribution stays last. Older checkpoints below are historical; see FINISH-1.0.md for the current ledger.
|
||||
**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.
|
||||
|
||||
## November 2 — administrator viewing controls
|
||||
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).
|
||||
|
||||
Removed app-authored Stop viewing / Allow viewing, Stop device screen sharing and the custom notification Stop sharing action. The visible indicator, Android consent/system revocation, password/lifecycle guards, server authorization and lease expiry remain unchanged. The existing native regression verifies an informational non-clickable indicator with no stop/allow buttons, continued JPEG frames after resume, background frame clearing, administrator session termination and fresh login.
|
||||
## Verification and installation
|
||||
|
||||
Verification: 185 JVM tests (44 core, 56 mobileapp, 85 host), eight Node guard tests, lint zero errors/warnings (two existing informational hints), debug/QA/test APK builds, and the real TC22 session-monitor scenario passed; all 112 native requests matched published semantic operations. The disposable QA packages were removed. Whole-device Android consent was not rerun for this UI removal.
|
||||
- **232 unit tests** pass: core 52, mobileapp 57, host 123; lint reports **0 errors/0 warnings**. Debug, E2E and instrumentation builds pass. Eight runner guard tests pass.
|
||||
- Isolated MC9400 native acceptance passes rich-record paging/groups/relations/search/filter/Back, camera/gallery persistence, parent-scoped saved photos, approved preview, rotation, pending-photo navigation protection and forced-logout cleanup. All requests match the semantic API. Process-photo regression passes separately.
|
||||
- The final date fallback recognizes default `%s` server metadata. It received unit/lint/debug-build gates and actual TC22 checks; the full native suite preceded that date-only follow-up.
|
||||
- Existing TC22 app updated in place with signer and installed bytes verified: SHA-256 `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`. No uninstall/data clear or separate manual QA app. Existing login, enrollment and server configuration survived.
|
||||
- Actual TC22 inspection confirms Actions and capture chooser, direct tabs, date/timezone and currency formatting, empty Photos state and order 26 → Android Back → page two. Automated camera/gallery inputs are synthetic; no new physical camera/print claim is made.
|
||||
|
||||
Installed the verified debug APK D8109C61E71660D2195337DA988F7F692CC4A0DBE95F61230930D6549CAB4D9B on the normal TC22 with install -r and no data clear. The process restart required sign-in; restored the existing scanner account and verified Connected, the Echo catalog entry and existing approval. Local server57832 remains HTTP200 and the print service remains Running. This APK also contains the previously verified optional raw-scan metadata client, gated off against the unchanged installed mobile1.4 server. Existing unrelated source/test changes are preserved. No commits, publication or broader release work resumed for this scoped request.
|
||||
## Source and runtime
|
||||
|
||||
## November 2 — current checkpoint
|
||||
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.
|
||||
|
||||
Source candidate: immutable `ScannerCapture` retains SDK raw bytes as canonical Base64 before the callback returns and routes them through the ordinary semantic process payload. New optional fields are sent only after same-server discovery advertises mobile-scanners >=1.5,<2; older servers still receive the five legacy keys. Barcode text and symbology remain unchanged, scan/capture string representations are redacted, and a failed-submit regression proves the entire event is resent unchanged. Verification passes 185 JVM tests (44 core, 56 mobileapp, 85 host), lint with zero errors/warnings (two existing informational hints), debug/E2E APK builds and QA Android-test compilation. New native tests and operator installation have not run. The SDK configuration is unchanged and AIM extraction is not implemented by this transport slice.
|
||||
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).
|
||||
|
||||
Completed and verified:
|
||||
## Accepted and outstanding hardware work
|
||||
|
||||
- Android source `fb3cc5d` is signed and published as Gitea PR 10. It passes 174 JVM tests (44 core, 55 mobileapp, 75 host), lint with zero errors and zero warnings, and the debug APK build.
|
||||
- The native `SameOriginInterceptor` fixes the cookie-bearing heartbeat that returned 403. The server's same-origin guard is not weakened.
|
||||
- The normal TC22 installation was updated without clearing data. Existing login and device approval, heartbeat, the real print client's printer, fresh 4x1 media and published document selection, and Cancel then Leave all work against the one local server.
|
||||
- That server is one QQQ demo on host port 57832. It runs Mobile Scanners/Echo, Collaboration/Record Files, Document Generation and Printing (API 1.10.0, mobile 1.4.0, printing 2.3.0). On this workstation, device port 8080 forwards to host port 57832. The original accounts, enrollment and approval are retained.
|
||||
- The Printing owner has aligned the loaded 4x1 stock (101600 x 25400 microns at 300 dpi) and updated the real media revision. A real Echo 4x1 barcode document is published. The TC22 selected the actual printer, the fresh 4x1 media and that document, and Next succeeded and shows Scanner ready and Connected.
|
||||
- The emulator is off because it stalls this workstation. Do not restart it automatically.
|
||||
|
||||
Not yet done:
|
||||
|
||||
- No physical Echo print or rescan has been proven. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed. One physical scan of QQQ-TEST-123456 has been requested from the user.
|
||||
- Still open for 1.0: physical Code128 and leading-zero QR scan, print and rescan; full provider, camera, GPS, projection and device qualification; public coherent artifacts; primary Gitea CI; and final signing and distribution. Signing is deliberately last, by the user's decision. The first demo working does not collapse these requirements, and nothing here claims release readiness.
|
||||
- Core native API merge continuation PR 1337 (a42f9e5) was reviewed with no actionable conflict regression and its 57 focused tests were verified. Normal CI and publication are not established, and default dependencies are not repinned.
|
||||
|
||||
The entries below are historical. Where they describe the printer media report, the 4x1 document or the same-origin fix as pending, this checkpoint supersedes them.
|
||||
|
||||
## November 2 — native cookie Origin compatibility verified on TC22
|
||||
|
||||
The combined Echo Demo on57832 (reached through device port8080) returned200 for login, then403 for the cookie-bearing session heartbeat POST, because Android's native QQQ requests omitted `Origin`. The server's same-origin guard remains enforced. Core now has a small `SameOriginInterceptor`: it sets `Origin` from the actual request URL's canonical scheme/host/port only (default ports omitted, IPv6 bracketed, never credentials/input/headers) and is installed last so additional headers cannot override it. It is used by `NetworkQQQRepository` and the `DeviceApi`, `MobileSessionApi` and `RecordFilesApi` clients. Version discovery, presentation/logo fetches, OIDC/browser/token requests and redirect refusal are unchanged.
|
||||
|
||||
All174 JVM tests passed (44core/55mobileapp/75host), with zero failures/errors/skips. Lint has zero errors/warnings and two existing informational hints; debug APK build passed. MockWebServer tests cover exact Origin plus cookie on real POSTs, canonicalization, override resistance and redirect non-forwarding. Session and record-file host tests assert the header on their actual requests.
|
||||
|
||||
The verified APK (SHA256 `0B872005B2FA84F693E73CC49133F5C1C02F732A034AA0635A2B50682A61A98E`) was installed on the normal TC22 without clearing its data. Existing scanner login and device approval work against the actual combined server; heartbeat remains connected. Echo opens its three required choices, the approved printer and its reported media load, and Cancel -> Leave returns to the app normally. This directly qualifies setup/session/cancel against the strict host, not a physical print. The earlier112-request/two-synthetic-scan acceptance remains separate. (Superseded: the printing owner has since reported the 4x1 media and the 4x1 document is published; see the current checkpoint above. Physical printing is still unproven.) Emulator stays off.
|
||||
|
||||
## November 1 — required Document choice verified on the real TC22
|
||||
|
||||
Echo asks for printer, paper/label size and Document once; all subsequent scans use those server-pinned choices. Android production code is unchanged: the generic semantic INTEGER/PVS/dependency controls handle it. The updated separate E2E app passed all7 stages on the actual TC22 (select Document, clear stale choices on parent change, validate required fields, queue two scans, recreate without duplication, Apps/Leave/CLOSED). All112 requests matched published semantic operations. Disposable app/reverse/fixture cleanup passed, normal app/data preserved, emulator off. Printing and scanner inputs are simulated; physicalScannerQualified/physicalPrintingQualified remain false.
|
||||
|
||||
Verification:99 focused/host JVM tests,8 Node guards, zero lint errors/warnings and both disposable APKs compiled; after a test-only fixed-footer click correction, the actual TC22 acceptance passed. Companion server source8da274e is signed/pushed with45 library and24 composed demo tests passing. Private live server53118 still has header fix101efc8 pending document-choice rollout; real printer pairing retry awaits the current Windows consent. Installed normal APK324227CC... remains sourcea2f0080, whose production code is identical to this test-only change. Full physical/provider/camera/GPS/projection/public artifact/signing gates remain open.
|
||||
## November 1 — Echo native semantic choices verified on TC22
|
||||
|
||||
Claude implemented named process possible values using the actual published `searchProcessPossibleValues` JSON contract, dependent-field metadata, cancellation/error/retry and stale-response rejection. The unchanged Echo fixture now completes login, enrollment/approval, printer/media selection once, two accepted synthetic scans, rotation/recreation without duplicates, and Apps/Leave closing the run on the real TC22. All86observed requests matched published semantic operations. Disposable-package cleanup passed; normal package/data and real printers were preserved.
|
||||
|
||||
Validation:169JVMtests,8Nodeguards,236-file source standards, zero lint errors/warnings and bothE2EAPKs pass. This is simulated printing, not EMDK or physical output qualification. The remaining immediate blocker is the real Generation/Printing adapter and template/host composition, followed by physical print/rescan. Normal installed APK remains625a275; normal server API1.9/mobile1.1. Production signing remains deferred.
|
||||
# Session state
|
||||
|
||||
## Echo semantic presentation source checkpoint
|
||||
|
||||
The optional extension 1.4 acknowledgmentLabel and manualEntryEnabled hints are published on feature/echo-scanner-presentation (draft PR9). Existing defaults remain Saved scan/manual enabled; Echo uses Print queued/manual disabled. Strict compilation, 167 JVM cases, lint zero errors/warnings and debug assembly pass. Hardware, lifecycle and receipt predicates are unchanged. The server companion passes 69 consumer cases plus two contract cases; the optional Echo module passes 15 strict QQQ/H2 tests.
|
||||
|
||||
The normal TC22 remains the separately installed 625a275 build (APK AF13778A…3E9216), preserving sign-in/data. Its About screen was opened and Query artwork, both public links and licenses verified through filtered accessibility data. New Echo-native UI/actual owner adapters/physical printing remain unqualified; printing and document/scripting owners have acknowledged their implementation lanes. The running normal server remains API1.9/mobile1.1. Production signing/distribution stays the owner's final step.
|
||||
|
||||
## November 1 standards fixes and About update
|
||||
|
||||
Applied the scoped standards review: copyright/SPDX, explicit imports, declaration headers, native official/three-space formatter profile, mechanical CI source gate and Kotlin warnings-as-errors. Strict compilation and163 JVM tests pass; all46 disposable sample native tests pass on TC22, and production QA instrumentation compiles. The native suite uses fixture providers; actual Android external-browser/provider acceptance remains separate. The existing formatter reports one unstable Gradle script and full formatter CI runner provisioning remains explicit in the review.
|
||||
|
||||
Settings → About QQQ Mobile now shows bundled Query artwork, app version, Dogfooding/Query public links and open-source licenses. The normal APK rebuild uses drawable-nodpi for the artwork. No credential/device parameters are added to website links. The preceding diagnostics/default-on GPS update remains installed until the preserving update recorded below; actual GPS fix is still open.
|
||||
|
||||
The real API1.10 Authentik desktop sequence now passes negative nonce rejection, A login, explicit company logout, old-cookie rejection, fresh B credentials/principal in the same retained browser context, and B local logout. Its provider-specific User Logout flow is required for company sign-out; default ordinary client logout stays local. Server0b53f34 is published and all8hosted checks pass. CorePR1369's exact architecture allow-list correction is being verified; full fresh core CI is not yet green.
|
||||
|
||||
## November 1 diagnostics and location update
|
||||
|
||||
Diagnostics now uses a full-screen, scrollable native view with a connection summary, local-time newest-first activity, All/Problems filters, pause/resume, copy/share/clear and Back. Existing footer and menu entry points still block process input while diagnostics is open. Location reporting defaults on for approved signed-in foreground sessions; Android grants remain required, explicit opt-out persists and automatic permission requests are not repeated after denial. Controls remain under Settings → Device permissions.
|
||||
|
||||
The combined client passes 159 JVM tests, zero-error/zero-warning debug lint, normal/E2E APK builds and the focused diagnostics interaction test on TC22 using the separate E2E package. The new UI/default policy still needs normal-package field observation. This does not establish a physical GPS fix, background tracking, real provider sign-out or production release readiness.
|
||||
|
||||
The preceding signed checkpoint1c766bf was installed in place as APK E34AC3C6…C1874EB with private backup, matching signer and no data clear. The operator and backend confirm one Code128 and one QR event on these bytes, including exact leading-zero value001234567890 from EMDK/QRCODE. Apps → menu → Scan Capture round trip was also confirmed. The normal server remains API1.9/mobile1.1; API1.10 core159 tests, current contract3 tests and coherent scanner/demo/files/browser78 cases pass separately while real Authentik acceptance continues.
|
||||
|
||||
## November 1 native authentication checkpoint
|
||||
|
||||
The optional company sign-out and original-nonce client changes pass 151 JVM tests, zero-error/zero-warning lint, debug assembly and native-test compilation. All 28 selected OAuth2 login/logout and authentication-lifecycle native tests also pass on the TC22 in the disposable sample application, with no failures or skips. The normal scanner package/data were preserved and its Activity is foreground again. These native tests use synthetic provider adapters; they do not establish real Authentik or external-browser acceptance.
|
||||
|
||||
Independent review covered all 26 implementation/test/documentation files in the frozen patch and found no security candidates (completed scan c358cde3-b51d-4451-b07d-8d1afe6c4485). This is a patch review, not a fresh audit of every unchanged file. Native OAuth now requires API1.10, the advertised nonce-validation feature and REQUIRED policy; TABLE authentication remains compatible. Company sign-out is separately advertised and explicitly selected; the automatic sign-out policy remains undecided.
|
||||
|
||||
The paired server changes and real A-to-company-logout-to-B acceptance remain in progress. Earlier real-provider testing established operator A and local QQQ invalidation, but retained browser SSO silently reused A; that failed handoff remains failed. This checkpoint is source publication, not a production release or installation of these bytes on the normal TC22 package. Signing/distribution remain the owner's final step. All historical entries below retain their original scope.
|
||||
|
||||
## November 1 status and visible QR fixture
|
||||
|
||||
All eleven hosted checks at portrait-default7de2b508 passed. Public QR fixture checkpoint2e0fd962 is signed and pushed; docs/TEST-BARCODES.md records independent ZXing decoding of001234567890. Physical QR acceptance is still pending. Normal TC22 APK C89161C…C6340 remains installed with portrait requested, and the backend now has nine trusted EMDK Code128 events (eight on prior F3AF and one after the portrait update).
|
||||
|
||||
Release1.0 remains incomplete: actual Authentik acceptance remains pending; the local TLS proxy correction is verified, and provider configuration returned a validation error under diagnosis; physical QR, camera/gallery, navigation/failure, projection and GPS acceptance remain open. Filesystem portability passes142 discovered/138 passed/four existing skips, and the separate RF HTTP consumer now passes18 cases on a transitional custom-filesystem tuple. This supersedes its earlier fixture-compilation failure; it does not establish immutable/default dependency adoption or change the normal API1.9/mobile1.1 runtime on57832. Signing/distribution remain last by owner instruction. Current summaries supersede dated open-state statements below without changing their historical evidence or sealed review scopes.
|
||||
|
||||
## November 1 portrait default installed; prior source fully green
|
||||
|
||||
The user requested portrait lock by default. Missing/invalid preferences now choose Portrait; all explicit saved choices remain available. Four focused policy tests, debug lint (zero errors/warnings) and debug assembly pass. Development APK C89161C…C6340 was installed in place after signer verification and a new private-data backup; no app data was cleared. Android reports the normal Activity resumed with SCREEN_ORIENTATION_PORTRAIT, and a direct screenshot confirms the signed-in Apps menu. Barcode/lifecycle acceptance of these new APK bytes remains pending; earlier F3AF physical evidence is preserved.
|
||||
|
||||
All eleven hosted checks at229fce3 passed, including both GitHub native runs (jobs124829276568/124828879619):35 shared cases, PixelCopy and all seven semantic scenarios. Logs show session-login-action-ready, session-fresh-login and the semantic request audit in both runs. Server documentation checkpoint2de8470 also passes all eight checks. Fresh checks will qualify the portrait-default source update. The operator confirmed the eight earlier Code128 events were deliberate trigger pulls. The QR was not visible in chat; a direct image link was supplied after unreliable browser automation, and current QR acceptance remains pending.
|
||||
|
||||
## November 1 physical EMDK positive control verified
|
||||
|
||||
The operator reported Saved scan on the normal F3AF development app. Direct ownership-guarded backend inspection verifies eight new Code128 records for QQQ-TEST-123456, source zebra-emdk, TC22 attribution and eight distinct event IDs. This closes the current trusted hardware positive-control gap for Code128 only. Operator confirmation of repeated trigger intent and the current leading-zero QR remain pending; [physical acceptance](PHYSICAL-TC22-ACCEPTANCE.md) records exact APK/runtime and remaining lifecycle/retry/ownership gates. No normal app/data clear or QA run occurred.
|
||||
|
||||
Strict Firefox profile-only CA trust now passes issuer/hostname checks using the supported PLAYWRIGHT_FIREFOX_POLICIES_JSON entry point. Windows trust is unchanged; the earlier Windows-root approval question is obsolete. Actual provider startup/exchange remains unexecuted while an independently reviewed known-listener guard is prepared for genuine TIME_WAIT rows. Independent architecture closure of Android229/server2de reports no additional concrete source blocker and maps A1-A11 controls/tests; runtime/deployment/dependency/signing limits remain. Record Files compatibility now has a concrete exact-revision filesystem dependency gate in a new private cache; no frozen0515/default/runtime adoption occurred.
|
||||
|
||||
## November 1 native CI — rotation passes, fresh-login test readiness under correction
|
||||
|
||||
Android5ba PR run41297344726 passed:35 shared native cases, PixelCopy and all seven semantic scenarios, including session-monitor. The duplicate push run41297340376 also passed rotation/recreation/mirroring/administrator termination, then timed out waiting for Username after clicking Log back in. Its request audit shows completed Android logout200 and no second Android manageSession. Static review confirms that the test awaited only the label, which also renders while the logout action is disabled. This supports a test-readiness race; the failure artifact does not record the button state at the click, so no production cause is asserted. The correction waits for a final signed-out message and enabled action, asserts enabled and clicks once; it retains all fresh-login/session assertions. The opt-in device-screen test uses the same readiness requirement. Fresh native execution remains required.
|
||||
|
||||
Independent release automation review of three exact5ba files found no actionable source finding; [scope and hashes](RELEASE-AUTOMATION-REVIEW.md) retain external-configuration limits. A separate API1.9/mobile1.2 backend-only smoke passed readiness and shutdown with private credentials removed. Its launcher initially failed closed on the Node shim PID; actual Node resolution corrected the helper, and the unsuccessful fixture also cleaned up. No TC22 QA test, ordinary-app cleanup or normal-server replacement occurred.
|
||||
|
||||
## November 1 TC22 walkthrough — reported disappearance investigated
|
||||
|
||||
After the operator reported that touching the handheld crashed or moved the app, direct ADB inspection found the normal scanner process alive and its Activity resumed/focused, with no crash-buffer entries. A direct screen capture showed the connected sign-in screen; the black desktop mirror was not reliable evidence of an app crash. Restored the existing scanner demo login using locally encrypted credentials, and a subsequent direct capture confirmed the Apps menu. Opened Scan Capture to resume the manual walkthrough. No production defect has been reproduced or fixed by this inspection; physical trigger/navigation acceptance remains pending. No QA instrumentation or app cleanup was launched over the operator's normal app.
|
||||
|
||||
## November 1 morning — normal app updated; native test correction pending hosted verification
|
||||
|
||||
The normal TC22 application is now updated in place to the reviewed development APK `F3AF638E…E9FAA`. Matching signing identity and a new owner-only private-data backup were verified; app data/enrollment were not cleared. Non-streaming installation completed and the normal process is running against the preserved API1.9/mobile1.1 demo. This supersedes older unchanged-normal-APK statements below. Operator login/navigation/scanning confirmation is pending; no production release is claimed.
|
||||
|
||||
Published `e04fdb2` passed CircleCI and CodeQL, but both GitHub native runs failed the same session test: it requested rotation then another Activity recreation without waiting for configuration convergence, and the barcode draft was empty. The test now waits for landscape/resumed configuration and asserts the draft after rotation and again after explicit recreation. This is a test synchronization correction awaiting native execution, not a demonstrated production draft fix. Physical barcode attempts separately hit a wrong expected value (operator confirmed the old test code), a transient focus assertion and an ADB timeout; none is a full hardware pass. Focus preconditions now wait briefly within the120second stage deadline, and install timeouts identify the operation.
|
||||
|
||||
The opt-in QA screen-consent harness, shared HTTP helpers and both QA/E2E test packages compile. Its early safety checks reject missing operator opt-in and both protected demo ports without device access. Actual consent/Home/Stop/expiry/end-session acceptance remains pending. The harness author stopped at the workspace agent-credit limit; root completed the compile corrections and continues directly. A real Authentik fixture is compiled with pinned images downloaded, but no services or OS trust changes have occurred; temporary Windows CA approval is still pending.
|
||||
|
||||
## November 1 — refined device-screen client and published paired server
|
||||
|
||||
The refined client passes137 JVM tests, all package/lint gates, fresh four-APK inspection and the TC22 ordinary-UID scan-ingress regression. Continuous image draining retains one latest buffer; repeated Share is blocked through service binding. Fresh independent follow-up review found no concrete issue. The source-pinned1.2 server `1041693` is published and passes39 server/six demo/all15 browser tests with strict195-JAR/2,441-class provenance in a cloned private Maven cache. Hosted client CI now targets that exact source. No actual projection consent/physical trigger is proved; the normal APK remains unchanged and its running demo remains qualified1.1. See [candidate scope and hashes](DEVICE-SCREEN-ACCEPTANCE.md).
|
||||
|
||||
## November 1 — initial consent-based device-screen checkpoint
|
||||
|
||||
The optional device-screen client and paired1.2 server pass initial compilation and automated gates:135 Android JVM tests, lint/all package builds,39 server and six real-HTTP demo tests. Parent and fresh independent static review found no reportable client issue across the nine changed-file inventory; actual consent and projection behavior remain unverified. See [scope, hashes and remaining acceptance](DEVICE-SCREEN-ACCEPTANCE.md). The normal operator APK and qualified1.1 demo remain separate. Frame freshness/binding refinements and final native acceptance are still required.
|
||||
|
||||
## November 1 — hosted Back focus failure and physical-trigger checkpoint
|
||||
|
||||
Published Android head `6845abc5289fa975e6f50b23b159c3131a82eee1` has a successful CodeQL check; not all CI is green. CircleCI android-test job 140 fails three NavigationTests before injecting Back: their Activity is resumed and the device is interactive/unlocked, but Activity/decor window focus stays false for five seconds. Both job attempts reproduce the failure. Retained JUnit contains no focused-window owner, so the cause is not yet established. Failure-only diagnostics now classify the focused window as own/system/other, report bounded own-window flags and the known network-dialog visibility; they retain the five-second focus requirement and actual Back injection. CircleCI unit job 141 passed. One GitHub native run failed and the other was still running at inspection; neither is claimed successful.
|
||||
|
||||
The physical QA attempt using APK hash prefix `CFFF` reached its first hardware-ready stage but recorded no acknowledged operator scan. A final near-deadline HTTP poll timed out; this is not physical scanner acceptance. Evidence is preserved under `C:/Users/james/.cache/qqq-tc22-physical-attempt-20261101-0210`; `physicalTriggerVerified` remains false. A separate, unqualified test follow-up is prepared to avoid sub-second status polls and label deadline failures without extending the 120-second stage bound. Its compilation is deferred until the concurrent scanner changes are ready; it is not part of this diagnostic checkpoint.
|
||||
|
||||
The earlier normal-server ownership hold is resolved. After correcting the CIM positive control, the owner completed the reviewed upgrade to the frozen `ecf`/`b14` API 1.9 / mobile-scanners 1.1 TABLE runtime. A fresh backup, original-column invariants, old-after-new recovery logins before and after stopping the predecessor, and live login/metadata/logout for both demo accounts passed. Two devices and three scans, including the leading-zero value, were preserved. Private evidence is retained in `C:/Users/james/.cache/qqq-normal-api19-002594902b1942bda2480c512ed644ab/normal-verification.json`. The normal operator APK remains unchanged. Further optional screen-capture source is under separate implementation; these diagnostic edits do not qualify it.
|
||||
|
||||
## October 31 — published client checkpoint and probe target clarity
|
||||
|
||||
Signed client checkpoint `d316567` is published to existing draftPR8 and the clean canonical feature checkout is fast-forwarded to it. Fresh hosted checks found two CodeQL intent-redirection annotations in the separate permissionless test probe. Inline static triage at that exact revision classified both as not actionable: the exact QA/E2E package allowlist dominates both fixed-action broadcast sends, no arbitrary activity/service is launched, and the probe adds no sender authority. No alert was dismissed or completed audit rewritten. The probe now maps accepted requests to explicit final package literals rather than forwarding even the validated caller string. Probe-only build/lint and repeated ordinary-UID eight-forgery/manual-positive acceptance pass; production APK hashes are unchanged. Publish this source clarity follow-up and inspect fresh CodeQL/client checks before release. Physical trigger and whole-handheld viewing scope questions remain pending.
|
||||
|
||||
## 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.
|
||||
|
||||
## October 31 navigation/authentication review checkpoint
|
||||
|
||||
The combined client checkpoint passed 102 JVM tests (26 core, 43 mobileapp, 33 scanner), scanner lint, debug/E2E APK assembly and native-test compilation. The final follow-up build passed in 1m3s (266 tasks, 34 executed). The TC22 rerun passed all 20 selected navigation/input/account/OAuth tests in 49.235 seconds, including all seven synthetic AppAuth cases. The first run exposed Back during drawer opening; the handler now also handles a drawer targeting Open, and the native helper waits for settled UI before hardware Back. [Native acceptance evidence](NATIVE-AUTH-ACCEPTANCE.md) distinguishes real handheld execution from the simulated browser/provider. No actual Authentik sign-in is claimed. New hosted CI is required after publication.
|
||||
|
||||
Independent source review found no concrete defect in the native authentication/navigation patch; this is not final security or deployment clearance. [AppAuth package evidence](third-party/APPAUTH-VERIFICATION.md) confirms 131 bundled entries, 126 external runtime dependencies, nine complete texts and unchanged eight native libraries in the inspected debug/E2E APKs. OAuth2 depends on the companion core UI API1.9 native-session contract, still pending publication/host adoption. TABLE_BASED remains usable on compatible API1.8 servers. Production signing/distribution and actual shared-operator/provider acceptance remain separate gates.
|
||||
|
||||
## October 31 semantic authentication — synthetic native acceptance passed
|
||||
|
||||
The combined source passes 102 JVM tests (core26/mobile43/scanner33), scanner lint and debug/E2E/test APK assembly. All seven synthetic native OAuth cases and the navigation/input/account suite passed on TC22 as described above. Generated notices contain 131 entries including AppAuth. The initial sandbox run stopped at the existing debug-key lock; scoped rerun preserved the key and completed successfully. Testing used the separate sample package; no emulator or normal operator installation was started for this checkpoint.
|
||||
|
||||
The native descriptor now requires an advertised `prompt: "login"` policy, applied through AppAuth and bound to each pending attempt. Missing/unsupported prompt policies fail closed. Focused tests cover policy changes and mismatched returned requests; the real shared-handheld acceptance gate includes switching operators with an existing browser SSO session. This requests fresh authentication; it is not global IdP logout or verified `auth_time` proof.
|
||||
|
||||
The owner requires Authentik when configured by the server. Generic OAuth2 browser sign-in is implemented against an additive UI API1.9 `nativeSessionLogin` capability, with explicit issuer/discovery/callback metadata and an `authenticationFlow` marker on the existing session exchange. AppAuth performs authorization with S256; the secret and code exchange stay on QQQ. TABLE_BASED remains unchanged and native bearer metadata is kept distinct. Client gates, callback/lifecycle checks, deferred cookie adoption and focused tests are implemented; actual configured Authentik acceptance remains pending. See [AUTHENTICATION.md](AUTHENTICATION.md). No live provider settings or operator data were changed.
|
||||
|
||||
## October 31 navigation correction — native acceptance passed
|
||||
|
||||
The primary navigation uses one hamburger drawer with All apps and server-authorized app entries, plus Settings and Diagnostics fixed at its bottom. The duplicate toolbar Apps button and Switch app overflow action are removed. Back remains beside the hamburger within apps/processes, Account stays separate, and prelogin/pending-approval drawers hide business destinations. Drawer dismissal and secondary surfaces preserve active work; selecting a primary destination retains the existing leave and pending-upload guards. Compilation and the selected native navigation suite passed, including the drawer-opening Back correction above. The successful checkpoint below describes earlier session/photo acceptance. No installed operator app, server or data was changed for this correction.
|
||||
|
||||
## Current verified checkpoint — October 31
|
||||
|
||||
All 12 shared TC22 navigation/input/account tests pass. TC22 orientation (all five persisted policies), offline licenses, synthetic camera/gallery/rotation/remote-logout cleanup, and the full session-monitor scenario pass. Session monitoring includes real JPEG app pixels, background clearing, renewed viewing on resume, operator pause, administrator termination and fresh login. Photo/session request audits match published semantic operations. JVM reports total 91 tests (22 core, 36 mobileapp, 33 scanner), zero failures; scanner lint passes. Notice coverage gates and packaged APK inspection pass for debug/release/E2E (125 runtime dependencies, 130 entries and nine complete license texts), and negative omission/blank-text cases fail as required with generated metadata restored.
|
||||
|
||||
The false-offline investigation was a test-clock issue, not a demonstrated timezone defect. The independent standalone file-backed JVM test in America/Chicago passed across 18 seconds. No timezone workaround or relaxed heartbeat/offline policy was added. Remaining work: publish the Android review checkpoint and verify fresh hosted CI, finish independent full audits/native attribution, qualify immutable optional dependencies/CI and real lens/operator acceptance, then return to signing/distribution as the owner requested. The normal installed app and operator demo remain on their earlier versions; isolated test acceptance is not deployment. Historical entries below preserve earlier failures and are superseded by this checkpoint.
|
||||
|
||||
Viewing diagnosis resolved the apparent offline state: the test advanced only16ms of Compose coroutine time per250ms wall polling, so heartbeat LaunchedEffect timers did not run during125 widget polls. The standalone non-UTC file-JVM regression passed and audit ordering confirmed zero heartbeats during those polls. The helper now advances by elapsed monotonic wall time. The next TC22 run reached `session-mirror-frame`: actual app JPEG pixels were delivered/decoded and background clearing passed. It then failed the already-identified old resume assertion because no viewer demand was renewed. Resume now polls the admin widget again; rebuild/full scenario rerun is active in the current turn. No production heartbeat interval or offline threshold was relaxed. The standalone regression source is in the companion demo, added by the read-only diagnosis agent and verified separately.
|
||||
|
||||
Latest viewing diagnostics: eligibility is available=true, but no indicator/frame upload occurs; 125 widget requests and 18 native heartbeats succeed. A real HTTP regression now explicitly requests the viewer widget as a browser (without the Android client header) before the native heartbeat; both demo tests PASS, ruling out the simple cross-channel lease-isolation hypothesis in that fixture. Actual core widget response fields are `layout`, `blocks`, `type`, `blockTypeName`; corrected the diagnostic extractor to read `blocks` (the earlier blank status was an incorrect `subBlocks` assumption). That diagnostic change needs rebuild/rerun to obtain the actual server widget status. All runs in this checkpoint are terminal. No production state-sharing change was made on an unproven hypothesis.
|
||||
|
||||
The window-focus synchronization rerun advanced past capture eligibility: no APP_NOT_FOCUSED failure remains in that run. It now times out at the viewer indicator. Request audit shows 125 successful web widget renders and 18 successful Android heartbeats, with no screen-upload operation. Added bounded failure diagnostics for current server availability/reason and the widget's status text (never image data or credentials), to distinguish lease issuance from client rendering. This newest diagnostic needs rebuild/rerun. All native processes from that run are terminal. Android changes are staged as a review checkpoint, but later focus/diagnostic edits remain unstaged; re-stage and verify before committing.
|
||||
|
||||
Screen-viewing follow-up reproduced a stale tracked-focus flag: server reported APP_NOT_FOCUSED while instrumentation confirmed interactive=true, no keyguard/device lock, both Activity/decor focus=true and lifecycle RESUMED. ScannerActivity now observes actual view-tree window-focus events, resynchronizes on resume, and removes the observer on destruction. Existing captureAppScreen independently checks actual decor focus, so capture protection remains enforced. Scanner unit/lint/E2E APK verification passed (build1m19s); the focused real-server TC22 scenario is rerunning in the active root turn. The operator demo remains on extension1.0; no normal-app update was made.
|
||||
|
||||
TC22 photo acceptance PASSES against the verified guarded0352 fixture: synthetic external-camera result automatically uploads, exact hash/size/provenance/parent and collection association are inspected, gallery staging survives rotation/recreation, leaving is blocked while pending, and remote session termination clears the private copied original without deleting the selected gallery source. All recorded native requests match published semantic operations. The run exposed and fixed a real UI-thread defect: ScannerActivity now dispatches unauthorized-session navigation on the main thread and ignores callbacks for a superseded session. Scanner unit/lint/APK rebuild passed before the successful native rerun. Fixture owner metadata remains in the private task cache; do not infer a normal-app deployment or actual lens capture from this synthetic result. Real session-viewing acceptance is starting next in the active root turn.
|
||||
|
||||
TC22 navigation/input/account acceptance now PASSES all 12 tests on rebuilt sample APKs. Orientation (all five policies) and offline licenses also pass. The guarded Record Files private launcher needed execution outside the sandbox for its process to survive; current private fixture PID111928 was started with a 900-second lifetime, with protected metadata path in the task cache. The first native photo run reached Saved and verified exact bytes/provenance/parent, then failed because the test assumed stored collection order was zero-based. Actual owner storage is one-based while manifest/receipt order is zero-based. Corrected that assertion and the duplicate Apps selector; photo rebuild exec64104 is active in the current turn, then rerun the synthetic camera/gallery/rotation/remote-logout scenario. Preserve fixture ownership and stop only that verified fixture when finished. Session mirroring acceptance and final audits remain open.
|
||||
|
||||
Latest navigation rerun is terminal: 11 of 12 TC22 tests pass. Restored account/server assertions now pass. The remaining failure selected both the menu action and the closed drawer heading named Switch app; a stable menu-action identifier and matching test selector are added. Those last two changes need rebuild/rerun. No native test or fixture remains active from this run.
|
||||
|
||||
TC22 follow-up: orientation and offline licenses each PASS their instrumentation scenario. Shared navigation ran 12 tests: 10 passed, with failures from duplicate visible/closed-drawer Apps selectors and an old account-dialog expectation that server selection still lived there. Added a stable toolbar Apps identifier, preserved the account user assertion, and verified restored server selection under Settings instead. Rebuilt sample/test APKs successfully; navigation rerun is starting in the current root turn. Guarded server changes and passing 18-test evidence are committed/pushed as84ffd7c on the existing draft PR; no merge or production release.
|
||||
|
||||
Latest TC22 result: the rebuilt orientation acceptance PASSED (one instrumentation test covering all five policies, persisted selection, actual fixed screen axis, Activity recreation and a fresh Activity launch). The bounded configuration-convergence wait resolved the prior timing failure; the axis assertion remains enforced. Guarded Record Files integration now passes all 18 tests and runtime/Checkstyle verification, with evidence retained in the companion module. Root is running TC22 licenses followed by shared navigation acceptance as exec session85018; poll that handle before starting another device run. No operator-app update or production release is implied by isolated test acceptance.
|
||||
|
||||
Latest verification follow-up: the notice-integrated APK build log ends BUILD SUCCESSFUL. Explicit TC22 orientation acceptance ran and failed because the observed screen axis was still portrait when landscape was requested. A bounded 15-second configuration convergence wait was added to the native test while retaining its final axis assertion; rebuild/rerun remains required. The disposable test runner cleaned its resources. Emulator remains closed. Record Files guarded-artifact verification has four unresolved intake-event concurrency test failures; see the companion integration session state. No production release is qualified.
|
||||
|
||||
2026-10-31 Android acceptance follow-up: the bounded pre-notices build passed 89 JVM tests (core22/mobile36/scanner31), normal and isolated APK assembly, and scanner lint with zero errors/warnings; broader modules retain inherited vector/dependency warnings. The emulator remains off after confirmed workstation stutter. On TC22, session capture remained safely blocked with APP_NOT_FOCUSED after recreation; no mirror frame was uploaded. The 12 shared navigation/input/account tests then ran: six passed and six failed (stale account/server-confirmation assertions, duplicate catalog/drawer text selectors, and a hardware Back case). Assertion corrections and window-focus diagnostics are prepared; these failures are not counted as passes. Orientation Settings, retained manual draft state, synthetic photo E2E, explicit physical photo fixture support, and bundled-license Settings entry are now implemented but require the next compilation/native acceptance. The license owner is running the bounded integration qualification. Ordinary scanner app/data, DataWedge settings and existing USB reverses were preserved.
|
||||
|
||||
## 2026-10-31 — Navigation hierarchy and release preparation
|
||||
|
||||
Owner explicitly deferred signing/distribution to the final step until the channel is chosen. No signing key or production signing configuration has been created. Continue implementation, audits, native/physical acceptance, documentation and ordinary CI; do not create release tags or force a distribution decision early.
|
||||
|
||||
Workstation interruption: user reports whole-desktop pauses whenever the emulator acts and explicitly confirmed the workstation is smooth after closing it. All local emulator/build runs were paused and emulator-5554 shut down gracefully without clearing application data. TC22 remains connected. Sampled host had 48 logical CPUs, CPU17%, about152GiB free RAM of256GiB, GPU10% with6.1GiB/30GiB used; these samples do not rule out brief spikes. WHPX is operational outside the sandbox. The running host-GPU emulator log includes OpenGL context preemption/restore failure and external-memory import errors; graphics interoperability is a hypothesis, not a proven root cause. Saved AVD config now uses software graphics, previous config backed up locally, but no comparison run has been made. Do not restart the emulator automatically. Continue acceptance on TC22/hosted CI or schedule a controlled software-renderer comparison later.
|
||||
|
||||
Android checkpoint: primary Apps, an app-switching drawer, separate Account and Settings, host device/location settings and shared leave confirmation are implemented. A bounded build with the emulator closed passed 85 JVM tests (core22/mobile35/scanner28), scanner lint with zero errors/warnings, isolated scanner APK/test APK and shared native-test compilation. The focused native long-form test passed after waiting for actual IME dismissal; the earlier immediate assertion raced its animation. Expanded native navigation cases are compiled but not run. Photo capture/picker, registered Record Files transport, stable retry identity, exact receipts and lifecycle tests are implemented. Subsequent camera auto-upload/destruction cleanup, the public mobile-record-files vocabulary gate and associated tests await the next bounded build after concurrent asset/backend work finishes. No emulator restart is authorized; physical acceptance uses the separate explicit runner in [DEVICE-TESTING.md](DEVICE-TESTING.md).
|
||||
|
||||
The session native test now passes login/approval/catalog/process and recreation, including parent-app metadata and denied-location reporting, against the corrected file-H2 server. It still timed out before the administrator-viewing indicator. The test now waits for explicit screen eligibility and renews the widget viewer lease like the real admin poller; that change is unverified. Actual PixelCopy pixel-boundary instrumentation passed earlier, but full session/mirror/admin-end native acceptance is not complete. Ordinary synchronous process responses have no generic status endpoint: the host retains the process reference, prevents replay and describes uncertainty before explicit cancellation; it does not promise unsupported result checking.
|
||||
|
||||
Current plan: finish native navigation/session acceptance; integrate camera/gallery with the actual Record Files runtime; freeze paired revisions for final independent reviews; qualify signed delivery and hosted CI before publication. Apps → app → process is primary navigation. Account/sign-out, Settings and diagnostics are secondary surfaces that preserve active work. Toolbar and Android Back share one guarded leave policy. See [NAVIGATION-UX.md](NAVIGATION-UX.md); implementation and native acceptance are underway, not yet complete.
|
||||
|
||||
Latest intermediate results: Android 76 JVM tests and lint with zero errors/warnings; shared native 11/12, with the remaining form/IME assertion under investigation. Companion server 37 tests, two real HTTP acceptance tests and all 15 browser E2E tests pass. The H2 last-connection close/compaction issue was reproduced and fixed with the demo database lifetime setting; the browser suite now completes in 59.4 seconds. These counts describe their tested intermediate source, not a final release.
|
||||
|
||||
Record Files now has actual registered ingestion routes and a frozen 19-file consumer bundle (`retained-contract-20261031-0238`), all hashes verified by the optional integration owner. Camera/gallery implementation will use its existing storage and durable receipts. Atomic authorization across revocation and upload commit remains a shared contract issue; runtime compatibility and native acceptance are still being qualified.
|
||||
|
||||
Release preparation adds optional environment-only signing, validated version properties and a separate scanner-tag workflow that calls the native verification lane before building/signing/publishing. Actionlint passes. The signing identity/secrets, actual signed build/upgrade and final publication have not been configured or verified. See [RELEASING.md](RELEASING.md). Debug/E2E defaults and the operator's installed TC22 application are preserved.
|
||||
|
||||
## 2026-10-30 — Sessions, screen viewing and audit corrections (uncommitted)
|
||||
|
||||
The host now requires mobile-scanners `>=1.1.0 <2.0.0` and uses registered heartbeat/end/screen operations. Session telemetry contains screen metadata only. Administrator viewing leases enable bounded own-window PixelCopy JPEGs with a visible operator stop control. Capture blocks login/password screens, unrendered transitions, background/unfocused windows and ended sessions; checks repeat after capture and before upload. Foreground location is optional and permission denial leaves scanning usable. Device approval displays the server-generated short name and supports administrator renaming through QQQ.
|
||||
|
||||
Audit fixes require complete scanner receipts and reject invalid explicit maxLength. Navigation now follows NavController's actual destination, including system Back and controller replacement. Shared renderer/authentication fixes are being completed by the parallel review agent. Current intermediate verification: 75 JVM tests passed, APK builds passed, lint zero errors/eight warnings before dependency catalog cleanup. Actual PixelCopy instrumentation passed after an emulator startup ANR was resolved by a cold restart preserving data. Shared native suite passed 11 of 12 tests; a new short-viewport login regression found the IME obscuring the button and its fix is awaiting rerun. Both new navigation tests passed. Real-server session/end/viewing scenarios are implemented but await the rebuilt companion server and final native run. These are work-in-progress results, not publication or production acceptance.
|
||||
|
||||
At this October 30 checkpoint, Record Files camera/gallery routes were unavailable. The October 31 checkpoint above supersedes that dependency status. Version one remains online only.
|
||||
|
||||
## 2026-10-30 — Semantic API migration in progress
|
||||
|
||||
The current work makes the scanner host use the registered semantic UI API contract for every client operation. Public `/qqq/v1/version` discovery gates credentials, branding and metadata; the supported range is core `>=1.8.0 <2.0.0` and mobile-scanners `>=1.0.0 <2.0.0`, with `android` advertised in `clientChannels`. The four mobile extension operations now resolve under `/qqq/v1/qbit/mobile-scanners`. The native renderer applies server-directed completion, field/step updates, form submission and cancellation, and explicitly blocks unsupported required controls. Review fixes honor hidden fields and explicit null clearing, and keep generic fields named `eventId` separate from scanner retry context. See [UI-API.md](UI-API.md) for the exact rendering boundary; this is not a claim of full web-client parity.
|
||||
|
||||
The final combined JVM suites passed 53 tests: core 21, mobileapp 23 and scanner-host 9. Lint passed with zero errors and six warnings; normal debug and isolated E2E/test APKs assembled. A native administration regression exposed that core QQQ completes a process by omitting nextStep from its COMPLETE response. The renderer now honors that server signal, preserves the final display step until submitted, and closes completed processes without issuing cancellation; a ViewModel regression verifies the full sequence. The companion qbit passed root verification/install with 23 tests and demo HTTP verification; all six migrated browser E2E scenarios passed in 2.9 minutes. The first native run exposed HTTP400 because the server had not registered the Android client channel. The server now registers it while preserving existing channel policies, and the real HTTP regression sends the Android header on all requests and passes. All six native semantic scenarios passed across focused runs: incompatible core major, missing extension, missing Android channel, supported-server scan, additive-future metadata scan, and native administrative approve/revoke/cancel. The first five passed before the terminal-state correction; the final administration scenario passed afterward in 255.917 seconds. Its OpenAPI audit matched all 31 requests to 13 published operations, including backend cancellation. Safe stage markers show both processes reaching completion and Close, then a populated form being cancelled without changing persisted status. The emulator was CPU-bound with animations enabled; the runner now wakes/unlocks only the selected emulator, temporarily disables its animation scales and restores them during cleanup, allows 60 seconds for UI conditions and 300 seconds for instrumentation. The six shared native sample tests also passed. Hosted Android verification remains pending; earlier records below apply to their earlier revisions.
|
||||
|
||||
Active plan: publish the Android branch and verify hosted results. The companion server is published at 7dd02b8874882ca2edcdcd89109b3dee6b4b0c88 with all server CI checks green, and the Android workflow pins that revision. The six shared native sample instrumentation tests passed on API35. The local server and emulator restarted after the workstation crash with data preserved.
|
||||
|
||||
Physical acceptance now includes the Zebra TC22 on Android 14 with DataWedge 13.0.325. The latest normal APK was installed with login/enrollment preserved; browser record approval displayed one device approved. The native catalog and Scan Capture opened with Connected status. The DataWedge profile associates all scanner-app activities, enables barcode input, disables keystroke output, and sends the SCAN action/default category as a broadcast to the explicit package with signature checking enabled. The user performed two deliberate physical triggers and saw Saved scan. The matching persisted Code128 row was independently inspected in the backend: synthetic barcode QQQ-TEST-123456, symbology LABEL-TYPE-CODE128, source scanner, user scanner, TC22/Android14/app0.1.0, mobileScanApp/mobileScanCapture, and capture/receipt timestamps. The two deliberate Code128 triggers produced two records, one event per trigger. A subsequent physical QR scan also displayed Saved scan; the independently inspected row preserved the leading-zero barcode 001234567890, symbology LABEL-TYPE-QRCODE, scanner source and the same TC22/user/app/process attribution with capture/receipt timestamps. The table contained exactly three records for two Code128 triggers and one QR trigger. Connection-loss/retry and revoked-device behavior still need physical-device acceptance; emulator checks do not replace those cases.
|
||||
|
||||
## Earlier verification records
|
||||
|
||||
2026-10-30 process E2E follow-up: added a real scanner-host Compose instrumentation scenario against a fresh QQQ demo database. It drives server selection and login, confirms pending enrollment, approves through the real admin process, selects the native app/process, submits a leading-zero manual barcode, waits for the actual saved-scan acknowledgment, independently queries durable provenance, and verifies a revoked device cannot save through its already-open process. The final scenario passed on API35 (one end-to-end test, 50.932 seconds), including completion of administrative result screens. It uses the separate `io.qrun.mobile.scanner.e2e` application; the operator's app, accounts, data and existing forwarding are preserved. Random test credentials are passed through stdin to private app storage and redacted from reports. The runner removes both test APKs and cleans up its temporary server/database and dedicated forwarding; only the normal scanner package remained installed after verification. Added a pinned companion-server checkout and this E2E to the push/PR emulator lane; hosted execution remains unverified until publication. Final required Gradle verification passed: 28 JVM tests, lint zero errors/six existing warnings, normal debug APK and isolated E2E/test APKs assembled. Actionlint, Node syntax validation and diff whitespace checks passed.
|
||||
|
||||
2026-10-30 follow-up: the scanner could show a blocking “No Network Connection” dialog while its server footer correctly said Connected. The shared legacy callback used one Boolean for multiple networks; losing either network incorrectly marked all connectivity unavailable. It now tracks network handles, with two regression tests that fail against the old logic. Scanner hosts opt out of the legacy internet-presence modal and use the existing server reachability footer; server authentication and device approval are unchanged. All 28 JVM tests and six native API35 instrumentation tests passed, lint has zero errors/six warnings, and the replacement APK was installed without clearing app data. Scanner activity and its existing session were restored after tests.
|
||||
|
||||
The local AVD is configured for host NVIDIA graphics (`hw.gpu.enabled=yes`, `hw.gpu.mode=host`), with the previous config backed up under the user's cache. Reopening with `-gpu host -no-snapshot` and restoring adb reverse showed Home frame percentiles of 16/17/18 ms (50th/90th/99th) and scanner 19/22/48 ms. These are post-restart observations, not a controlled before/after benchmark; the original slow process had already exited. The actual previous renderer had used the GPU despite the saved AVD setting, so disabled GPU is not established as the root cause. The admin browser is open at the demo login on port57832.
|
||||
|
||||
2026-10-29 verification: all 26 JVM tests passed, lint reported zero errors/six warnings, and the debug APK assembled successfully. Runtime diagnostics now include sanitized native API and device-request outcomes, in addition to branding and heartbeat events. Android 15 system-bar insets were corrected after emulator inspection. The shared mobile library now declares its connectivity permission for all consumers. Login copy uses a neutral title beneath the customer branding. CI APK artifacts include their own `SHA256SUMS`; local builds can be hashed with `Get-FileHash`.
|
||||
|
||||
Repository implementation is published for review in [PR #8](https://github.com/QRun-IO/qqq-android/pull/8); it has not been merged or released. The branding/diagnostics commit `a5621c5` passed all hosted CircleCI, scanner build and CodeQL checks. The optional manually dispatched hosted emulator lane was skipped; local native emulator tests passed. The companion qbit is published at https://github.com/QRun-IO/qbit-mobile-scanners, with all hosted server/CodeQL/demo checks passing and protected branches/security settings enabled.
|
||||
|
||||
Final-APK acceptance passed: six native UI tests, safe Android 15 header insets, customer branding, neutral login text, diagnostic panel and Connected → Disconnected → Connected recovery. APK SHA256: `A25451EA7A1360474428F1B806134DDFD059190FB99E81B4F69E720808C3209C`. The visible API35 emulator is left at login with `http://127.0.0.1:8080/` forwarded to host demo port57832. No credentials were entered or scans submitted through the emulator; the full operator/approval/capture walkthrough and physical TC22 remain pending. The server HTTP acceptance suite separately verifies the complete authorization/capture flow.
|
||||
|
||||
Earlier implementation records follow; the publication and emulator statements above supersede their original pending status.
|
||||
|
||||
Runtime branding and diagnostics added: public server metadata supplies display name, primary color, and an optional HTTPS logo; malformed/missing metadata uses QQQ defaults and server changes reset presentation. A persistent foreground connection footer checks the public status endpoint, with bounded credential-free diagnostics Copy/Clear. Shared top-bar colors now use the matching Material foreground role and its connectivity callback is disposed correctly. Branding/status protocol, isolation, malformed input, response redaction, and log bounds have JVM regression tests. Validation passed: full 23 JVM tests (core12, mobileapp4, scanner7), debug APK assembly, and lint with zero errors / six warnings. The new APK SHA256 is F388AF488688DE87811FE32C94D07601DC3DD61ADAC139069ADCFA627E21D01C. Updated emulator UI acceptance is pending.
|
||||
|
||||
Follow-up: diagnostics now also collect fixed native QQQ/device request outcomes through an optional, exception-isolated observer (status and latency only). Added regression cases for HTTP errors, transport failure, observer exceptions, and category formatting. Emulator confirmed F388 branding/login/Connected footer/diagnostics; observed Android15 system-bar overlap corrected with safe-drawing insets. Final verification for these follow-ups is pending.
|
||||
|
||||
2026-10-29: Implementing QQQ Mobile scanner host and native host extensions. First target is Zebra TC22, online only. Backend companion is `qbit-mobile-scanners`; metadata-defined apps/processes remain the native execution model. Device approval is mandatory. Local demo uses QQQ TABLE_BASED auth on QQQ4.1.0-RC.20.
|
||||
|
||||
Implemented server selection, per-server enrollment credentials, approval gate, SCANNER widget DataWedge input, explicit same-event retry, native password login, host extension points, and removal of session/token logging from touched authentication paths. No commit or push.
|
||||
|
||||
Validation passed: 19 JVM tests (core12, mobileapp4, scanner3), debug APK assembly, APK signature verification, and lint with zero errors. Five lint warnings remain: three inherited Navigation custom-check compatibility warnings and two dependency/catalog notices. Real sanitized demo HTTP metadata/process/init fixtures deserialize through the native DTOs. Server logout, HttpOnly-cookie login, and per-origin header isolation have regression tests. Physical TC22 acceptance remains required.
|
||||
|
||||
Previous pre-branding debug APK SHA256: B3E160CE3279C369CEDB425AC6E777A2729CE8A2E430E7D6F46D35C6376F8214.
|
||||
|
||||
Repository readiness added: pinned GitHub scanner tests/lint/APK artifacts and optional emulator workflow; CODEOWNERS, contribution/security/conduct guidance, issue/PR templates, Dependabot, changelog, editor/line-ending policy, and scoped agent instructions. `actionlint`, `shellcheck`, `shfmt`, and `git diff --check` passed. Upstream main `63fca89` was fast-forwarded and all implementation files restored without conflicts; the managed CircleCI Android profile and QRun-IO Maven registry updates are preserved. The full Gradle verification command passed again after reconciliation (16 executed tasks, remaining unchanged tasks up to date). Remote CI and emulator acceptance have not yet been recorded here.
|
||||
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).
|
||||
@@ -1,131 +1,25 @@
|
||||
# TODO
|
||||
Latest November 3 checkpoint (current; supersedes the older entries below):
|
||||
# Checklist
|
||||
|
||||
- [x] Android `f72b90d` the signed application-code commit published in PR 10 (SSH read-back verified); Gitea CI 3480 succeeded (unit-test, device-test, package, secrets, gate); clean merge-tree with `main` b75fdac, nothing merged. Pull requests #8 and #10 remain open; merge and release not done. This update changes documentation only.
|
||||
- [x] Fresh disposable TC22 Echo ten-stage run: 109/109 semantic requests (simulated inputs/printing); installed APK `4AAB6951...AB850E` unchanged data.
|
||||
- [x] Fresh real Authentik desktop acceptance passes; native harness compiled, 8 launcher guards pass. [ ] On-device run awaits the temporary CA and PIN; no trust bypass.
|
||||
- [x] Server-side RC.21 gate PASS: 377 unique tests plus 15 real admin-browser E2E on public RC.21; the earlier MySQL timeouts did not recur. [ ] It is public core RC.21 with qualified private owner inputs, not a public/released cohort; the normal runtime core is not upgraded and Echo on RC.21 is not installed.
|
||||
Open work only, as of **2026-11-05**. Current evidence: [SESSION-STATE](SESSION-STATE.md). Completed and superseded items: [history](history/README.md).
|
||||
|
||||
- [x] Install final client over the normal TC22 with signature match/data preserved; existing sign-in/approval and Connected verified. Settings include portrait, location reporting and working wallpaper preview.
|
||||
- [x] Fresh 222 JVM tests, lint zero errors/warnings, all APK builds, 23 Node tests and 255-file standards gate; updated ten-stage native Echo acceptance passes with simulated scans/printing.
|
||||
- [x] Integrate and compile opt-in real Authentik Android acceptance; real-provider desktop acceptance rerun passes all six phases with strict TLS.
|
||||
- [ ] Run real native Authentik after temporary CA installation/device interaction. Confirm physical scanner/label count/fidelity, camera/GPS/projection/profile behavior. Complete shared artifact publication/hosted CI; signing remains last.
|
||||
## Hardware and release qualification
|
||||
|
||||
Current checkpoint (November 2, copies adoption; supersedes older source/runtime notes below):
|
||||
- [ ] Qualify actual GS1 label media and physical printed-label rescan. Synthetic capture/render evidence does not close this check.
|
||||
- [ ] Verify actual production-APK native Authentik login, logout and account switching; awaiting the MC wireless-debug endpoint. Real-server discovery (UI API 1.10, strict nonce, scanner and printing channels) is already verified; no native-Authentik PASS is claimed.
|
||||
- [ ] After Android PRs 17 and 18 merge, rebase `release/scanner-1.0.2`, then build, sign (same permanent signer) and publish 1.0.2 (versionCode 4) with checksum and installation instructions after remaining qualification; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). The signed 1.0.1 (versionCode 3) candidate is superseded and stays unpublished. The published release is still the 1.0.0 signed preview.
|
||||
- [ ] Publish and distribute full release assets to remaining devices; validate MDM/company profile and wallpaper separately. A customer deployment needs a customer HTTPS URL with a trusted certificate.
|
||||
- [ ] Printing: the next installer remains with the active printing owner (print nonce fix PR 11 postdates published print 1.1.1).
|
||||
- [ ] Optional, non-blocking: automatic APK signing CI. Direct APK release does not depend on it.
|
||||
|
||||
- [x] Verify optional Copies per scan through the actual generic native UI: default 1, range 1–100, selected 3 retained across two scans/recreation, and Apps/Leave; ten stages, 116 documented requests. Normal TC22 setup shows 1 against the updated local server.
|
||||
- [x] Combined candidate gate: 222 JVM tests, lint zero errors/warnings, debug/E2E/test APK compilation and 15 Node tests. Normal installed APK remains D8109C61; newer feedback/profile/wallpaper/AIM code is not installed.
|
||||
- [ ] Physically confirm label quantity and barcode fidelity. Native Authentik and camera/GPS/projection qualification remain open while the operator is remote; temporary CA installation is unconfirmed.
|
||||
- [ ] Coordinate canonical Generation copies adoption and coherent publication/CI. Signing/distribution remains last.
|
||||
Done (November 5, do not repeat): MC9400 migration with release 1.0.0 trusted-HTTPS enrollment then in-place 1.0.1 update preserving login/enrollment; Apps/ScanCapture navigation and EMDK Scanner-ready observed; operator-confirmed physical Saved scan verified in the backend (CODE128, AIM, raw bytes, user, device; actual barcode differed from the requested literal). Live photo policy verified with a signed MC9400 synthetic gallery photo (`AVAILABLE`, exact parent/type/size/hash).
|
||||
|
||||
- [x] Implement negotiated mobile1.5 raw-byte transport with immutable capture, same-server capability gating and byte-identical failed-request retry;185 JVM tests, lint0errors/0warnings, debug/E2E builds and QA test compilation pass.
|
||||
- [ ] Qualify new raw-byte delivery on TC22 and complete actual AIM extraction/decoder-profile checks. Current source leaves AIM absent and does not change hardware configuration; full Echo fidelity is still open.
|
||||
- [x] Verify native `Origin` compatibility:174 JVM tests, lint0errors0warnings and APK build pass; normal TC22 login/approval/heartbeat/Echo setup/printer-media choices/Cancel-Leave pass against the strict local Echo host through8080->57832, preserving data.
|
||||
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.
|
||||
|
||||
- [x] Verify generic INTEGER Document choice/dependency clearing on actual TC22:7 stages,112 semantic requests,99 focused/host tests and8 Node guards; simulated printing, normal data preserved.
|
||||
- [x] Connect the installed print client to the single local server and publish the actual Echo4x1 document through native QQQ processes.
|
||||
- [x] Resolve the printing agent's earlier 4x6 media report versus loaded 4x1 stock: the Printing owner aligned 101600 x 25400 micron/300 dpi media and the TC22 selected the real printer, fresh 4x1 media and published document; Next succeeded.
|
||||
- [ ] Complete one physical Code128 (QQQ-TEST-123456) scan, print and rescan on the TC22, then leading-zero QR. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed.
|
||||
- [ ] Qualify public coherent artifacts and primary Gitea CI; signing and distribution stay last by user decision. Core PR 1337 normal CI and publication are not established; do not repin default dependencies.
|
||||
## Dependencies and broader coverage
|
||||
|
||||
- [x] Add metadata-driven Echo acknowledgment/manual-entry hints with old defaults; 167 JVM tests, strict compilation, lint zero errors/warnings and debug build pass.
|
||||
- [ ] Qualify native Echo scan/print-acceptance with independently decoded physical output (setup and Leave against the real owner adapters are verified; source tests are not physical printing evidence).
|
||||
- [x] Verify Query artwork, Dogfooding/Query links and licenses on the normal TC22 About screen using filtered accessibility data.
|
||||
- [ ] 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.
|
||||
|
||||
- [x] Apply scoped copyright/import/header/formatter corrections, mechanical CI gate and Kotlin warnings-as-errors;163JVM plus46TC22 sample native cases pass. See [review](CODING-STANDARDS-REVIEW-2026-11-01.md) for the remaining formatter CI limitation.
|
||||
- [x] Add Query artwork, app version, Dogfooding/Query links and license access under Settings → About QQQ Mobile.
|
||||
- [x] Qualify real Authentik desktop nonce rejection and retained-browser A → company logout → fresh B; actual Android callback acceptance remains separate.
|
||||
|
||||
- [ ] Resolve the explicitly retained [legacy source follow-ups](LEGACY-SOURCE-FOLLOWUPS.md): metadata typing, renderer contracts, navigation/action validation and build identity. Removing inline work markers does not implement these features.
|
||||
|
||||
- [x] Replace the raw diagnostics popup with tested full-screen status/activity, filtering, pause, copy/share/clear and Back; 159 client JVM tests, zero-warning lint/build and the focused TC22 diagnostics UI test pass.
|
||||
- [x] Default foreground location reporting on, preserving explicit opt-out and one-time Android permission behavior; settings/policy tests pass.
|
||||
- [x] Verify current trusted EMDK Code128, QR leading zeros and menu/reopen on installed checkpoint1c766bf/E34AC3C6.
|
||||
- [ ] Observe the diagnostics/location update on the normal package, grant/deny/restore location permission and verify an actual provider fix; keep physical lifecycle/outage/retry and provider acceptance gates open.
|
||||
|
||||
- [x] Verify the API1.10 original-nonce and optional company-logout client patch: 151 JVM tests, zero-warning lint/build and 28 disposable TC22 native authentication/lifecycle tests pass; independent 26-file source review completed with no findings.
|
||||
- [ ] Qualify the paired strict server policy and actual Authentik A-to-company-logout-to-B handoff, including the exported native callback and real SDK/browser logging boundary; synthetic native tests do not close this gate.
|
||||
|
||||
- [x] Verify current TC22 Code128 hardware trigger-to-save on the trusted EMDK path with operator Saved scan, matching backend source/model and distinct event identities; see [physical evidence](PHYSICAL-TC22-ACCEPTANCE.md).
|
||||
- [ ] Finish current EMDK leading-zero QR, repeat-intent, lifecycle/ownership and retry/leave acceptance; historical DataWedge scans do not qualify these checks.
|
||||
|
||||
- [x] Verify the synchronized rotation/recreation assertions in both5ba GitHub native runs; one entire run passed and the other reached a later fresh-login timeout.
|
||||
- [x] Verify final fresh-login readiness at229fce3 and the later portrait-default7de checkpoint: all eleven hosted checks pass, including native navigation/input, PixelCopy and semantic scenarios with a new session. QR documentation2e0fd962 does not qualify physical QR acceptance.
|
||||
- [x] Make portrait the default for missing/invalid preferences while preserving explicit choices; four focused tests, lint/debug assembly and preserving TC22 install pass, with actual requested portrait orientation confirmed.
|
||||
- [ ] Finish the operator walkthrough on the newly updated normal TC22 app; private data/enrollment and the development signing identity were preserved.
|
||||
- [x] Compile the opt-in screen-consent QA harness and ordinary E2E tests; verify early opt-in/protected-port safety gates. Actual permission/display acceptance remains open.
|
||||
- [x] Compile/review additive consent-based device-screen client and paired1.2 server:137 JVM, package/lint/APK inspection, ordinary-UID TC22 scan-input rejection,39 server/six demo/all15 browser cases and strict provenance pass. Actual projection acceptance remains separate.
|
||||
- [ ] Execute opt-in actual Android consent, notification, Home/background pixels, Stop, lease expiry, lock/rotation and forced-session-end acceptance on the refined device-screen candidate.
|
||||
- [x] Supersede the historical hosted Back/focus failure gate with all eleven passing checks at7de; retain the failed-job diagnostics and actual Back/focus regression requirements in history.
|
||||
- [x] Establish the current EMDK Code128 positive control after the historical failed attempt: eight priorF3AF events and oneC891 event are verified. Preserve the failed-attempt evidence and bounded operator stage; QR/lifecycle/retry acceptance remains open above.
|
||||
- [x] Resolve normal backend ownership and perform the reviewed API 1.9 upgrade with fresh backup, original-column/data checks, recovery logins and live account validation; preserve the operator APK. Private verification confirms two devices and three scans retained.
|
||||
|
||||
- [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).
|
||||
- [x] Verify the trusted scanner-ingress replacement with production-path ordinary-UID rejection and actual installed EMDK Code128 positive controls. Preserve the historical security report scope; this does not close the remaining physical lifecycle/QR matrix.
|
||||
- [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.
|
||||
- [x] Reverify hosted Back/focus behavior at7de with real Back injection and the focus guard; historical failures remain documented in session state.
|
||||
- [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).
|
||||
- [x] Resolve viewing scope through optional operator-consented DEVICE_SCREEN on mobile1.2 source, retaining default APP_CONTENT and old-server compatibility. Actual projection acceptance remains open; normal mobile1.1 runtime exposes app-content only.
|
||||
|
||||
- [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.
|
||||
|
||||
- [x] Qualify semantic OAuth2 client protocol/policy and generated notices; all seven synthetic browser-result cases and the selected native navigation suite passed on TC22. See [acceptance evidence](NATIVE-AUTH-ACCEPTANCE.md).
|
||||
- [ ] Complete actual configured Authentik acceptance: real browser/exported-callback round trip, authorization rotation/process death, and next-operator switching with prior browser SSO. See [authentication deployment](AUTHENTICATION.md).
|
||||
- [x] Verify the single hamburger drawer correction: no duplicate Apps/Switch app controls, fixed Settings/Diagnostics, signed-out gating, drawer Back preserving drafts, and guarded Home/app switching with exactly one cancellation.
|
||||
|
||||
- [x] Verify persisted device Orientation choices on TC22, including process draft and pending-photo preservation across rotation.
|
||||
|
||||
- [x] Implement and verify [navigation UX](NAVIGATION-UX.md): primary Apps/app/process navigation, secondary Account/Settings/diagnostics, shared Back/leave policy, protected in-flight operations and sign-out lifecycle.
|
||||
- [x] Prepare independent scanner release workflow and environment-only signing/version configuration; actionlint passes.
|
||||
- [x] Independently review the exact release workflow, reusable verification and Gradle signing configuration; see [review scope and hashes](RELEASE-AUTOMATION-REVIEW.md). No actionable source finding; external protection settings are not verified by this review.
|
||||
- [ ] Configure reviewed GitHub release protections and the organization signing identity, verify the actual signed APK and upgrade/migration, and publish only after all [release prerequisites](RELEASING.md) pass. Signing/distribution remains owner-deferred until the final step.
|
||||
|
||||
- [x] Correct false legacy network blocking while the scanner's server is reachable; preserve multi-network connectivity for shared clients.
|
||||
- [x] Persist workstation host-GPU configuration and document cold-start commands.
|
||||
|
||||
- [x] Add runtime server branding and a foreground connection footer with bounded sanitized diagnostics.
|
||||
- [x] Verify customer header/theme, diagnostics and connection loss/recovery on the emulator.
|
||||
- [ ] Verify customer-specific logo assets and physical TC22 appearance.
|
||||
|
||||
- [x] Complete Gradle unit tests, debug APK, signature, and lint verification (26 tests; zero lint errors).
|
||||
- [ ] Complete the current EMDK physical matrix: QR, lifecycle/ownership, retry/revocation, bad credentials, sign-out and re-login. The completed DataWedge checks below are historical and do not qualify current EMDK behavior.
|
||||
- [x] Verify TC22 physical Code128 and QR triggers through DataWedge intent output, visible Saved scan, preserved leading-zero QR data and independently inspected backend provenance; two Code128 triggers plus one QR trigger produced exactly three records.
|
||||
- [ ] Verify release APK signing/distribution separately; current deliverable is a debug APK.
|
||||
- [x] Reconcile exact historical project-owned source license boilerplate and the IDE template with the existing Apache-2.0 migration; preserve copyright ownership and other notices.
|
||||
- [ ] Verify third-party asset/dependency attribution and signed release/upgrade delivery before production distribution.
|
||||
- [ ] Configure and test production identity-provider integration; TABLE_BASED remains supported, and native OAuth2 requires explicit server advertisement and callback registration.
|
||||
- [x] Implement public `/qqq/v1/version` compatibility checks before credentials or native screens, including required features and the registered mobile-scanners extension.
|
||||
- [x] Implement semantic process completion, field/step updates, editable/view forms, backend cancellation and explicit unsupported-component blocking.
|
||||
- [x] Verify semantic migration JVM suites: 53 tests passed (core 21/mobileapp 23/scanner 9), including hidden fields, explicit null clearing, generic event ID isolation and completion when the server omits `nextStep`.
|
||||
- [x] Verify companion browser E2E after the semantic migration: six scenarios passed in 2.9 minutes.
|
||||
- [x] Require Android client-channel discovery; companion real HTTP tests use the Android header on every request and pass.
|
||||
- [x] Complete final JVM/lint/build verification of this semantic API migration: 53 tests, zero lint errors/six warnings, normal debug and isolated E2E/test APKs.
|
||||
- [x] Verify all six native scenarios across focused runs and the OpenAPI request audit: incompatible core, missing extension, missing Android channel, baseline/additive scans and native administrative forms.
|
||||
- [x] Identify the TC22 through ADB, authorize USB debugging, install the latest normal APK with login/enrollment preserved, approve its device record and open native Scan Capture with Connected status. Initial Code128 decoding passed; the remaining physical matrix is still pending.
|
||||
- [ ] Extend the [documented native component boundary](UI-API.md) with remote possible values, record lists, specialized forms and table/report/download screens as required; add conformance and rendered E2E coverage for each new capability.
|
||||
|
||||
Offline queue/background synchronization is deliberately outside version one.
|
||||
- [x] Implement semantic mobile session activity, server termination, demand-based own-app screen viewing and optional foreground location.
|
||||
- [x] Add capture lifetime/lease/race tests, complete scanner receipt checks, strict maxLength and NavController regression coverage.
|
||||
- [x] Verify short-viewport login and focused long-form submission after IME Done and dismissal handling.
|
||||
- [x] Complete integrated session/native administrator termination E2E and all 12 shared TC22 navigation/input/account tests; local emulator remains closed.
|
||||
- [x] Freeze and review session/whole-device source within the documented security scopes and independent architecture A1–A11 closure. Final release revision/scope and external-runtime gates remain in the release ledger.
|
||||
- [x] Integrate camera/gallery with Record Files typed routes and guarded optional bridge fixture; synthetic native E2E verifies persisted receipt, rotation and forced-logout cleanup.
|
||||
- [ ] Complete actual camera-lens/operator acceptance after final runtime installation; synthetic activity results do not prove the OEM camera application flow.
|
||||
- [x] Supersede the October31 hosted checkpoint gate with all eleven passing hosted checks at7de; retain earlier revision-specific results as history.
|
||||
- [x] Verify the published push/PR native E2E lanes through the current7de hosted checkpoint; physical operator acceptance remains separate.
|
||||
- [x] Run the native sample instrumentation suite (six passed on API35).
|
||||
- [x] Add and run real-server scanner-host E2E: login, pending enrollment, approval, native app/process selection, manual scan persistence and revoked-device denial. Use an isolated app ID and database.
|
||||
- [x] Run and verify native real-server E2E on pushes and pull requests; current7de hosted checks pass.
|
||||
- [ ] Complete the final operator walkthrough on TC22 after updating the preserved demo/runtime, including outage/reconnect and real camera capture.
|
||||
|
||||
- [x] Implement named semantic process choices and qualify Echo native setup/two scans/rotation/Apps Leave onTC22 (169JVM,8guards,86documentedrequests; printing simulated).
|
||||
- [x] Real Echo host and owner adapters are composed and running in the single local server (see the Origin item above).
|
||||
- [ ] Print a physical label and rescan the exact value and type (tracked above).
|
||||
|
||||
- [x] Clear scanner feedback on process reset; 57 mobileapp JVM tests, zero-warning scanner lint, debug build and 255-file standards pass. Hosted checks and PR10 → PR8 → main integration remain pending.
|
||||
|
||||
# Scoped administrator viewing update — November 2
|
||||
|
||||
- [x] Remove app-authored viewing stop controls, verify the existing native session-monitor flow and install on TC22 with app data preserved. Retain the visible indicator, Android consent/system stop controls, server leases and authorization.
|
||||
Offline queueing and background synchronization remain outside version one. The November 4 TC22 operator pass is accepted and should not be requested again.
|
||||
@@ -63,7 +63,7 @@ The scanner event preserves the barcode string, event UUID and capture timestamp
|
||||
|
||||
## Session activity and screen viewing
|
||||
|
||||
The approved, authenticated host sends foreground activity through the registered extension. Heartbeats contain app/process/step names and availability, never form values, barcodes, passwords or credentials. The server derives identity from the verified QQQ session and device. Ending a session in QQQ causes the host to clear its local login when the next protected operation or heartbeat returns an authentication failure. Signing out or changing servers stops capture immediately and attempts to end the current server session before standard logout.
|
||||
The approved, authenticated host sends foreground activity through the registered extension. Heartbeats contain app/process/step names and availability, never form values, barcodes, passwords or credentials. Each heartbeat also carries the installed `osVersion` and `appVersion` (the values enrollment reported); a mobile-scanners 1.6 server refreshes the device record when they change so scan receipts after an in-place update show the new version, and older servers ignore the extra optional keys. The server derives identity from the verified QQQ session and device. Ending a session in QQQ causes the host to clear its local login when the next protected operation or heartbeat returns an authentication failure. Signing out or changing servers stops capture immediately and attempts to end the current server session before standard logout.
|
||||
|
||||
An administrator viewing the ordinary QQQ session record renews a short screen-viewing lease through its standard widget. The host uses PixelCopy on its own app-content rectangle only, at most once per second, bounded to 640×1280 pixels and 256 KiB JPEG. No OS screen, other application or remote control is exposed. Capture requires the resumed, focused, approved authenticated screen, completed rendering and a live lease. Password screens, loading transitions, backgrounding and logout block capture and clear the server's displayed frame. Screen/session state is checked after capture and immediately before upload. Lease timing uses server-relative duration and Android's monotonic clock, not the handheld wall clock. Frames are not saved locally.
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
> Historical record, superseded by [current status](../SESSION-STATE.md). Do not treat as current.
|
||||
|
||||
# Finish ledger for 1.0 (current: November 3, 2026)
|
||||
|
||||
Nothing here is a 1.0 release. History is in SESSION-STATE.md.
|
||||
|
||||
## Source published (not merged, not released)
|
||||
|
||||
- Android `f72b90d08cf29d2785cef1d5c5e7eeaf75f9afd2`: signed application code published in PR 10 (read back over SSH before this documentation update). Gitea CI 3480 succeeded: unit-test, device-test, package, secrets, gate. A clean git merge-tree with `main` b75fdac was verified; nothing was merged. Pull requests #8 and #10 remain open. This documentation update leaves runtime code unchanged.
|
||||
- Server `c0e27b36fd16c00eaca3451af3df43f4deedada1`: signed and published in Gitea PR 7, including the verified QQQ 4.1.0-RC.21 dependency update and Record Files test fix.
|
||||
- Generation copies: signed `aac68907e67906aeef4e0cd687d28bc98b738f8c` on `feature/echo-generation-copies` (base PR 343 head `8f473fa`), 13 paths, 21/21 manifest match, real PostgreSQL 17 migration passes. Owner integration and publication are not acknowledged.
|
||||
|
||||
## Implemented and installed (normal TC22 and local server 57832)
|
||||
|
||||
- APK `4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E`: same signer, no data clear. Connected, Apps/Settings, portrait, location on and the brand-only wallpaper preview were verified. No wallpaper or company profile was applied. The device shows Echo setup, Copies 1.
|
||||
- Server on 57832: UI API 1.10, mobile-scanners 1.5, printing 2.3, mobile-record-files 1.0, with the original database, accounts, approval and printer. Echo Copies per scan (default 1, range 1-100, one job per scan). Its core is not upgraded to RC.21.
|
||||
|
||||
## Tested
|
||||
|
||||
- Android: 222 JVM tests, lint 0 errors/0 warnings (2 info), debug/E2E/test APK builds, 23 Node tests, 255-file standards.
|
||||
- Disposable TC22 Echo ten stages, 109/109 semantic requests; inputs and printing are simulated.
|
||||
- Server RC.21 gate PASS: 377 unique tests (Generation 179, Printing bridge 16, scanner 53, demo 8, mobile Record Files 18, Echo 68, Echo demo 35), zero failures/errors/skips, Checkstyle PASS, plus 15 real admin-browser E2E on public RC.21. Earlier MySQL timeouts did not recur. Evidence: `C:/Users/james/.cache/qqq-rc21-copies-gate-076d059d8d0641d9a85874ec64008def/verified-summary.json`.
|
||||
- Honest scope: this is public core RC.21 plus qualified owner inputs. The Printing-bridge tests still resolve three private `pu507` libraries through the owner Printing POM, and other owner qbits are private inputs. It is not a fully public or released cohort.
|
||||
- Real Authentik desktop acceptance passes with strict TLS (nonce, A, logout, fresh B). The native harness is compiled with 8 launcher guards passing.
|
||||
|
||||
## Still open (none of these is claimed done)
|
||||
|
||||
- Generation: canonical owner integration, then public reproducible inputs for the owner qbits and a coherent published cohort.
|
||||
- Source: primary CI on the final source, merge, review completion and release.
|
||||
- Manual on the TC22 (operator remote): physical trigger, scan value/type, AIM, label count, rescan, photo capture/upload, GPS, projection, company profile/wallpaper deployment.
|
||||
- Native Authentik on the device: temporary CA installation, PIN interaction and the real callback. No trust bypass.
|
||||
- Signing and distribution: explicitly last, by user decision.
|
||||
|
||||
## Pointers
|
||||
|
||||
Runtime `C:/Users/james/.cache/qqq-local-all-qbits-current.txt`; native proof `scanner-host/build/native-device-e2e/echo-proof.json`; Generation handoff `C:/Users/james/.cache/qqq-generation-copies-handoff.md`; Authentik `docs/REAL-AUTHENTIK-ACCEPTANCE.md`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Historical status notes
|
||||
|
||||
November 5 record UX evidence is in [the completion note](../RECORD-UX-2026-11-05.md). The preceding [session state](SESSION-STATE-before-record-ux-2026-11-05.md) and [checklist](TODO-before-record-ux-2026-11-05.md) preserve the earlier local-preview and signing details.
|
||||
|
||||
These files are historical records, not current status. They preserve the rolling notes as they stood at the end of 2026-11-04, including statements that were later superseded (for example signing deferral, open pull requests, and a missing release workflow). For the current view see [status](../SESSION-STATE.md), [checklist](../TODO.md) and [finish ledger](../FINISH-1.0.md).
|
||||
|
||||
- [SESSION-STATE-through-2026-11-04.md](SESSION-STATE-through-2026-11-04.md): rolling session log, newest first.
|
||||
- [TODO-through-2026-11-04.md](TODO-through-2026-11-04.md): rolling checklist.
|
||||
- [FINISH-1.0-through-2026-11-03.md](FINISH-1.0-through-2026-11-03.md): November 3 finish ledger.
|
||||
@@ -0,0 +1,50 @@
|
||||
Historical checkpoint before the November 5 record UX update. Current state is in [session state](../SESSION-STATE.md).
|
||||
|
||||
## November 5 rich-record preview
|
||||
|
||||
The same TC22 app now has grouped fields and direct related-record navigation through standard semantic metadata/query APIs. The maintained Echo demo supplies Test Data: 1,000 orders (39 fields, six sections), 12,000 items and 3,000 history entries. Android unit tests, lint and debug/E2E builds passed. Isolated MC9400 native acceptance passed paging, grouping, filtered child navigation, camera/gallery attachments to order 26, rotation and remote logout cleanup; all requests matched the semantic API. Installed TC22 APK SHA-256: `65FB631F0CFF9B68A0F3FC01D37D656FD69D690F1BA63F8A34FB314A2B03B97F`, matching signer and installed bytes verified. Existing app data, enrollment and server address were retained. These source changes remain local/uncommitted; no new release was published. This supersedes the earlier preview/server-selection status below.
|
||||
|
||||
## November 5 TC22 preview update
|
||||
|
||||
At the user's request, the existing TC22 app was updated in place to the local records/photo debug build, with matching signer verified before installation and installed APK SHA-256 `79E6CD0559419E059EC253DD0364C13BDDB3C27F566F0DA9A478FBB70D99A038` verified afterward. No uninstall, data clear, separate QA app or server change occurred. Server selection and hands-on record/photo acceptance are pending. The older acceptance evidence below applies to the prior development build.
|
||||
|
||||
# Current status
|
||||
|
||||
As of 2026-11-05. Scope: the scanner host and the local Echo workflow only. This is the authoritative current view; earlier rolling notes are historical and kept in [docs/history](README.md). Nothing here claims a stable or fully qualified 1.0; a signed preview is published. Checklist: [TODO](../TODO.md). Ledger: [FINISH-1.0](../FINISH-1.0.md).
|
||||
|
||||
## Local feature verification
|
||||
|
||||
Uncommitted GS1 and record-photo work adds permitted-table browsing, record details, and camera/gallery attachments using existing semantic APIs. Android unit tests, lint and debug/E2E builds passed. Disposable MC9400 tests passed synthetic GS1 capture with exact HRI and saved-record browsing, plus record-linked camera/gallery upload, rotation, navigation blocking and remote-logout cleanup. These tests used isolated apps/databases and simulated barcode input/printing. The companion composed server cohort passed 188 tests and Checkstyle; the existing photo-process regression and eight runner guard tests also passed. The ordinary scanner app is unchanged; physical GS1 print/rescan and deployment remain outstanding.
|
||||
|
||||
## Source
|
||||
|
||||
- Protected `main` contains all application work (PR 8 and PR 10 merged). Application source is unchanged by the documentation and signing work after that merge.
|
||||
- The Gitea workflow `.gitea/workflows/qrun-android-release.yaml` exists on `main`; it calls the shared platform Android workflow and is not a scanner-APK release pipeline (see [RELEASING](../RELEASING.md)).
|
||||
- Documentation is published separately (signing guide and this reconciliation). Shared CI/CD redesign is outside this repository's local use and does not gate the demo.
|
||||
|
||||
## Local demo: accepted
|
||||
|
||||
- Local server on the workstation (port 57832): public QQQ 4.1.0-RC.21 plus released Printing 1.0 and qualified document, files and scripting owner inputs. UI API 1.10, mobile-scanners 1.5, printing 2.3, mobile-record-files 1.0.
|
||||
- TC22 runs the development-signed debug APK (version 0.1.0, versionCode 1, SHA-256 `38A11155BF6A9C089A1EE6D9E44401E8FF39497B7150B51A85F1F916148BE60D`), reaching the server through device port 8080 forwarded to the workstation.
|
||||
- The operator reported the outstanding manual TC22 acceptance group ALL PASS on 2026-11-04, including label count/fidelity, camera and GPS, and screen viewing. This is operator-reported acceptance, not instrumented or per-case evidence; no per-case artifacts are recorded and the checks are not requested again.
|
||||
- Desktop-browser Authentik acceptance passed with strict TLS (nonce rejection, login, company logout, fresh second operator).
|
||||
|
||||
## MC9400 (second device)
|
||||
|
||||
- A production-signed 1.0.0 (versionCode 2) APK was installed on a fresh MC9400 (Android 13, EMDK present) and launched; installed bytes matched the signed APK hash. No fatal crash at startup.
|
||||
- At the user's request the MC9400 now runs the same development APK as the TC22 in demo mode, with device port 8080 forwarded to the workstation server over paired wireless ADB. It signed in and was approved through the ordinary mobile device approval process (status APPROVED).
|
||||
- **Remaining check:** a physical MC9400 trigger scan confirmed in the backend. Not yet verified; no scan claim is made.
|
||||
|
||||
## Signed release
|
||||
|
||||
- Direct signed-APK / MDM distribution was chosen. A QRun-owned key exists (alias `qqq-mobile-release`, certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`) with a verified backup. Key, passwords and vault details are not in this repository.
|
||||
- Signed `QQQ-Mobile-1.0.0.apk` (versionCode 2, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`) exists locally, built from `main` application source: lint 0 errors/0 warnings, v2/v3 signature with the exact certificate, alignment and non-debuggable verified.
|
||||
- It is published as a **signed preview**: Gitea prerelease [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (ID 1361, signed tag on protected `main` `3eef6431ca39b6450cf764424d921a02b2afb9ab`; app source matches `0ae2101f`, the tag adds documentation only). Hosted assets are the APK (14933499 bytes) and `SHA256SUMS` (88 bytes), both downloaded back and matching the hash above. The local installation kit ZIP was not published. Forge sign-in is required. This is not a stable release. No automatic signing CI is configured, and SDK CI publication is a separate path.
|
||||
- Production qualification (still pending) needs an HTTPS server and a full device pass of the signed build ([RELEASING](../RELEASING.md), [INSTALLATION](../INSTALLATION.md)). The local demo does not need HTTPS; release builds reject HTTP.
|
||||
|
||||
## Open (not claimed)
|
||||
|
||||
- Release qualification of the signed APK end to end (HTTPS server, enrollment, scan, navigation, update).
|
||||
- Actual on-device Authentik callback: unverified; no separate evidence, remains production qualification.
|
||||
- Dependency publication: the Generation, Record Files and Scripting owner inputs behind the local server are qualified inputs, not a fully public, reproducible or released cohort.
|
||||
- Company profile / MDM rollout: nothing applied or verified on a device ([DEVICE-PROVISIONING](../DEVICE-PROVISIONING.md)).
|
||||
@@ -0,0 +1,319 @@
|
||||
> Historical record, superseded by [current status](../SESSION-STATE.md). Do not treat as current.
|
||||
|
||||
## November 4 — production signing custody (current; supersedes the signing-deferral notes below)
|
||||
|
||||
The owner decided on signed-APK/MDM distribution with a new QRun-owned Android key, stored in a QRun vault in the owner's Personal 1Password account. Key alias `qqq-mobile-release`, PKCS12 RSA 4096, certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`, valid through 2056-10-27; app ID `io.qrun.mobile.scanner`. Backup recovery was verified 2026-11-04 (recovered file hash, keystore opened, certificate compared). Root built unsigned 1.0.0/versionCode 2 (`lintRelease` + `assembleRelease`: 0 errors, 0 warnings, 2 information) from source matching protected main `0ae2101f082c241ca6a70462dc9ec3057aa6c906` (only docs differ), then signed it in isolation with `apksigner` at 2026-11-04T16:22:05Z; the key was not passed to the application build. **Local signed artifact and verification are complete:** `QQQ-Mobile-1.0.0.apk`, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`; v2+v3 verify, exactly one signer with the certificate above, 16 KiB alignment pass, non-debuggable, app ID `io.qrun.mobile.scanner`. It is not installed, published or production-qualified; the development-signed TC22 install and data are intact. Remaining: qualification of the actual production-signed APK on a migrated or different device (normal device untouched), first distribution (needs an HTTPS server), and paused CI wiring. No Infisical/CI activation, no scanner release workflow, no tags; the shared CI hold is unchanged. Runtime app code is unchanged from main `0ae2101`; Android guide PR 12 is still open. The November 4 manual all-pass was for the development-signed build and is not requested again; it does not qualify the new signer. See [RELEASING.md](../RELEASING.md). Entries below, including the deferral of signing, are historical.
|
||||
|
||||
## November 4 — signing readiness documentation (historical)
|
||||
|
||||
[RELEASING.md](../RELEASING.md) was rewritten as concise Gitea-first release readiness. This documentation update has been checked against the actual Gradle configuration and Gitea caller. No keys, secrets, tags, releases, CI, build or source were changed. Central main Android lifecycle 3634 passed and published SDK AARs but is not a scanner-APK signing pipeline. Local Gradle signing via `SCANNER_KEYSTORE`/`SCANNER_STORE_PASSWORD`/`SCANNER_KEY_ALIAS`/`SCANNER_KEY_PASSWORD` exists; release builds are unsigned without them. Production signing still needs the owner's channel decision (direct/MDM vs Play), an organization-owned key with secure storage and a backup owner, and signed-APK device acceptance. The operator reported manual acceptance all-pass on November 4; the last recorded installed build was development-signed. Signing/distribution remains last; older entries below are historical.
|
||||
|
||||
## November 3 — verified widget-state reset fix
|
||||
|
||||
`ProcessViewModel.reset()` now clears `widgetState` so last scanner feedback no longer carries into a new run. The regression proves ordinary step updates retain feedback and reset clears it. Root verified all 57 mobileapp JVM tests (zero failures/errors/skips), scanner lint (zero errors/warnings), debug APK assembly and the 255-file standards gate. Prior head `9cf789c` passed hosted CI3553 in 16m22s; this fix still needs its own hosted checks and normal PR10 → PR8 → main integration. The installed APK and manual acceptance are separate gates; signing/distribution remains deferred.
|
||||
|
||||
## November 3 — current checkpoint (verified; supersedes all older entries below)
|
||||
|
||||
- **Source:** `f72b90d08cf29d2785cef1d5c5e7eeaf75f9afd2` is the signed application-code commit published in PR 10, independently read back over SSH before this documentation update. Gitea CI 3480 **succeeded** on it (unit-test, device-test, package, secrets and gate; root read the UI). Root also verified a clean git merge-tree of `f72b90d` with actual `main` b75fdac and merged nothing. Pull requests #8 and #10 remain open; no 1.0 is released. This documentation update leaves runtime code unchanged from CI-passing `f72b90d`.
|
||||
- **Installed:** the updated normal APK `4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E` is installed with the same signer and no data clear. Connected, Apps/Settings, portrait, location on and the brand-only system wallpaper preview were verified. No wallpaper or company profile was applied.
|
||||
- **Fresh verification:** 222 JVM tests, lint 0 errors/0 warnings (2 info), APK builds, 23 Node tests, 255-file source standards pass. A fresh disposable TC22 Echo ten-stage run passed 109/109 semantic requests (simulated inputs and printing).
|
||||
- **Authentik:** fresh real desktop acceptance passes (nonce rejection, A login, logout with old-cookie replay rejected, fresh B without clearing cookies, logout) against the same isolated TLS fixture. The opt-in native harness is integrated and compiled, and its 8 launcher guards pass; the on-device run still needs the temporary CA and PIN interaction. No trust bypass. The normal server on 57832 is unchanged and ready.
|
||||
- **Server side:** server `45f9cbd` is signed and pushed (Gitea PR 7); the public QQQ 4.1.0-RC.21 source update is prepared for the next signed commit. The final RC.21 gate passed: 377 unique tests (Generation 179, Printing bridge 16, scanner 53, demo 8, mobile Record Files 18, Echo 68, Echo demo 35), zero failures/errors/skips, Checkstyle PASS, plus 15 real admin-browser E2E on public RC.21; the earlier MySQL timeouts did not recur. This is public core RC.21 with qualified owner inputs (three private `pu507` libraries still resolve through the owner Printing POM), not a fully public or released cohort. The normal runtime core was not upgraded.
|
||||
- **Still open (not claimed):** Generation canonical owner integration and public reproducible owner inputs; primary source CI, merge and release; manual TC22 trigger, value/type, AIM, label count, rescan, photo, GPS, projection and profile checks (operator remote); native Authentik CA, PIN and callback; signing/distribution, explicitly last. The normal TC22 shows Echo setup, Copies 1, Connected.
|
||||
|
||||
## November 3 — final client installed; Authentik harness integrated (earlier; superseded by the checkpoint above)
|
||||
|
||||
The verified debug APK 4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E is installed on the normal TC22 with the same signer and install -r. Existing sign-in, device approval and Connected status survived. Navigation, portrait setting, foreground-location toggle and the brand-only system wallpaper preview were verified; wallpaper was not applied. Feedback, managed configuration and AIM code are now installed, with physical trigger/fidelity still unqualified.
|
||||
|
||||
Fresh verification passes 222 JVM tests, lint zero errors/warnings, debug/E2E/test APK builds, 23 Node tests and the 255-file source standard check. The updated disposable TC22 Echo suite passed all ten stages with every request matching semantic operations; simulated input/printing only. The actual Authentik desktop test passed again on November 3 with strict TLS: nonce rejection, operator A, company logout, old-cookie rejection and fresh operator B. The opt-in real Authentik Android harness is now in the repository and compiles; native execution awaits temporary CA installation/device interaction. No normal app/server trust change was made.
|
||||
|
||||
Echo server source is signed and published at 45f9cbd in Gitea PR7. Generation copies now has an isolated canonical Git integration, with additive PostgreSQL migration verified on real PostgreSQL17. Shared source/dependency publication and hosted CI remain separate gates; no production 1.0 release or physical print claim. Earlier installed-APK/uncompiled-harness notes below are historical.
|
||||
|
||||
## November 2 — Echo copies live and native acceptance complete
|
||||
|
||||
Echo setup now exposes optional **Copies per scan**, default 1, range 1–100. The server pins it for the run and submits one job with that copy count for each scan. The normal TC22 is connected and left on the fresh setup screen showing 1. Its installed APK remains D8109C61E71660D2195337DA988F7F692CC4A0DBE95F61230930D6549CAB4D9B; no client upgrade was needed for this generic semantic field.
|
||||
|
||||
Disposable TC22 Echo acceptance passed ten stages and 116/116 documented requests, including default 1, rejecting 0/101, selected 3 across two scans, recreation and Apps/Leave. Inputs and printing are simulated; physical label count is unconfirmed. The combined source passes 222 JVM tests, lint with zero errors/warnings, APK compilation and 15 Node tests. New scan-feedback, managed-profile/wallpaper and AIM source is not installed on the normal app.
|
||||
|
||||
The existing local server on port 57832 now advertises mobile-scanners 1.5 and mobile-record-files 1.0, retaining the original database, approval, accounts and printer settings. Server/owner gates passed 377 unique tests across the qualified module runs. Generation copies changes remain in the isolated owner handoff, awaiting canonical adoption/publication. Physical trigger/print/rescan, camera/GPS/projection and native Authentik remain open; the operator is remote and temporary CA installation is unconfirmed. Signing/distribution stays last. Older checkpoints below are historical; see FINISH-1.0.md for the current ledger.
|
||||
|
||||
## November 2 — administrator viewing controls
|
||||
|
||||
Removed app-authored Stop viewing / Allow viewing, Stop device screen sharing and the custom notification Stop sharing action. The visible indicator, Android consent/system revocation, password/lifecycle guards, server authorization and lease expiry remain unchanged. The existing native regression verifies an informational non-clickable indicator with no stop/allow buttons, continued JPEG frames after resume, background frame clearing, administrator session termination and fresh login.
|
||||
|
||||
Verification: 185 JVM tests (44 core, 56 mobileapp, 85 host), eight Node guard tests, lint zero errors/warnings (two existing informational hints), debug/QA/test APK builds, and the real TC22 session-monitor scenario passed; all 112 native requests matched published semantic operations. The disposable QA packages were removed. Whole-device Android consent was not rerun for this UI removal.
|
||||
|
||||
Installed the verified debug APK D8109C61E71660D2195337DA988F7F692CC4A0DBE95F61230930D6549CAB4D9B on the normal TC22 with install -r and no data clear. The process restart required sign-in; restored the existing scanner account and verified Connected, the Echo catalog entry and existing approval. Local server57832 remains HTTP200 and the print service remains Running. This APK also contains the previously verified optional raw-scan metadata client, gated off against the unchanged installed mobile1.4 server. Existing unrelated source/test changes are preserved. No commits, publication or broader release work resumed for this scoped request.
|
||||
|
||||
## November 2 — current checkpoint
|
||||
|
||||
Source candidate: immutable `ScannerCapture` retains SDK raw bytes as canonical Base64 before the callback returns and routes them through the ordinary semantic process payload. New optional fields are sent only after same-server discovery advertises mobile-scanners >=1.5,<2; older servers still receive the five legacy keys. Barcode text and symbology remain unchanged, scan/capture string representations are redacted, and a failed-submit regression proves the entire event is resent unchanged. Verification passes 185 JVM tests (44 core, 56 mobileapp, 85 host), lint with zero errors/warnings (two existing informational hints), debug/E2E APK builds and QA Android-test compilation. New native tests and operator installation have not run. The SDK configuration is unchanged and AIM extraction is not implemented by this transport slice.
|
||||
|
||||
Completed and verified:
|
||||
|
||||
- Android source `fb3cc5d` is signed and published as Gitea PR 10. It passes 174 JVM tests (44 core, 55 mobileapp, 75 host), lint with zero errors and zero warnings, and the debug APK build.
|
||||
- The native `SameOriginInterceptor` fixes the cookie-bearing heartbeat that returned 403. The server's same-origin guard is not weakened.
|
||||
- The normal TC22 installation was updated without clearing data. Existing login and device approval, heartbeat, the real print client's printer, fresh 4x1 media and published document selection, and Cancel then Leave all work against the one local server.
|
||||
- That server is one QQQ demo on host port 57832. It runs Mobile Scanners/Echo, Collaboration/Record Files, Document Generation and Printing (API 1.10.0, mobile 1.4.0, printing 2.3.0). On this workstation, device port 8080 forwards to host port 57832. The original accounts, enrollment and approval are retained.
|
||||
- The Printing owner has aligned the loaded 4x1 stock (101600 x 25400 microns at 300 dpi) and updated the real media revision. A real Echo 4x1 barcode document is published. The TC22 selected the actual printer, the fresh 4x1 media and that document, and Next succeeded and shows Scanner ready and Connected.
|
||||
- The emulator is off because it stalls this workstation. Do not restart it automatically.
|
||||
|
||||
Not yet done:
|
||||
|
||||
- No physical Echo print or rescan has been proven. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed. One physical scan of QQQ-TEST-123456 has been requested from the user.
|
||||
- Still open for 1.0: physical Code128 and leading-zero QR scan, print and rescan; full provider, camera, GPS, projection and device qualification; public coherent artifacts; primary Gitea CI; and final signing and distribution. Signing is deliberately last, by the user's decision. The first demo working does not collapse these requirements, and nothing here claims release readiness.
|
||||
- Core native API merge continuation PR 1337 (a42f9e5) was reviewed with no actionable conflict regression and its 57 focused tests were verified. Normal CI and publication are not established, and default dependencies are not repinned.
|
||||
|
||||
The entries below are historical. Where they describe the printer media report, the 4x1 document or the same-origin fix as pending, this checkpoint supersedes them.
|
||||
|
||||
## November 2 — native cookie Origin compatibility verified on TC22
|
||||
|
||||
The combined Echo Demo on57832 (reached through device port8080) returned200 for login, then403 for the cookie-bearing session heartbeat POST, because Android's native QQQ requests omitted `Origin`. The server's same-origin guard remains enforced. Core now has a small `SameOriginInterceptor`: it sets `Origin` from the actual request URL's canonical scheme/host/port only (default ports omitted, IPv6 bracketed, never credentials/input/headers) and is installed last so additional headers cannot override it. It is used by `NetworkQQQRepository` and the `DeviceApi`, `MobileSessionApi` and `RecordFilesApi` clients. Version discovery, presentation/logo fetches, OIDC/browser/token requests and redirect refusal are unchanged.
|
||||
|
||||
All174 JVM tests passed (44core/55mobileapp/75host), with zero failures/errors/skips. Lint has zero errors/warnings and two existing informational hints; debug APK build passed. MockWebServer tests cover exact Origin plus cookie on real POSTs, canonicalization, override resistance and redirect non-forwarding. Session and record-file host tests assert the header on their actual requests.
|
||||
|
||||
The verified APK (SHA256 `0B872005B2FA84F693E73CC49133F5C1C02F732A034AA0635A2B50682A61A98E`) was installed on the normal TC22 without clearing its data. Existing scanner login and device approval work against the actual combined server; heartbeat remains connected. Echo opens its three required choices, the approved printer and its reported media load, and Cancel -> Leave returns to the app normally. This directly qualifies setup/session/cancel against the strict host, not a physical print. The earlier112-request/two-synthetic-scan acceptance remains separate. (Superseded: the printing owner has since reported the 4x1 media and the 4x1 document is published; see the current checkpoint above. Physical printing is still unproven.) Emulator stays off.
|
||||
|
||||
## November 1 — required Document choice verified on the real TC22
|
||||
|
||||
Echo asks for printer, paper/label size and Document once; all subsequent scans use those server-pinned choices. Android production code is unchanged: the generic semantic INTEGER/PVS/dependency controls handle it. The updated separate E2E app passed all7 stages on the actual TC22 (select Document, clear stale choices on parent change, validate required fields, queue two scans, recreate without duplication, Apps/Leave/CLOSED). All112 requests matched published semantic operations. Disposable app/reverse/fixture cleanup passed, normal app/data preserved, emulator off. Printing and scanner inputs are simulated; physicalScannerQualified/physicalPrintingQualified remain false.
|
||||
|
||||
Verification:99 focused/host JVM tests,8 Node guards, zero lint errors/warnings and both disposable APKs compiled; after a test-only fixed-footer click correction, the actual TC22 acceptance passed. Companion server source8da274e is signed/pushed with45 library and24 composed demo tests passing. Private live server53118 still has header fix101efc8 pending document-choice rollout; real printer pairing retry awaits the current Windows consent. Installed normal APK324227CC... remains sourcea2f0080, whose production code is identical to this test-only change. Full physical/provider/camera/GPS/projection/public artifact/signing gates remain open.
|
||||
## November 1 — Echo native semantic choices verified on TC22
|
||||
|
||||
Claude implemented named process possible values using the actual published `searchProcessPossibleValues` JSON contract, dependent-field metadata, cancellation/error/retry and stale-response rejection. The unchanged Echo fixture now completes login, enrollment/approval, printer/media selection once, two accepted synthetic scans, rotation/recreation without duplicates, and Apps/Leave closing the run on the real TC22. All86observed requests matched published semantic operations. Disposable-package cleanup passed; normal package/data and real printers were preserved.
|
||||
|
||||
Validation:169JVMtests,8Nodeguards,236-file source standards, zero lint errors/warnings and bothE2EAPKs pass. This is simulated printing, not EMDK or physical output qualification. The remaining immediate blocker is the real Generation/Printing adapter and template/host composition, followed by physical print/rescan. Normal installed APK remains625a275; normal server API1.9/mobile1.1. Production signing remains deferred.
|
||||
# Session state
|
||||
|
||||
## Echo semantic presentation source checkpoint
|
||||
|
||||
The optional extension 1.4 acknowledgmentLabel and manualEntryEnabled hints are published on feature/echo-scanner-presentation (draft PR9). Existing defaults remain Saved scan/manual enabled; Echo uses Print queued/manual disabled. Strict compilation, 167 JVM cases, lint zero errors/warnings and debug assembly pass. Hardware, lifecycle and receipt predicates are unchanged. The server companion passes 69 consumer cases plus two contract cases; the optional Echo module passes 15 strict QQQ/H2 tests.
|
||||
|
||||
The normal TC22 remains the separately installed 625a275 build (APK AF13778A…3E9216), preserving sign-in/data. Its About screen was opened and Query artwork, both public links and licenses verified through filtered accessibility data. New Echo-native UI/actual owner adapters/physical printing remain unqualified; printing and document/scripting owners have acknowledged their implementation lanes. The running normal server remains API1.9/mobile1.1. Production signing/distribution stays the owner's final step.
|
||||
|
||||
## November 1 standards fixes and About update
|
||||
|
||||
Applied the scoped standards review: copyright/SPDX, explicit imports, declaration headers, native official/three-space formatter profile, mechanical CI source gate and Kotlin warnings-as-errors. Strict compilation and163 JVM tests pass; all46 disposable sample native tests pass on TC22, and production QA instrumentation compiles. The native suite uses fixture providers; actual Android external-browser/provider acceptance remains separate. The existing formatter reports one unstable Gradle script and full formatter CI runner provisioning remains explicit in the review.
|
||||
|
||||
Settings → About QQQ Mobile now shows bundled Query artwork, app version, Dogfooding/Query public links and open-source licenses. The normal APK rebuild uses drawable-nodpi for the artwork. No credential/device parameters are added to website links. The preceding diagnostics/default-on GPS update remains installed until the preserving update recorded below; actual GPS fix is still open.
|
||||
|
||||
The real API1.10 Authentik desktop sequence now passes negative nonce rejection, A login, explicit company logout, old-cookie rejection, fresh B credentials/principal in the same retained browser context, and B local logout. Its provider-specific User Logout flow is required for company sign-out; default ordinary client logout stays local. Server0b53f34 is published and all8hosted checks pass. CorePR1369's exact architecture allow-list correction is being verified; full fresh core CI is not yet green.
|
||||
|
||||
## November 1 diagnostics and location update
|
||||
|
||||
Diagnostics now uses a full-screen, scrollable native view with a connection summary, local-time newest-first activity, All/Problems filters, pause/resume, copy/share/clear and Back. Existing footer and menu entry points still block process input while diagnostics is open. Location reporting defaults on for approved signed-in foreground sessions; Android grants remain required, explicit opt-out persists and automatic permission requests are not repeated after denial. Controls remain under Settings → Device permissions.
|
||||
|
||||
The combined client passes 159 JVM tests, zero-error/zero-warning debug lint, normal/E2E APK builds and the focused diagnostics interaction test on TC22 using the separate E2E package. The new UI/default policy still needs normal-package field observation. This does not establish a physical GPS fix, background tracking, real provider sign-out or production release readiness.
|
||||
|
||||
The preceding signed checkpoint1c766bf was installed in place as APK E34AC3C6…C1874EB with private backup, matching signer and no data clear. The operator and backend confirm one Code128 and one QR event on these bytes, including exact leading-zero value001234567890 from EMDK/QRCODE. Apps → menu → Scan Capture round trip was also confirmed. The normal server remains API1.9/mobile1.1; API1.10 core159 tests, current contract3 tests and coherent scanner/demo/files/browser78 cases pass separately while real Authentik acceptance continues.
|
||||
|
||||
## November 1 native authentication checkpoint
|
||||
|
||||
The optional company sign-out and original-nonce client changes pass 151 JVM tests, zero-error/zero-warning lint, debug assembly and native-test compilation. All 28 selected OAuth2 login/logout and authentication-lifecycle native tests also pass on the TC22 in the disposable sample application, with no failures or skips. The normal scanner package/data were preserved and its Activity is foreground again. These native tests use synthetic provider adapters; they do not establish real Authentik or external-browser acceptance.
|
||||
|
||||
Independent review covered all 26 implementation/test/documentation files in the frozen patch and found no security candidates (completed scan c358cde3-b51d-4451-b07d-8d1afe6c4485). This is a patch review, not a fresh audit of every unchanged file. Native OAuth now requires API1.10, the advertised nonce-validation feature and REQUIRED policy; TABLE authentication remains compatible. Company sign-out is separately advertised and explicitly selected; the automatic sign-out policy remains undecided.
|
||||
|
||||
The paired server changes and real A-to-company-logout-to-B acceptance remain in progress. Earlier real-provider testing established operator A and local QQQ invalidation, but retained browser SSO silently reused A; that failed handoff remains failed. This checkpoint is source publication, not a production release or installation of these bytes on the normal TC22 package. Signing/distribution remain the owner's final step. All historical entries below retain their original scope.
|
||||
|
||||
## November 1 status and visible QR fixture
|
||||
|
||||
All eleven hosted checks at portrait-default7de2b508 passed. Public QR fixture checkpoint2e0fd962 is signed and pushed; docs/TEST-BARCODES.md records independent ZXing decoding of001234567890. Physical QR acceptance is still pending. Normal TC22 APK C89161C…C6340 remains installed with portrait requested, and the backend now has nine trusted EMDK Code128 events (eight on prior F3AF and one after the portrait update).
|
||||
|
||||
Release1.0 remains incomplete: actual Authentik acceptance remains pending; the local TLS proxy correction is verified, and provider configuration returned a validation error under diagnosis; physical QR, camera/gallery, navigation/failure, projection and GPS acceptance remain open. Filesystem portability passes142 discovered/138 passed/four existing skips, and the separate RF HTTP consumer now passes18 cases on a transitional custom-filesystem tuple. This supersedes its earlier fixture-compilation failure; it does not establish immutable/default dependency adoption or change the normal API1.9/mobile1.1 runtime on57832. Signing/distribution remain last by owner instruction. Current summaries supersede dated open-state statements below without changing their historical evidence or sealed review scopes.
|
||||
|
||||
## November 1 portrait default installed; prior source fully green
|
||||
|
||||
The user requested portrait lock by default. Missing/invalid preferences now choose Portrait; all explicit saved choices remain available. Four focused policy tests, debug lint (zero errors/warnings) and debug assembly pass. Development APK C89161C…C6340 was installed in place after signer verification and a new private-data backup; no app data was cleared. Android reports the normal Activity resumed with SCREEN_ORIENTATION_PORTRAIT, and a direct screenshot confirms the signed-in Apps menu. Barcode/lifecycle acceptance of these new APK bytes remains pending; earlier F3AF physical evidence is preserved.
|
||||
|
||||
All eleven hosted checks at229fce3 passed, including both GitHub native runs (jobs124829276568/124828879619):35 shared cases, PixelCopy and all seven semantic scenarios. Logs show session-login-action-ready, session-fresh-login and the semantic request audit in both runs. Server documentation checkpoint2de8470 also passes all eight checks. Fresh checks will qualify the portrait-default source update. The operator confirmed the eight earlier Code128 events were deliberate trigger pulls. The QR was not visible in chat; a direct image link was supplied after unreliable browser automation, and current QR acceptance remains pending.
|
||||
|
||||
## November 1 physical EMDK positive control verified
|
||||
|
||||
The operator reported Saved scan on the normal F3AF development app. Direct ownership-guarded backend inspection verifies eight new Code128 records for QQQ-TEST-123456, source zebra-emdk, TC22 attribution and eight distinct event IDs. This closes the current trusted hardware positive-control gap for Code128 only. Operator confirmation of repeated trigger intent and the current leading-zero QR remain pending; [physical acceptance](../PHYSICAL-TC22-ACCEPTANCE.md) records exact APK/runtime and remaining lifecycle/retry/ownership gates. No normal app/data clear or QA run occurred.
|
||||
|
||||
Strict Firefox profile-only CA trust now passes issuer/hostname checks using the supported PLAYWRIGHT_FIREFOX_POLICIES_JSON entry point. Windows trust is unchanged; the earlier Windows-root approval question is obsolete. Actual provider startup/exchange remains unexecuted while an independently reviewed known-listener guard is prepared for genuine TIME_WAIT rows. Independent architecture closure of Android229/server2de reports no additional concrete source blocker and maps A1-A11 controls/tests; runtime/deployment/dependency/signing limits remain. Record Files compatibility now has a concrete exact-revision filesystem dependency gate in a new private cache; no frozen0515/default/runtime adoption occurred.
|
||||
|
||||
## November 1 native CI — rotation passes, fresh-login test readiness under correction
|
||||
|
||||
Android5ba PR run41297344726 passed:35 shared native cases, PixelCopy and all seven semantic scenarios, including session-monitor. The duplicate push run41297340376 also passed rotation/recreation/mirroring/administrator termination, then timed out waiting for Username after clicking Log back in. Its request audit shows completed Android logout200 and no second Android manageSession. Static review confirms that the test awaited only the label, which also renders while the logout action is disabled. This supports a test-readiness race; the failure artifact does not record the button state at the click, so no production cause is asserted. The correction waits for a final signed-out message and enabled action, asserts enabled and clicks once; it retains all fresh-login/session assertions. The opt-in device-screen test uses the same readiness requirement. Fresh native execution remains required.
|
||||
|
||||
Independent release automation review of three exact5ba files found no actionable source finding; [scope and hashes](../RELEASE-AUTOMATION-REVIEW.md) retain external-configuration limits. A separate API1.9/mobile1.2 backend-only smoke passed readiness and shutdown with private credentials removed. Its launcher initially failed closed on the Node shim PID; actual Node resolution corrected the helper, and the unsuccessful fixture also cleaned up. No TC22 QA test, ordinary-app cleanup or normal-server replacement occurred.
|
||||
|
||||
## November 1 TC22 walkthrough — reported disappearance investigated
|
||||
|
||||
After the operator reported that touching the handheld crashed or moved the app, direct ADB inspection found the normal scanner process alive and its Activity resumed/focused, with no crash-buffer entries. A direct screen capture showed the connected sign-in screen; the black desktop mirror was not reliable evidence of an app crash. Restored the existing scanner demo login using locally encrypted credentials, and a subsequent direct capture confirmed the Apps menu. Opened Scan Capture to resume the manual walkthrough. No production defect has been reproduced or fixed by this inspection; physical trigger/navigation acceptance remains pending. No QA instrumentation or app cleanup was launched over the operator's normal app.
|
||||
|
||||
## November 1 morning — normal app updated; native test correction pending hosted verification
|
||||
|
||||
The normal TC22 application is now updated in place to the reviewed development APK `F3AF638E…E9FAA`. Matching signing identity and a new owner-only private-data backup were verified; app data/enrollment were not cleared. Non-streaming installation completed and the normal process is running against the preserved API1.9/mobile1.1 demo. This supersedes older unchanged-normal-APK statements below. Operator login/navigation/scanning confirmation is pending; no production release is claimed.
|
||||
|
||||
Published `e04fdb2` passed CircleCI and CodeQL, but both GitHub native runs failed the same session test: it requested rotation then another Activity recreation without waiting for configuration convergence, and the barcode draft was empty. The test now waits for landscape/resumed configuration and asserts the draft after rotation and again after explicit recreation. This is a test synchronization correction awaiting native execution, not a demonstrated production draft fix. Physical barcode attempts separately hit a wrong expected value (operator confirmed the old test code), a transient focus assertion and an ADB timeout; none is a full hardware pass. Focus preconditions now wait briefly within the120second stage deadline, and install timeouts identify the operation.
|
||||
|
||||
The opt-in QA screen-consent harness, shared HTTP helpers and both QA/E2E test packages compile. Its early safety checks reject missing operator opt-in and both protected demo ports without device access. Actual consent/Home/Stop/expiry/end-session acceptance remains pending. The harness author stopped at the workspace agent-credit limit; root completed the compile corrections and continues directly. A real Authentik fixture is compiled with pinned images downloaded, but no services or OS trust changes have occurred; temporary Windows CA approval is still pending.
|
||||
|
||||
## November 1 — refined device-screen client and published paired server
|
||||
|
||||
The refined client passes137 JVM tests, all package/lint gates, fresh four-APK inspection and the TC22 ordinary-UID scan-ingress regression. Continuous image draining retains one latest buffer; repeated Share is blocked through service binding. Fresh independent follow-up review found no concrete issue. The source-pinned1.2 server `1041693` is published and passes39 server/six demo/all15 browser tests with strict195-JAR/2,441-class provenance in a cloned private Maven cache. Hosted client CI now targets that exact source. No actual projection consent/physical trigger is proved; the normal APK remains unchanged and its running demo remains qualified1.1. See [candidate scope and hashes](../DEVICE-SCREEN-ACCEPTANCE.md).
|
||||
|
||||
## November 1 — initial consent-based device-screen checkpoint
|
||||
|
||||
The optional device-screen client and paired1.2 server pass initial compilation and automated gates:135 Android JVM tests, lint/all package builds,39 server and six real-HTTP demo tests. Parent and fresh independent static review found no reportable client issue across the nine changed-file inventory; actual consent and projection behavior remain unverified. See [scope, hashes and remaining acceptance](../DEVICE-SCREEN-ACCEPTANCE.md). The normal operator APK and qualified1.1 demo remain separate. Frame freshness/binding refinements and final native acceptance are still required.
|
||||
|
||||
## November 1 — hosted Back focus failure and physical-trigger checkpoint
|
||||
|
||||
Published Android head `6845abc5289fa975e6f50b23b159c3131a82eee1` has a successful CodeQL check; not all CI is green. CircleCI android-test job 140 fails three NavigationTests before injecting Back: their Activity is resumed and the device is interactive/unlocked, but Activity/decor window focus stays false for five seconds. Both job attempts reproduce the failure. Retained JUnit contains no focused-window owner, so the cause is not yet established. Failure-only diagnostics now classify the focused window as own/system/other, report bounded own-window flags and the known network-dialog visibility; they retain the five-second focus requirement and actual Back injection. CircleCI unit job 141 passed. One GitHub native run failed and the other was still running at inspection; neither is claimed successful.
|
||||
|
||||
The physical QA attempt using APK hash prefix `CFFF` reached its first hardware-ready stage but recorded no acknowledged operator scan. A final near-deadline HTTP poll timed out; this is not physical scanner acceptance. Evidence is preserved under `C:/Users/james/.cache/qqq-tc22-physical-attempt-20261101-0210`; `physicalTriggerVerified` remains false. A separate, unqualified test follow-up is prepared to avoid sub-second status polls and label deadline failures without extending the 120-second stage bound. Its compilation is deferred until the concurrent scanner changes are ready; it is not part of this diagnostic checkpoint.
|
||||
|
||||
The earlier normal-server ownership hold is resolved. After correcting the CIM positive control, the owner completed the reviewed upgrade to the frozen `ecf`/`b14` API 1.9 / mobile-scanners 1.1 TABLE runtime. A fresh backup, original-column invariants, old-after-new recovery logins before and after stopping the predecessor, and live login/metadata/logout for both demo accounts passed. Two devices and three scans, including the leading-zero value, were preserved. Private evidence is retained in `C:/Users/james/.cache/qqq-normal-api19-002594902b1942bda2480c512ed644ab/normal-verification.json`. The normal operator APK remains unchanged. Further optional screen-capture source is under separate implementation; these diagnostic edits do not qualify it.
|
||||
|
||||
## October 31 — published client checkpoint and probe target clarity
|
||||
|
||||
Signed client checkpoint `d316567` is published to existing draftPR8 and the clean canonical feature checkout is fast-forwarded to it. Fresh hosted checks found two CodeQL intent-redirection annotations in the separate permissionless test probe. Inline static triage at that exact revision classified both as not actionable: the exact QA/E2E package allowlist dominates both fixed-action broadcast sends, no arbitrary activity/service is launched, and the probe adds no sender authority. No alert was dismissed or completed audit rewritten. The probe now maps accepted requests to explicit final package literals rather than forwarding even the validated caller string. Probe-only build/lint and repeated ordinary-UID eight-forgery/manual-positive acceptance pass; production APK hashes are unchanged. Publish this source clarity follow-up and inspect fresh CodeQL/client checks before release. Physical trigger and whole-handheld viewing scope questions remain pending.
|
||||
|
||||
## 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.
|
||||
|
||||
## October 31 navigation/authentication review checkpoint
|
||||
|
||||
The combined client checkpoint passed 102 JVM tests (26 core, 43 mobileapp, 33 scanner), scanner lint, debug/E2E APK assembly and native-test compilation. The final follow-up build passed in 1m3s (266 tasks, 34 executed). The TC22 rerun passed all 20 selected navigation/input/account/OAuth tests in 49.235 seconds, including all seven synthetic AppAuth cases. The first run exposed Back during drawer opening; the handler now also handles a drawer targeting Open, and the native helper waits for settled UI before hardware Back. [Native acceptance evidence](../NATIVE-AUTH-ACCEPTANCE.md) distinguishes real handheld execution from the simulated browser/provider. No actual Authentik sign-in is claimed. New hosted CI is required after publication.
|
||||
|
||||
Independent source review found no concrete defect in the native authentication/navigation patch; this is not final security or deployment clearance. [AppAuth package evidence](../third-party/APPAUTH-VERIFICATION.md) confirms 131 bundled entries, 126 external runtime dependencies, nine complete texts and unchanged eight native libraries in the inspected debug/E2E APKs. OAuth2 depends on the companion core UI API1.9 native-session contract, still pending publication/host adoption. TABLE_BASED remains usable on compatible API1.8 servers. Production signing/distribution and actual shared-operator/provider acceptance remain separate gates.
|
||||
|
||||
## October 31 semantic authentication — synthetic native acceptance passed
|
||||
|
||||
The combined source passes 102 JVM tests (core26/mobile43/scanner33), scanner lint and debug/E2E/test APK assembly. All seven synthetic native OAuth cases and the navigation/input/account suite passed on TC22 as described above. Generated notices contain 131 entries including AppAuth. The initial sandbox run stopped at the existing debug-key lock; scoped rerun preserved the key and completed successfully. Testing used the separate sample package; no emulator or normal operator installation was started for this checkpoint.
|
||||
|
||||
The native descriptor now requires an advertised `prompt: "login"` policy, applied through AppAuth and bound to each pending attempt. Missing/unsupported prompt policies fail closed. Focused tests cover policy changes and mismatched returned requests; the real shared-handheld acceptance gate includes switching operators with an existing browser SSO session. This requests fresh authentication; it is not global IdP logout or verified `auth_time` proof.
|
||||
|
||||
The owner requires Authentik when configured by the server. Generic OAuth2 browser sign-in is implemented against an additive UI API1.9 `nativeSessionLogin` capability, with explicit issuer/discovery/callback metadata and an `authenticationFlow` marker on the existing session exchange. AppAuth performs authorization with S256; the secret and code exchange stay on QQQ. TABLE_BASED remains unchanged and native bearer metadata is kept distinct. Client gates, callback/lifecycle checks, deferred cookie adoption and focused tests are implemented; actual configured Authentik acceptance remains pending. See [AUTHENTICATION.md](../AUTHENTICATION.md). No live provider settings or operator data were changed.
|
||||
|
||||
## October 31 navigation correction — native acceptance passed
|
||||
|
||||
The primary navigation uses one hamburger drawer with All apps and server-authorized app entries, plus Settings and Diagnostics fixed at its bottom. The duplicate toolbar Apps button and Switch app overflow action are removed. Back remains beside the hamburger within apps/processes, Account stays separate, and prelogin/pending-approval drawers hide business destinations. Drawer dismissal and secondary surfaces preserve active work; selecting a primary destination retains the existing leave and pending-upload guards. Compilation and the selected native navigation suite passed, including the drawer-opening Back correction above. The successful checkpoint below describes earlier session/photo acceptance. No installed operator app, server or data was changed for this correction.
|
||||
|
||||
## Current verified checkpoint — October 31
|
||||
|
||||
All 12 shared TC22 navigation/input/account tests pass. TC22 orientation (all five persisted policies), offline licenses, synthetic camera/gallery/rotation/remote-logout cleanup, and the full session-monitor scenario pass. Session monitoring includes real JPEG app pixels, background clearing, renewed viewing on resume, operator pause, administrator termination and fresh login. Photo/session request audits match published semantic operations. JVM reports total 91 tests (22 core, 36 mobileapp, 33 scanner), zero failures; scanner lint passes. Notice coverage gates and packaged APK inspection pass for debug/release/E2E (125 runtime dependencies, 130 entries and nine complete license texts), and negative omission/blank-text cases fail as required with generated metadata restored.
|
||||
|
||||
The false-offline investigation was a test-clock issue, not a demonstrated timezone defect. The independent standalone file-backed JVM test in America/Chicago passed across 18 seconds. No timezone workaround or relaxed heartbeat/offline policy was added. Remaining work: publish the Android review checkpoint and verify fresh hosted CI, finish independent full audits/native attribution, qualify immutable optional dependencies/CI and real lens/operator acceptance, then return to signing/distribution as the owner requested. The normal installed app and operator demo remain on their earlier versions; isolated test acceptance is not deployment. Historical entries below preserve earlier failures and are superseded by this checkpoint.
|
||||
|
||||
Viewing diagnosis resolved the apparent offline state: the test advanced only16ms of Compose coroutine time per250ms wall polling, so heartbeat LaunchedEffect timers did not run during125 widget polls. The standalone non-UTC file-JVM regression passed and audit ordering confirmed zero heartbeats during those polls. The helper now advances by elapsed monotonic wall time. The next TC22 run reached `session-mirror-frame`: actual app JPEG pixels were delivered/decoded and background clearing passed. It then failed the already-identified old resume assertion because no viewer demand was renewed. Resume now polls the admin widget again; rebuild/full scenario rerun is active in the current turn. No production heartbeat interval or offline threshold was relaxed. The standalone regression source is in the companion demo, added by the read-only diagnosis agent and verified separately.
|
||||
|
||||
Latest viewing diagnostics: eligibility is available=true, but no indicator/frame upload occurs; 125 widget requests and 18 native heartbeats succeed. A real HTTP regression now explicitly requests the viewer widget as a browser (without the Android client header) before the native heartbeat; both demo tests PASS, ruling out the simple cross-channel lease-isolation hypothesis in that fixture. Actual core widget response fields are `layout`, `blocks`, `type`, `blockTypeName`; corrected the diagnostic extractor to read `blocks` (the earlier blank status was an incorrect `subBlocks` assumption). That diagnostic change needs rebuild/rerun to obtain the actual server widget status. All runs in this checkpoint are terminal. No production state-sharing change was made on an unproven hypothesis.
|
||||
|
||||
The window-focus synchronization rerun advanced past capture eligibility: no APP_NOT_FOCUSED failure remains in that run. It now times out at the viewer indicator. Request audit shows 125 successful web widget renders and 18 successful Android heartbeats, with no screen-upload operation. Added bounded failure diagnostics for current server availability/reason and the widget's status text (never image data or credentials), to distinguish lease issuance from client rendering. This newest diagnostic needs rebuild/rerun. All native processes from that run are terminal. Android changes are staged as a review checkpoint, but later focus/diagnostic edits remain unstaged; re-stage and verify before committing.
|
||||
|
||||
Screen-viewing follow-up reproduced a stale tracked-focus flag: server reported APP_NOT_FOCUSED while instrumentation confirmed interactive=true, no keyguard/device lock, both Activity/decor focus=true and lifecycle RESUMED. ScannerActivity now observes actual view-tree window-focus events, resynchronizes on resume, and removes the observer on destruction. Existing captureAppScreen independently checks actual decor focus, so capture protection remains enforced. Scanner unit/lint/E2E APK verification passed (build1m19s); the focused real-server TC22 scenario is rerunning in the active root turn. The operator demo remains on extension1.0; no normal-app update was made.
|
||||
|
||||
TC22 photo acceptance PASSES against the verified guarded0352 fixture: synthetic external-camera result automatically uploads, exact hash/size/provenance/parent and collection association are inspected, gallery staging survives rotation/recreation, leaving is blocked while pending, and remote session termination clears the private copied original without deleting the selected gallery source. All recorded native requests match published semantic operations. The run exposed and fixed a real UI-thread defect: ScannerActivity now dispatches unauthorized-session navigation on the main thread and ignores callbacks for a superseded session. Scanner unit/lint/APK rebuild passed before the successful native rerun. Fixture owner metadata remains in the private task cache; do not infer a normal-app deployment or actual lens capture from this synthetic result. Real session-viewing acceptance is starting next in the active root turn.
|
||||
|
||||
TC22 navigation/input/account acceptance now PASSES all 12 tests on rebuilt sample APKs. Orientation (all five policies) and offline licenses also pass. The guarded Record Files private launcher needed execution outside the sandbox for its process to survive; current private fixture PID111928 was started with a 900-second lifetime, with protected metadata path in the task cache. The first native photo run reached Saved and verified exact bytes/provenance/parent, then failed because the test assumed stored collection order was zero-based. Actual owner storage is one-based while manifest/receipt order is zero-based. Corrected that assertion and the duplicate Apps selector; photo rebuild exec64104 is active in the current turn, then rerun the synthetic camera/gallery/rotation/remote-logout scenario. Preserve fixture ownership and stop only that verified fixture when finished. Session mirroring acceptance and final audits remain open.
|
||||
|
||||
Latest navigation rerun is terminal: 11 of 12 TC22 tests pass. Restored account/server assertions now pass. The remaining failure selected both the menu action and the closed drawer heading named Switch app; a stable menu-action identifier and matching test selector are added. Those last two changes need rebuild/rerun. No native test or fixture remains active from this run.
|
||||
|
||||
TC22 follow-up: orientation and offline licenses each PASS their instrumentation scenario. Shared navigation ran 12 tests: 10 passed, with failures from duplicate visible/closed-drawer Apps selectors and an old account-dialog expectation that server selection still lived there. Added a stable toolbar Apps identifier, preserved the account user assertion, and verified restored server selection under Settings instead. Rebuilt sample/test APKs successfully; navigation rerun is starting in the current root turn. Guarded server changes and passing 18-test evidence are committed/pushed as84ffd7c on the existing draft PR; no merge or production release.
|
||||
|
||||
Latest TC22 result: the rebuilt orientation acceptance PASSED (one instrumentation test covering all five policies, persisted selection, actual fixed screen axis, Activity recreation and a fresh Activity launch). The bounded configuration-convergence wait resolved the prior timing failure; the axis assertion remains enforced. Guarded Record Files integration now passes all 18 tests and runtime/Checkstyle verification, with evidence retained in the companion module. Root is running TC22 licenses followed by shared navigation acceptance as exec session85018; poll that handle before starting another device run. No operator-app update or production release is implied by isolated test acceptance.
|
||||
|
||||
Latest verification follow-up: the notice-integrated APK build log ends BUILD SUCCESSFUL. Explicit TC22 orientation acceptance ran and failed because the observed screen axis was still portrait when landscape was requested. A bounded 15-second configuration convergence wait was added to the native test while retaining its final axis assertion; rebuild/rerun remains required. The disposable test runner cleaned its resources. Emulator remains closed. Record Files guarded-artifact verification has four unresolved intake-event concurrency test failures; see the companion integration session state. No production release is qualified.
|
||||
|
||||
2026-10-31 Android acceptance follow-up: the bounded pre-notices build passed 89 JVM tests (core22/mobile36/scanner31), normal and isolated APK assembly, and scanner lint with zero errors/warnings; broader modules retain inherited vector/dependency warnings. The emulator remains off after confirmed workstation stutter. On TC22, session capture remained safely blocked with APP_NOT_FOCUSED after recreation; no mirror frame was uploaded. The 12 shared navigation/input/account tests then ran: six passed and six failed (stale account/server-confirmation assertions, duplicate catalog/drawer text selectors, and a hardware Back case). Assertion corrections and window-focus diagnostics are prepared; these failures are not counted as passes. Orientation Settings, retained manual draft state, synthetic photo E2E, explicit physical photo fixture support, and bundled-license Settings entry are now implemented but require the next compilation/native acceptance. The license owner is running the bounded integration qualification. Ordinary scanner app/data, DataWedge settings and existing USB reverses were preserved.
|
||||
|
||||
## 2026-10-31 — Navigation hierarchy and release preparation
|
||||
|
||||
Owner explicitly deferred signing/distribution to the final step until the channel is chosen. No signing key or production signing configuration has been created. Continue implementation, audits, native/physical acceptance, documentation and ordinary CI; do not create release tags or force a distribution decision early.
|
||||
|
||||
Workstation interruption: user reports whole-desktop pauses whenever the emulator acts and explicitly confirmed the workstation is smooth after closing it. All local emulator/build runs were paused and emulator-5554 shut down gracefully without clearing application data. TC22 remains connected. Sampled host had 48 logical CPUs, CPU17%, about152GiB free RAM of256GiB, GPU10% with6.1GiB/30GiB used; these samples do not rule out brief spikes. WHPX is operational outside the sandbox. The running host-GPU emulator log includes OpenGL context preemption/restore failure and external-memory import errors; graphics interoperability is a hypothesis, not a proven root cause. Saved AVD config now uses software graphics, previous config backed up locally, but no comparison run has been made. Do not restart the emulator automatically. Continue acceptance on TC22/hosted CI or schedule a controlled software-renderer comparison later.
|
||||
|
||||
Android checkpoint: primary Apps, an app-switching drawer, separate Account and Settings, host device/location settings and shared leave confirmation are implemented. A bounded build with the emulator closed passed 85 JVM tests (core22/mobile35/scanner28), scanner lint with zero errors/warnings, isolated scanner APK/test APK and shared native-test compilation. The focused native long-form test passed after waiting for actual IME dismissal; the earlier immediate assertion raced its animation. Expanded native navigation cases are compiled but not run. Photo capture/picker, registered Record Files transport, stable retry identity, exact receipts and lifecycle tests are implemented. Subsequent camera auto-upload/destruction cleanup, the public mobile-record-files vocabulary gate and associated tests await the next bounded build after concurrent asset/backend work finishes. No emulator restart is authorized; physical acceptance uses the separate explicit runner in [DEVICE-TESTING.md](../DEVICE-TESTING.md).
|
||||
|
||||
The session native test now passes login/approval/catalog/process and recreation, including parent-app metadata and denied-location reporting, against the corrected file-H2 server. It still timed out before the administrator-viewing indicator. The test now waits for explicit screen eligibility and renews the widget viewer lease like the real admin poller; that change is unverified. Actual PixelCopy pixel-boundary instrumentation passed earlier, but full session/mirror/admin-end native acceptance is not complete. Ordinary synchronous process responses have no generic status endpoint: the host retains the process reference, prevents replay and describes uncertainty before explicit cancellation; it does not promise unsupported result checking.
|
||||
|
||||
Current plan: finish native navigation/session acceptance; integrate camera/gallery with the actual Record Files runtime; freeze paired revisions for final independent reviews; qualify signed delivery and hosted CI before publication. Apps → app → process is primary navigation. Account/sign-out, Settings and diagnostics are secondary surfaces that preserve active work. Toolbar and Android Back share one guarded leave policy. See [NAVIGATION-UX.md](../NAVIGATION-UX.md); implementation and native acceptance are underway, not yet complete.
|
||||
|
||||
Latest intermediate results: Android 76 JVM tests and lint with zero errors/warnings; shared native 11/12, with the remaining form/IME assertion under investigation. Companion server 37 tests, two real HTTP acceptance tests and all 15 browser E2E tests pass. The H2 last-connection close/compaction issue was reproduced and fixed with the demo database lifetime setting; the browser suite now completes in 59.4 seconds. These counts describe their tested intermediate source, not a final release.
|
||||
|
||||
Record Files now has actual registered ingestion routes and a frozen 19-file consumer bundle (`retained-contract-20261031-0238`), all hashes verified by the optional integration owner. Camera/gallery implementation will use its existing storage and durable receipts. Atomic authorization across revocation and upload commit remains a shared contract issue; runtime compatibility and native acceptance are still being qualified.
|
||||
|
||||
Release preparation adds optional environment-only signing, validated version properties and a separate scanner-tag workflow that calls the native verification lane before building/signing/publishing. Actionlint passes. The signing identity/secrets, actual signed build/upgrade and final publication have not been configured or verified. See [RELEASING.md](../RELEASING.md). Debug/E2E defaults and the operator's installed TC22 application are preserved.
|
||||
|
||||
## 2026-10-30 — Sessions, screen viewing and audit corrections (uncommitted)
|
||||
|
||||
The host now requires mobile-scanners `>=1.1.0 <2.0.0` and uses registered heartbeat/end/screen operations. Session telemetry contains screen metadata only. Administrator viewing leases enable bounded own-window PixelCopy JPEGs with a visible operator stop control. Capture blocks login/password screens, unrendered transitions, background/unfocused windows and ended sessions; checks repeat after capture and before upload. Foreground location is optional and permission denial leaves scanning usable. Device approval displays the server-generated short name and supports administrator renaming through QQQ.
|
||||
|
||||
Audit fixes require complete scanner receipts and reject invalid explicit maxLength. Navigation now follows NavController's actual destination, including system Back and controller replacement. Shared renderer/authentication fixes are being completed by the parallel review agent. Current intermediate verification: 75 JVM tests passed, APK builds passed, lint zero errors/eight warnings before dependency catalog cleanup. Actual PixelCopy instrumentation passed after an emulator startup ANR was resolved by a cold restart preserving data. Shared native suite passed 11 of 12 tests; a new short-viewport login regression found the IME obscuring the button and its fix is awaiting rerun. Both new navigation tests passed. Real-server session/end/viewing scenarios are implemented but await the rebuilt companion server and final native run. These are work-in-progress results, not publication or production acceptance.
|
||||
|
||||
At this October 30 checkpoint, Record Files camera/gallery routes were unavailable. The October 31 checkpoint above supersedes that dependency status. Version one remains online only.
|
||||
|
||||
## 2026-10-30 — Semantic API migration in progress
|
||||
|
||||
The current work makes the scanner host use the registered semantic UI API contract for every client operation. Public `/qqq/v1/version` discovery gates credentials, branding and metadata; the supported range is core `>=1.8.0 <2.0.0` and mobile-scanners `>=1.0.0 <2.0.0`, with `android` advertised in `clientChannels`. The four mobile extension operations now resolve under `/qqq/v1/qbit/mobile-scanners`. The native renderer applies server-directed completion, field/step updates, form submission and cancellation, and explicitly blocks unsupported required controls. Review fixes honor hidden fields and explicit null clearing, and keep generic fields named `eventId` separate from scanner retry context. See [UI-API.md](../UI-API.md) for the exact rendering boundary; this is not a claim of full web-client parity.
|
||||
|
||||
The final combined JVM suites passed 53 tests: core 21, mobileapp 23 and scanner-host 9. Lint passed with zero errors and six warnings; normal debug and isolated E2E/test APKs assembled. A native administration regression exposed that core QQQ completes a process by omitting nextStep from its COMPLETE response. The renderer now honors that server signal, preserves the final display step until submitted, and closes completed processes without issuing cancellation; a ViewModel regression verifies the full sequence. The companion qbit passed root verification/install with 23 tests and demo HTTP verification; all six migrated browser E2E scenarios passed in 2.9 minutes. The first native run exposed HTTP400 because the server had not registered the Android client channel. The server now registers it while preserving existing channel policies, and the real HTTP regression sends the Android header on all requests and passes. All six native semantic scenarios passed across focused runs: incompatible core major, missing extension, missing Android channel, supported-server scan, additive-future metadata scan, and native administrative approve/revoke/cancel. The first five passed before the terminal-state correction; the final administration scenario passed afterward in 255.917 seconds. Its OpenAPI audit matched all 31 requests to 13 published operations, including backend cancellation. Safe stage markers show both processes reaching completion and Close, then a populated form being cancelled without changing persisted status. The emulator was CPU-bound with animations enabled; the runner now wakes/unlocks only the selected emulator, temporarily disables its animation scales and restores them during cleanup, allows 60 seconds for UI conditions and 300 seconds for instrumentation. The six shared native sample tests also passed. Hosted Android verification remains pending; earlier records below apply to their earlier revisions.
|
||||
|
||||
Active plan: publish the Android branch and verify hosted results. The companion server is published at 7dd02b8874882ca2edcdcd89109b3dee6b4b0c88 with all server CI checks green, and the Android workflow pins that revision. The six shared native sample instrumentation tests passed on API35. The local server and emulator restarted after the workstation crash with data preserved.
|
||||
|
||||
Physical acceptance now includes the Zebra TC22 on Android 14 with DataWedge 13.0.325. The latest normal APK was installed with login/enrollment preserved; browser record approval displayed one device approved. The native catalog and Scan Capture opened with Connected status. The DataWedge profile associates all scanner-app activities, enables barcode input, disables keystroke output, and sends the SCAN action/default category as a broadcast to the explicit package with signature checking enabled. The user performed two deliberate physical triggers and saw Saved scan. The matching persisted Code128 row was independently inspected in the backend: synthetic barcode QQQ-TEST-123456, symbology LABEL-TYPE-CODE128, source scanner, user scanner, TC22/Android14/app0.1.0, mobileScanApp/mobileScanCapture, and capture/receipt timestamps. The two deliberate Code128 triggers produced two records, one event per trigger. A subsequent physical QR scan also displayed Saved scan; the independently inspected row preserved the leading-zero barcode 001234567890, symbology LABEL-TYPE-QRCODE, scanner source and the same TC22/user/app/process attribution with capture/receipt timestamps. The table contained exactly three records for two Code128 triggers and one QR trigger. Connection-loss/retry and revoked-device behavior still need physical-device acceptance; emulator checks do not replace those cases.
|
||||
|
||||
## Earlier verification records
|
||||
|
||||
2026-10-30 process E2E follow-up: added a real scanner-host Compose instrumentation scenario against a fresh QQQ demo database. It drives server selection and login, confirms pending enrollment, approves through the real admin process, selects the native app/process, submits a leading-zero manual barcode, waits for the actual saved-scan acknowledgment, independently queries durable provenance, and verifies a revoked device cannot save through its already-open process. The final scenario passed on API35 (one end-to-end test, 50.932 seconds), including completion of administrative result screens. It uses the separate `io.qrun.mobile.scanner.e2e` application; the operator's app, accounts, data and existing forwarding are preserved. Random test credentials are passed through stdin to private app storage and redacted from reports. The runner removes both test APKs and cleans up its temporary server/database and dedicated forwarding; only the normal scanner package remained installed after verification. Added a pinned companion-server checkout and this E2E to the push/PR emulator lane; hosted execution remains unverified until publication. Final required Gradle verification passed: 28 JVM tests, lint zero errors/six existing warnings, normal debug APK and isolated E2E/test APKs assembled. Actionlint, Node syntax validation and diff whitespace checks passed.
|
||||
|
||||
2026-10-30 follow-up: the scanner could show a blocking “No Network Connection” dialog while its server footer correctly said Connected. The shared legacy callback used one Boolean for multiple networks; losing either network incorrectly marked all connectivity unavailable. It now tracks network handles, with two regression tests that fail against the old logic. Scanner hosts opt out of the legacy internet-presence modal and use the existing server reachability footer; server authentication and device approval are unchanged. All 28 JVM tests and six native API35 instrumentation tests passed, lint has zero errors/six warnings, and the replacement APK was installed without clearing app data. Scanner activity and its existing session were restored after tests.
|
||||
|
||||
The local AVD is configured for host NVIDIA graphics (`hw.gpu.enabled=yes`, `hw.gpu.mode=host`), with the previous config backed up under the user's cache. Reopening with `-gpu host -no-snapshot` and restoring adb reverse showed Home frame percentiles of 16/17/18 ms (50th/90th/99th) and scanner 19/22/48 ms. These are post-restart observations, not a controlled before/after benchmark; the original slow process had already exited. The actual previous renderer had used the GPU despite the saved AVD setting, so disabled GPU is not established as the root cause. The admin browser is open at the demo login on port57832.
|
||||
|
||||
2026-10-29 verification: all 26 JVM tests passed, lint reported zero errors/six warnings, and the debug APK assembled successfully. Runtime diagnostics now include sanitized native API and device-request outcomes, in addition to branding and heartbeat events. Android 15 system-bar insets were corrected after emulator inspection. The shared mobile library now declares its connectivity permission for all consumers. Login copy uses a neutral title beneath the customer branding. CI APK artifacts include their own `SHA256SUMS`; local builds can be hashed with `Get-FileHash`.
|
||||
|
||||
Repository implementation is published for review in [PR #8](https://github.com/QRun-IO/qqq-android/pull/8); it has not been merged or released. The branding/diagnostics commit `a5621c5` passed all hosted CircleCI, scanner build and CodeQL checks. The optional manually dispatched hosted emulator lane was skipped; local native emulator tests passed. The companion qbit is published at https://github.com/QRun-IO/qbit-mobile-scanners, with all hosted server/CodeQL/demo checks passing and protected branches/security settings enabled.
|
||||
|
||||
Final-APK acceptance passed: six native UI tests, safe Android 15 header insets, customer branding, neutral login text, diagnostic panel and Connected → Disconnected → Connected recovery. APK SHA256: `A25451EA7A1360474428F1B806134DDFD059190FB99E81B4F69E720808C3209C`. The visible API35 emulator is left at login with `http://127.0.0.1:8080/` forwarded to host demo port57832. No credentials were entered or scans submitted through the emulator; the full operator/approval/capture walkthrough and physical TC22 remain pending. The server HTTP acceptance suite separately verifies the complete authorization/capture flow.
|
||||
|
||||
Earlier implementation records follow; the publication and emulator statements above supersede their original pending status.
|
||||
|
||||
Runtime branding and diagnostics added: public server metadata supplies display name, primary color, and an optional HTTPS logo; malformed/missing metadata uses QQQ defaults and server changes reset presentation. A persistent foreground connection footer checks the public status endpoint, with bounded credential-free diagnostics Copy/Clear. Shared top-bar colors now use the matching Material foreground role and its connectivity callback is disposed correctly. Branding/status protocol, isolation, malformed input, response redaction, and log bounds have JVM regression tests. Validation passed: full 23 JVM tests (core12, mobileapp4, scanner7), debug APK assembly, and lint with zero errors / six warnings. The new APK SHA256 is F388AF488688DE87811FE32C94D07601DC3DD61ADAC139069ADCFA627E21D01C. Updated emulator UI acceptance is pending.
|
||||
|
||||
Follow-up: diagnostics now also collect fixed native QQQ/device request outcomes through an optional, exception-isolated observer (status and latency only). Added regression cases for HTTP errors, transport failure, observer exceptions, and category formatting. Emulator confirmed F388 branding/login/Connected footer/diagnostics; observed Android15 system-bar overlap corrected with safe-drawing insets. Final verification for these follow-ups is pending.
|
||||
|
||||
2026-10-29: Implementing QQQ Mobile scanner host and native host extensions. First target is Zebra TC22, online only. Backend companion is `qbit-mobile-scanners`; metadata-defined apps/processes remain the native execution model. Device approval is mandatory. Local demo uses QQQ TABLE_BASED auth on QQQ4.1.0-RC.20.
|
||||
|
||||
Implemented server selection, per-server enrollment credentials, approval gate, SCANNER widget DataWedge input, explicit same-event retry, native password login, host extension points, and removal of session/token logging from touched authentication paths. No commit or push.
|
||||
|
||||
Validation passed: 19 JVM tests (core12, mobileapp4, scanner3), debug APK assembly, APK signature verification, and lint with zero errors. Five lint warnings remain: three inherited Navigation custom-check compatibility warnings and two dependency/catalog notices. Real sanitized demo HTTP metadata/process/init fixtures deserialize through the native DTOs. Server logout, HttpOnly-cookie login, and per-origin header isolation have regression tests. Physical TC22 acceptance remains required.
|
||||
|
||||
Previous pre-branding debug APK SHA256: B3E160CE3279C369CEDB425AC6E777A2729CE8A2E430E7D6F46D35C6376F8214.
|
||||
|
||||
Repository readiness added: pinned GitHub scanner tests/lint/APK artifacts and optional emulator workflow; CODEOWNERS, contribution/security/conduct guidance, issue/PR templates, Dependabot, changelog, editor/line-ending policy, and scoped agent instructions. `actionlint`, `shellcheck`, `shfmt`, and `git diff --check` passed. Upstream main `63fca89` was fast-forwarded and all implementation files restored without conflicts; the managed CircleCI Android profile and QRun-IO Maven registry updates are preserved. The full Gradle verification command passed again after reconciliation (16 executed tasks, remaining unchanged tasks up to date). Remote CI and emulator acceptance have not yet been recorded here.
|
||||
@@ -0,0 +1,44 @@
|
||||
Historical checkpoint before the November 5 record UX update. Current state is in [session state](../SESSION-STATE.md).
|
||||
|
||||
# Checklist
|
||||
|
||||
As of 2026-11-04, scanner host and local Echo scope only. Narrative: [SESSION-STATE](../SESSION-STATE.md). Completed and superseded items are kept in [docs/history](README.md).
|
||||
|
||||
## Active feature delivery
|
||||
|
||||
- [ ] Complete final review and deployment preparation for GS1 duplication and record photos; composed-server and native checks passed.
|
||||
- [ ] Qualify actual GS1 label media, physical trigger and printed-label rescan.
|
||||
- [ ] Deploy the reviewed app/server changes only with authorization, preserving ordinary app data and enrollment.
|
||||
|
||||
## Local demo (accepted)
|
||||
|
||||
- [x] Application work merged to protected `main`; `.gitea/workflows/qrun-android-release.yaml` present.
|
||||
- [x] Local server on port 57832 (QQQ 4.1.0-RC.21, Printing 1.0, qualified owner inputs) running UI API 1.10, mobile 1.5, printing 2.3, record-files 1.0.
|
||||
- [x] TC22 on the development APK against that server; operator reported the outstanding manual acceptance group ALL PASS (2026-11-04), including label count/fidelity, camera, GPS and screen viewing; operator-reported, not instrumented or per-case evidence.
|
||||
- [x] Echo setup (printer, label size, document, copies 1–100) and semantic native flow qualified, with simulated inputs/printing in disposable runs.
|
||||
- [x] Real desktop Authentik acceptance with strict TLS.
|
||||
|
||||
## MC9400 remaining check
|
||||
|
||||
- [x] Production-signed APK installed and launched on a fresh MC9400; hash read back.
|
||||
- [x] Switched to the TC22 development APK demo configuration at the user's request; signed in and approved (APPROVED).
|
||||
- [ ] Physical MC9400 trigger scan reaches the backend (Saved scan and a matching server record).
|
||||
|
||||
## Release qualification
|
||||
|
||||
- [x] Signed-APK/MDM channel chosen; QRun-owned key created with verified backup; signed 1.0.0 (versionCode 2) built and verified locally.
|
||||
- [x] Direct-install guide written ([INSTALLATION](../INSTALLATION.md)).
|
||||
- [ ] Qualify the production-signed APK on a device against an HTTPS server (enrollment, scanner path, navigation, update).
|
||||
- [x] Signed preview published: Gitea prerelease `scanner-v1.0.0` (APK and `SHA256SUMS` only; local install kit not hosted).
|
||||
- [ ] Distribution to the remaining devices after qualification; MDM later. Promotion to a stable release needs the qualification above.
|
||||
- [ ] Automatic signing/publication pipeline: not configured; owned by the shared CI design, not needed for manual distribution.
|
||||
- [ ] Actual on-device Authentik callback (unverified; no separate evidence).
|
||||
|
||||
## Dependencies and provisioning
|
||||
|
||||
- [ ] Public, reproducible publication of the Generation, Record Files and Scripting owner inputs (currently qualified inputs).
|
||||
- [ ] Company profile / EHS / OEMConfig / MDM deployment verified on a device ([DEVICE-PROVISIONING](../DEVICE-PROVISIONING.md), [COMPANY-PROFILE](../COMPANY-PROFILE.md)).
|
||||
- [ ] Legacy source follow-ups ([LEGACY-SOURCE-FOLLOWUPS](../LEGACY-SOURCE-FOLLOWUPS.md)).
|
||||
- [ ] Extend the [native component boundary](../UI-API.md) (possible values, record lists, specialized forms, reports) with conformance and E2E coverage as needed.
|
||||
|
||||
Offline queueing and background synchronization are outside version one.
|
||||
@@ -0,0 +1,142 @@
|
||||
> Historical record, superseded by [current status](../SESSION-STATE.md). Do not treat as current.
|
||||
|
||||
# TODO
|
||||
Current status (November 4):
|
||||
|
||||
- [x] Rewrite [RELEASING.md](../RELEASING.md) as concise Gitea-first signing/release readiness; legacy GitHub workflows marked historical. No keys, secrets, tags, releases, CI, build or source changed.
|
||||
- [x] Production signing decisions made (November 4): signed APK/MDM; new QRun-owned key `qqq-mobile-release` (PKCS12 RSA 4096, certificate SHA-256 `BDDB2D2F...5B17A9FF`, valid through 2056-10-27); backup in the owner's Personal 1Password vault `QRun`, recovery verified 2026-11-04. No CI/Infisical activation; shared CI/CD redesign paused separately.
|
||||
- [x] Local signed artifact and verification complete (November 4): 1.0.0/versionCode 2 from main `0ae2101` (lint/assemble 0 errors, 0 warnings), signed in isolation; `QQQ-Mobile-1.0.0.apk` SHA-256 `EE5AC896...79394D51`, v2+v3 verify, single signer with the fingerprint above, 16 KiB alignment, non-debuggable. Not installed, published or production-qualified; TC22 development-signed install/data intact.
|
||||
- [x] Direct-install guide prepared ([INSTALLATION.md](../INSTALLATION.md)) for a couple of devices; not deployed, and no physical test of the production APK is claimed yet. MDM follows later.
|
||||
- [ ] Qualify the actual production-signed APK on a planned migration or other device (install, enrollment, scanner path, navigation, update). The development-signed TC22 cannot upgrade in place to the new key; never uninstall/reset it implicitly. Requires an HTTPS server.
|
||||
- [ ] First direct installation and device qualification, then distribution to the remaining devices; MDM can follow later. Automated signing/publication pipeline work remains paused with the shared CI hold, independently of authorized manual distribution. Details in [RELEASING.md](../RELEASING.md).
|
||||
|
||||
Latest November 3 checkpoint (current; supersedes the older entries below):
|
||||
|
||||
- [x] Android `f72b90d` the signed application-code commit published in PR 10 (SSH read-back verified); Gitea CI 3480 succeeded (unit-test, device-test, package, secrets, gate); clean merge-tree with `main` b75fdac, nothing merged. Pull requests #8 and #10 remain open; merge and release not done. This update changes documentation only.
|
||||
- [x] Fresh disposable TC22 Echo ten-stage run: 109/109 semantic requests (simulated inputs/printing); installed APK `4AAB6951...AB850E` unchanged data.
|
||||
- [x] Fresh real Authentik desktop acceptance passes; native harness compiled, 8 launcher guards pass. [ ] On-device run awaits the temporary CA and PIN; no trust bypass.
|
||||
- [x] Server-side RC.21 gate PASS: 377 unique tests plus 15 real admin-browser E2E on public RC.21; the earlier MySQL timeouts did not recur. [ ] It is public core RC.21 with qualified private owner inputs, not a public/released cohort; the normal runtime core is not upgraded and Echo on RC.21 is not installed.
|
||||
|
||||
- [x] Install final client over the normal TC22 with signature match/data preserved; existing sign-in/approval and Connected verified. Settings include portrait, location reporting and working wallpaper preview.
|
||||
- [x] Fresh 222 JVM tests, lint zero errors/warnings, all APK builds, 23 Node tests and 255-file standards gate; updated ten-stage native Echo acceptance passes with simulated scans/printing.
|
||||
- [x] Integrate and compile opt-in real Authentik Android acceptance; real-provider desktop acceptance rerun passes all six phases with strict TLS.
|
||||
- [ ] Run real native Authentik after temporary CA installation/device interaction. Confirm physical scanner/label count/fidelity, camera/GPS/projection/profile behavior. Complete shared artifact publication/hosted CI; signing remains last.
|
||||
|
||||
Current checkpoint (November 2, copies adoption; supersedes older source/runtime notes below):
|
||||
|
||||
- [x] Verify optional Copies per scan through the actual generic native UI: default 1, range 1–100, selected 3 retained across two scans/recreation, and Apps/Leave; ten stages, 116 documented requests. Normal TC22 setup shows 1 against the updated local server.
|
||||
- [x] Combined candidate gate: 222 JVM tests, lint zero errors/warnings, debug/E2E/test APK compilation and 15 Node tests. Normal installed APK remains D8109C61; newer feedback/profile/wallpaper/AIM code is not installed.
|
||||
- [ ] Physically confirm label quantity and barcode fidelity. Native Authentik and camera/GPS/projection qualification remain open while the operator is remote; temporary CA installation is unconfirmed.
|
||||
- [ ] Coordinate canonical Generation copies adoption and coherent publication/CI. Signing/distribution remains last.
|
||||
|
||||
- [x] Implement negotiated mobile1.5 raw-byte transport with immutable capture, same-server capability gating and byte-identical failed-request retry;185 JVM tests, lint0errors/0warnings, debug/E2E builds and QA test compilation pass.
|
||||
- [ ] Qualify new raw-byte delivery on TC22 and complete actual AIM extraction/decoder-profile checks. Current source leaves AIM absent and does not change hardware configuration; full Echo fidelity is still open.
|
||||
- [x] Verify native `Origin` compatibility:174 JVM tests, lint0errors0warnings and APK build pass; normal TC22 login/approval/heartbeat/Echo setup/printer-media choices/Cancel-Leave pass against the strict local Echo host through8080->57832, preserving data.
|
||||
|
||||
- [x] Verify generic INTEGER Document choice/dependency clearing on actual TC22:7 stages,112 semantic requests,99 focused/host tests and8 Node guards; simulated printing, normal data preserved.
|
||||
- [x] Connect the installed print client to the single local server and publish the actual Echo4x1 document through native QQQ processes.
|
||||
- [x] Resolve the printing agent's earlier 4x6 media report versus loaded 4x1 stock: the Printing owner aligned 101600 x 25400 micron/300 dpi media and the TC22 selected the real printer, fresh 4x1 media and published document; Next succeeded.
|
||||
- [ ] Complete one physical Code128 (QQQ-TEST-123456) scan, print and rescan on the TC22, then leading-zero QR. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed.
|
||||
- [ ] Qualify public coherent artifacts and primary Gitea CI; signing and distribution stay last by user decision. Core PR 1337 normal CI and publication are not established; do not repin default dependencies.
|
||||
|
||||
- [x] Add metadata-driven Echo acknowledgment/manual-entry hints with old defaults; 167 JVM tests, strict compilation, lint zero errors/warnings and debug build pass.
|
||||
- [ ] Qualify native Echo scan/print-acceptance with independently decoded physical output (setup and Leave against the real owner adapters are verified; source tests are not physical printing evidence).
|
||||
- [x] Verify Query artwork, Dogfooding/Query links and licenses on the normal TC22 About screen using filtered accessibility data.
|
||||
|
||||
- [x] Apply scoped copyright/import/header/formatter corrections, mechanical CI gate and Kotlin warnings-as-errors;163JVM plus46TC22 sample native cases pass. See [review](../CODING-STANDARDS-REVIEW-2026-11-01.md) for the remaining formatter CI limitation.
|
||||
- [x] Add Query artwork, app version, Dogfooding/Query links and license access under Settings → About QQQ Mobile.
|
||||
- [x] Qualify real Authentik desktop nonce rejection and retained-browser A → company logout → fresh B; actual Android callback acceptance remains separate.
|
||||
|
||||
- [ ] Resolve the explicitly retained [legacy source follow-ups](../LEGACY-SOURCE-FOLLOWUPS.md): metadata typing, renderer contracts, navigation/action validation and build identity. Removing inline work markers does not implement these features.
|
||||
|
||||
- [x] Replace the raw diagnostics popup with tested full-screen status/activity, filtering, pause, copy/share/clear and Back; 159 client JVM tests, zero-warning lint/build and the focused TC22 diagnostics UI test pass.
|
||||
- [x] Default foreground location reporting on, preserving explicit opt-out and one-time Android permission behavior; settings/policy tests pass.
|
||||
- [x] Verify current trusted EMDK Code128, QR leading zeros and menu/reopen on installed checkpoint1c766bf/E34AC3C6.
|
||||
- [ ] Observe the diagnostics/location update on the normal package, grant/deny/restore location permission and verify an actual provider fix; keep physical lifecycle/outage/retry and provider acceptance gates open.
|
||||
|
||||
- [x] Verify the API1.10 original-nonce and optional company-logout client patch: 151 JVM tests, zero-warning lint/build and 28 disposable TC22 native authentication/lifecycle tests pass; independent 26-file source review completed with no findings.
|
||||
- [ ] Qualify the paired strict server policy and actual Authentik A-to-company-logout-to-B handoff, including the exported native callback and real SDK/browser logging boundary; synthetic native tests do not close this gate.
|
||||
|
||||
- [x] Verify current TC22 Code128 hardware trigger-to-save on the trusted EMDK path with operator Saved scan, matching backend source/model and distinct event identities; see [physical evidence](../PHYSICAL-TC22-ACCEPTANCE.md).
|
||||
- [ ] Finish current EMDK leading-zero QR, repeat-intent, lifecycle/ownership and retry/leave acceptance; historical DataWedge scans do not qualify these checks.
|
||||
|
||||
- [x] Verify the synchronized rotation/recreation assertions in both5ba GitHub native runs; one entire run passed and the other reached a later fresh-login timeout.
|
||||
- [x] Verify final fresh-login readiness at229fce3 and the later portrait-default7de checkpoint: all eleven hosted checks pass, including native navigation/input, PixelCopy and semantic scenarios with a new session. QR documentation2e0fd962 does not qualify physical QR acceptance.
|
||||
- [x] Make portrait the default for missing/invalid preferences while preserving explicit choices; four focused tests, lint/debug assembly and preserving TC22 install pass, with actual requested portrait orientation confirmed.
|
||||
- [ ] Finish the operator walkthrough on the newly updated normal TC22 app; private data/enrollment and the development signing identity were preserved.
|
||||
- [x] Compile the opt-in screen-consent QA harness and ordinary E2E tests; verify early opt-in/protected-port safety gates. Actual permission/display acceptance remains open.
|
||||
- [x] Compile/review additive consent-based device-screen client and paired1.2 server:137 JVM, package/lint/APK inspection, ordinary-UID TC22 scan-input rejection,39 server/six demo/all15 browser cases and strict provenance pass. Actual projection acceptance remains separate.
|
||||
- [ ] Execute opt-in actual Android consent, notification, Home/background pixels, Stop, lease expiry, lock/rotation and forced-session-end acceptance on the refined device-screen candidate.
|
||||
- [x] Supersede the historical hosted Back/focus failure gate with all eleven passing checks at7de; retain the failed-job diagnostics and actual Back/focus regression requirements in history.
|
||||
- [x] Establish the current EMDK Code128 positive control after the historical failed attempt: eight priorF3AF events and oneC891 event are verified. Preserve the failed-attempt evidence and bounded operator stage; QR/lifecycle/retry acceptance remains open above.
|
||||
- [x] Resolve normal backend ownership and perform the reviewed API 1.9 upgrade with fresh backup, original-column/data checks, recovery logins and live account validation; preserve the operator APK. Private verification confirms two devices and three scans retained.
|
||||
|
||||
- [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).
|
||||
- [x] Verify the trusted scanner-ingress replacement with production-path ordinary-UID rejection and actual installed EMDK Code128 positive controls. Preserve the historical security report scope; this does not close the remaining physical lifecycle/QR matrix.
|
||||
- [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.
|
||||
- [x] Reverify hosted Back/focus behavior at7de with real Back injection and the focus guard; historical failures remain documented in session state.
|
||||
- [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).
|
||||
- [x] Resolve viewing scope through optional operator-consented DEVICE_SCREEN on mobile1.2 source, retaining default APP_CONTENT and old-server compatibility. Actual projection acceptance remains open; normal mobile1.1 runtime exposes app-content only.
|
||||
|
||||
- [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.
|
||||
|
||||
- [x] Qualify semantic OAuth2 client protocol/policy and generated notices; all seven synthetic browser-result cases and the selected native navigation suite passed on TC22. See [acceptance evidence](../NATIVE-AUTH-ACCEPTANCE.md).
|
||||
- [ ] Complete actual configured Authentik acceptance: real browser/exported-callback round trip, authorization rotation/process death, and next-operator switching with prior browser SSO. See [authentication deployment](../AUTHENTICATION.md).
|
||||
- [x] Verify the single hamburger drawer correction: no duplicate Apps/Switch app controls, fixed Settings/Diagnostics, signed-out gating, drawer Back preserving drafts, and guarded Home/app switching with exactly one cancellation.
|
||||
|
||||
- [x] Verify persisted device Orientation choices on TC22, including process draft and pending-photo preservation across rotation.
|
||||
|
||||
- [x] Implement and verify [navigation UX](../NAVIGATION-UX.md): primary Apps/app/process navigation, secondary Account/Settings/diagnostics, shared Back/leave policy, protected in-flight operations and sign-out lifecycle.
|
||||
- [x] Prepare independent scanner release workflow and environment-only signing/version configuration; actionlint passes.
|
||||
- [x] Independently review the exact release workflow, reusable verification and Gradle signing configuration; see [review scope and hashes](../RELEASE-AUTOMATION-REVIEW.md). No actionable source finding; external protection settings are not verified by this review.
|
||||
- [ ] Configure reviewed GitHub release protections and the organization signing identity, verify the actual signed APK and upgrade/migration, and publish only after all [release prerequisites](../RELEASING.md) pass. Signing/distribution remains owner-deferred until the final step.
|
||||
|
||||
- [x] Correct false legacy network blocking while the scanner's server is reachable; preserve multi-network connectivity for shared clients.
|
||||
- [x] Persist workstation host-GPU configuration and document cold-start commands.
|
||||
|
||||
- [x] Add runtime server branding and a foreground connection footer with bounded sanitized diagnostics.
|
||||
- [x] Verify customer header/theme, diagnostics and connection loss/recovery on the emulator.
|
||||
- [ ] Verify customer-specific logo assets and physical TC22 appearance.
|
||||
|
||||
- [x] Complete Gradle unit tests, debug APK, signature, and lint verification (26 tests; zero lint errors).
|
||||
- [ ] Complete the current EMDK physical matrix: QR, lifecycle/ownership, retry/revocation, bad credentials, sign-out and re-login. The completed DataWedge checks below are historical and do not qualify current EMDK behavior.
|
||||
- [x] Verify TC22 physical Code128 and QR triggers through DataWedge intent output, visible Saved scan, preserved leading-zero QR data and independently inspected backend provenance; two Code128 triggers plus one QR trigger produced exactly three records.
|
||||
- [ ] Verify release APK signing/distribution separately; current deliverable is a debug APK.
|
||||
- [x] Reconcile exact historical project-owned source license boilerplate and the IDE template with the existing Apache-2.0 migration; preserve copyright ownership and other notices.
|
||||
- [ ] Verify third-party asset/dependency attribution and signed release/upgrade delivery before production distribution.
|
||||
- [ ] Configure and test production identity-provider integration; TABLE_BASED remains supported, and native OAuth2 requires explicit server advertisement and callback registration.
|
||||
- [x] Implement public `/qqq/v1/version` compatibility checks before credentials or native screens, including required features and the registered mobile-scanners extension.
|
||||
- [x] Implement semantic process completion, field/step updates, editable/view forms, backend cancellation and explicit unsupported-component blocking.
|
||||
- [x] Verify semantic migration JVM suites: 53 tests passed (core 21/mobileapp 23/scanner 9), including hidden fields, explicit null clearing, generic event ID isolation and completion when the server omits `nextStep`.
|
||||
- [x] Verify companion browser E2E after the semantic migration: six scenarios passed in 2.9 minutes.
|
||||
- [x] Require Android client-channel discovery; companion real HTTP tests use the Android header on every request and pass.
|
||||
- [x] Complete final JVM/lint/build verification of this semantic API migration: 53 tests, zero lint errors/six warnings, normal debug and isolated E2E/test APKs.
|
||||
- [x] Verify all six native scenarios across focused runs and the OpenAPI request audit: incompatible core, missing extension, missing Android channel, baseline/additive scans and native administrative forms.
|
||||
- [x] Identify the TC22 through ADB, authorize USB debugging, install the latest normal APK with login/enrollment preserved, approve its device record and open native Scan Capture with Connected status. Initial Code128 decoding passed; the remaining physical matrix is still pending.
|
||||
- [ ] Extend the [documented native component boundary](../UI-API.md) with remote possible values, record lists, specialized forms and table/report/download screens as required; add conformance and rendered E2E coverage for each new capability.
|
||||
|
||||
Offline queue/background synchronization is deliberately outside version one.
|
||||
- [x] Implement semantic mobile session activity, server termination, demand-based own-app screen viewing and optional foreground location.
|
||||
- [x] Add capture lifetime/lease/race tests, complete scanner receipt checks, strict maxLength and NavController regression coverage.
|
||||
- [x] Verify short-viewport login and focused long-form submission after IME Done and dismissal handling.
|
||||
- [x] Complete integrated session/native administrator termination E2E and all 12 shared TC22 navigation/input/account tests; local emulator remains closed.
|
||||
- [x] Freeze and review session/whole-device source within the documented security scopes and independent architecture A1–A11 closure. Final release revision/scope and external-runtime gates remain in the release ledger.
|
||||
- [x] Integrate camera/gallery with Record Files typed routes and guarded optional bridge fixture; synthetic native E2E verifies persisted receipt, rotation and forced-logout cleanup.
|
||||
- [ ] Complete actual camera-lens/operator acceptance after final runtime installation; synthetic activity results do not prove the OEM camera application flow.
|
||||
- [x] Supersede the October31 hosted checkpoint gate with all eleven passing hosted checks at7de; retain earlier revision-specific results as history.
|
||||
- [x] Verify the published push/PR native E2E lanes through the current7de hosted checkpoint; physical operator acceptance remains separate.
|
||||
- [x] Run the native sample instrumentation suite (six passed on API35).
|
||||
- [x] Add and run real-server scanner-host E2E: login, pending enrollment, approval, native app/process selection, manual scan persistence and revoked-device denial. Use an isolated app ID and database.
|
||||
- [x] Run and verify native real-server E2E on pushes and pull requests; current7de hosted checks pass.
|
||||
- [ ] Complete the final operator walkthrough on TC22 after updating the preserved demo/runtime, including outage/reconnect and real camera capture.
|
||||
|
||||
- [x] Implement named semantic process choices and qualify Echo native setup/two scans/rotation/Apps Leave onTC22 (169JVM,8guards,86documentedrequests; printing simulated).
|
||||
- [x] Real Echo host and owner adapters are composed and running in the single local server (see the Origin item above).
|
||||
- [ ] Print a physical label and rescan the exact value and type (tracked above).
|
||||
|
||||
- [x] Clear scanner feedback on process reset; 57 mobileapp JVM tests, zero-warning scanner lint, debug build and 255-file standards pass. Hosted checks and PR10 → PR8 → main integration remain pending.
|
||||
|
||||
# Scoped administrator viewing update — November 2
|
||||
|
||||
- [x] Remove app-authored viewing stop controls, verify the existing native session-monitor flow and install on TC22 with app data preserved. Retain the visible indicator, Android consent/system stop controls, server leases and authorization.
|
||||
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -30,10 +30,10 @@ plugins {
|
||||
publishing {
|
||||
repositories {
|
||||
maven {
|
||||
name = "githubQqqMavenRegistry"
|
||||
url = uri("https://maven.pkg.github.com/QRun-IO/qqq-maven-registry")
|
||||
// username and password (a personal Github access token) should be specified as
|
||||
// `githubPackagesUsername` and `githubPackagesPassword` Gradle properties
|
||||
// Gitea Maven registry of the repository owner. CI sets giteaMavenUrl, giteaUsername
|
||||
// and giteaPassword (ci-workflows android.yaml, publish-tasks on vX.Y.Z tags).
|
||||
name = "gitea"
|
||||
url = uri(providers.gradleProperty("giteaMavenUrl").getOrElse("https://git.kof22.com/api/packages/QRun-IO/maven"))
|
||||
credentials(PasswordCredentials::class)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,48 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
sessionUUIDs[baseUrl] = sessionUUID
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded, stably ordered read-only pages retain normal session/device guards.
|
||||
*******************************************************************************/
|
||||
override suspend fun getTableMetaData(tableName: String) = qqqApiServiceMap[baseUrl]!!.getTableMetaData(tableName)
|
||||
|
||||
override suspend fun queryRecords(tableName: String, primaryKey: String, offset: Int, limit: Int): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> {
|
||||
require(offset >= 0 && limit in 1..100 && primaryKey.isNotBlank())
|
||||
val filter = JSONObject().put("skip", offset).put("limit", limit)
|
||||
.put("orderBys", org.json.JSONArray().put(JSONObject().put("fieldName", primaryKey).put("isAscending", true)))
|
||||
val body = JSONObject().put("filter", filter).toString().toRequestBody("application/json".toMediaType())
|
||||
return qqqApiServiceMap[baseUrl]!!.queryRecords(tableName, body).records
|
||||
}
|
||||
|
||||
override suspend fun getRecord(tableName: String, recordId: String) = qqqApiServiceMap[baseUrl]!!.getRecord(tableName, recordId).record
|
||||
|
||||
override suspend fun searchRecords(tableName: String, primaryKey: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch, offset: Int, limit: Int): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> {
|
||||
require(offset >= 0 && limit in 1..100 && primaryKey.isNotBlank())
|
||||
val filter = search.filter().put("skip", offset).put("limit", limit)
|
||||
.put("orderBys", org.json.JSONArray().put(JSONObject().put("fieldName", primaryKey).put("isAscending", true)))
|
||||
val body = JSONObject().put("filter", filter).toString().toRequestBody("application/json".toMediaType())
|
||||
return qqqApiServiceMap[baseUrl]!!.queryRecords(tableName, body).records
|
||||
}
|
||||
|
||||
override suspend fun countRecords(tableName: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch): Long {
|
||||
val body = JSONObject().put("filter", search.filter()).toString().toRequestBody("application/json".toMediaType())
|
||||
return qqqApiServiceMap[baseUrl]!!.countRecords(tableName, body).count.also { require(it >= 0) }
|
||||
}
|
||||
|
||||
override suspend fun queryRelatedRecords(tableName: String, primaryKey: String, matches: Map<String, kotlinx.serialization.json.JsonElement>, offset: Int, limit: Int): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> {
|
||||
require(offset >= 0 && limit in 1..100 && primaryKey.isNotBlank() && matches.isNotEmpty())
|
||||
val criteria = org.json.JSONArray()
|
||||
matches.forEach { (field, value) ->
|
||||
require(field.isNotBlank() && value is kotlinx.serialization.json.JsonPrimitive && value != kotlinx.serialization.json.JsonNull)
|
||||
criteria.put(JSONObject().put("fieldName", field).put("operator", "EQUALS")
|
||||
.put("values", org.json.JSONArray("[$value]")))
|
||||
}
|
||||
val filter = JSONObject().put("skip", offset).put("limit", limit).put("booleanOperator", "AND").put("criteria", criteria)
|
||||
.put("orderBys", org.json.JSONArray().put(JSONObject().put("fieldName", primaryKey).put("isAscending", true)))
|
||||
val body = JSONObject().put("filter", filter).toString().toRequestBody("application/json".toMediaType())
|
||||
return qqqApiServiceMap[baseUrl]!!.queryRecords(tableName, body).records
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData {
|
||||
|
||||
@@ -134,6 +134,21 @@ interface QQQApiService {
|
||||
@GET(BASE_PATH + "metaData/process/{processName}")
|
||||
suspend fun getProcessMetaData(@Path(value = "processName") processName: String): QProcessMetaData
|
||||
|
||||
/*******************************************************************************
|
||||
** Registered table metadata/query/get operations; no alternative record API.
|
||||
*******************************************************************************/
|
||||
@GET(BASE_PATH + "metaData/table/{tableName}")
|
||||
suspend fun getTableMetaData(@Path("tableName") tableName: String): com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
|
||||
|
||||
@POST(BASE_PATH + "table/{tableName}/query")
|
||||
suspend fun queryRecords(@Path("tableName") tableName: String, @Body body: RequestBody): com.kingsrook.qqq.frontend.android.core.model.RecordQueryResponse
|
||||
|
||||
@POST(BASE_PATH + "table/{tableName}/count")
|
||||
suspend fun countRecords(@Path("tableName") tableName: String, @Body body: RequestBody): com.kingsrook.qqq.frontend.android.core.model.RecordCountResponse
|
||||
|
||||
@GET(BASE_PATH + "table/{tableName}/{recordId}")
|
||||
suspend fun getRecord(@Path("tableName") tableName: String, @Path("recordId") recordId: String): com.kingsrook.qqq.frontend.android.core.model.RecordGetResponse
|
||||
|
||||
/*******************************************************************************
|
||||
** Start process execution with explicit record selection and values, returning authoritative server step state.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -106,6 +106,27 @@ interface QQQRepository {
|
||||
*******************************************************************************/
|
||||
suspend fun getProcessMetaData(processName: String): QProcessMetaData
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only table navigation uses the core API and its normal permissions.
|
||||
*******************************************************************************/
|
||||
suspend fun getTableMetaData(tableName: String): com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData =
|
||||
throw UnsupportedOperationException("Table browsing is unavailable")
|
||||
|
||||
suspend fun queryRecords(tableName: String, primaryKey: String, offset: Int, limit: Int = 25): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> =
|
||||
throw UnsupportedOperationException("Table browsing is unavailable")
|
||||
|
||||
suspend fun getRecord(tableName: String, recordId: String): com.kingsrook.qqq.frontend.android.core.model.RecordData =
|
||||
throw UnsupportedOperationException("Record viewing is unavailable")
|
||||
|
||||
suspend fun searchRecords(tableName: String, primaryKey: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch, offset: Int, limit: Int = 25): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> =
|
||||
throw UnsupportedOperationException("Record search is unavailable")
|
||||
|
||||
suspend fun countRecords(tableName: String, search: com.kingsrook.qqq.frontend.android.core.model.RecordSearch): Long =
|
||||
throw UnsupportedOperationException("Record counts are unavailable")
|
||||
|
||||
suspend fun queryRelatedRecords(tableName: String, primaryKey: String, matches: Map<String, kotlinx.serialization.json.JsonElement>, offset: Int, limit: Int = 10): List<com.kingsrook.qqq.frontend.android.core.model.RecordData> =
|
||||
throw UnsupportedOperationException("Related records are unavailable")
|
||||
|
||||
/*******************************************************************************
|
||||
** Start server-defined process execution with explicit values and record selection; return authoritative step state.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.core.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned table responses preserve opaque primary keys and display values.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class RecordData(
|
||||
val tableName: String? = null,
|
||||
val recordLabel: String? = null,
|
||||
val values: Map<String, JsonElement> = emptyMap(),
|
||||
val displayValues: Map<String, JsonElement> = emptyMap(),
|
||||
) {
|
||||
fun id(primaryKey: String): String? = (values[primaryKey] as? JsonPrimitive)?.contentOrNull
|
||||
fun display(field: String): String = ((displayValues[field] ?: values[field]) as? JsonPrimitive)?.contentOrNull.orEmpty()
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query records are unwrapped; a get response wraps exactly one record.
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class RecordQueryResponse(val records: List<RecordData> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class RecordGetResponse(val record: RecordData)
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.core.model
|
||||
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordAssociation
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
data class RecordFieldGroup(val label: String, val fields: List<QFieldMetaData>)
|
||||
|
||||
/** Retain metadata order, suppress hidden sections, and show ungrouped fields once. */
|
||||
fun QTableMetaData.recordGroups(): List<RecordFieldGroup> {
|
||||
val sections = sections.orEmpty()
|
||||
val hidden = sections.filter { it.isHidden || it.hidden }.flatMap { it.fieldNames.orEmpty() }.toSet()
|
||||
val remaining = fields.filterValues { !it.isHidden && !it.isHeavy }.toMutableMap()
|
||||
hidden.forEach(remaining::remove)
|
||||
val groups = sections.filter { !it.isHidden && !it.hidden }.mapNotNull { section ->
|
||||
val members = section.fieldNames.orEmpty().mapNotNull(remaining::remove)
|
||||
if (members.isEmpty()) null else RecordFieldGroup(section.label ?: section.name, members)
|
||||
}.toMutableList()
|
||||
if (remaining.isNotEmpty()) groups.add(RecordFieldGroup(if (groups.isEmpty()) "Details" else "Other fields", remaining.values.toList()))
|
||||
return groups
|
||||
}
|
||||
|
||||
/** Resolve only declared, direct equality joins. Missing values must never become an unfiltered query. */
|
||||
fun QRecordAssociation.parentMatches(parentTable: String, record: RecordData): Map<String, JsonElement>? {
|
||||
val forward = join.leftTable == parentTable && join.rightTable == associatedTableName
|
||||
val reverse = join.rightTable == parentTable && join.leftTable == associatedTableName
|
||||
if ((!forward && !reverse) || join.joinOns.isEmpty()) return null
|
||||
val matches = linkedMapOf<String, JsonElement>()
|
||||
for (on in join.joinOns) {
|
||||
val source = if (forward) on.leftField else on.rightField
|
||||
val target = if (forward) on.rightField else on.leftField
|
||||
val value = record.values[source]
|
||||
if (target.isBlank() || source.isBlank() || value !is JsonPrimitive || value == JsonNull || target in matches) return null
|
||||
matches[target] = value
|
||||
}
|
||||
return matches
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.core.model
|
||||
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.NumberFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/** The same predicate is used by page and count requests. Parent matches remain mandatory. */
|
||||
data class RecordSearch(
|
||||
val text: String = "",
|
||||
val searchFields: List<String> = emptyList(),
|
||||
val matches: Map<String, JsonElement> = emptyMap(),
|
||||
) {
|
||||
fun filter(): JSONObject {
|
||||
val criteria = JSONArray()
|
||||
matches.forEach { (name, value) ->
|
||||
require(name.isNotBlank() && value is JsonPrimitive && value != JsonNull)
|
||||
criteria.put(JSONObject().put("fieldName", name).put("operator", "EQUALS").put("values", JSONArray("[$value]")))
|
||||
}
|
||||
val result = JSONObject().put("booleanOperator", "AND").put("criteria", criteria)
|
||||
if (text.isNotBlank()) {
|
||||
require(searchFields.isNotEmpty() && searchFields.all(String::isNotBlank))
|
||||
val search = JSONArray()
|
||||
searchFields.forEach { search.put(JSONObject().put("fieldName", it).put("operator", "CONTAINS").put("values", JSONArray().put(text))) }
|
||||
result.put("subFilters", JSONArray().put(JSONObject().put("booleanOperator", "OR").put("criteria", search)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RecordCountResponse(val count: Long)
|
||||
|
||||
/** Search only visible scalar text fields; server metadata defines preferred fields. */
|
||||
fun QTableMetaData.textFields(): List<QFieldMetaData> = recordGroups().flatMap { it.fields }
|
||||
.filter { it.type == QFieldType.STRING || it.type == QFieldType.TEXT }
|
||||
|
||||
fun QTableMetaData.recordSearchFields(): List<String> {
|
||||
val visible = textFields().map { it.name }
|
||||
return searchFields.orEmpty().filter { it in visible }.ifEmpty { visible.take(3) }
|
||||
}
|
||||
|
||||
/** Preserve server formatting and identifiers. Only typed dates and numbers receive a local fallback. */
|
||||
fun RecordData.formatted(field: QFieldMetaData): String {
|
||||
val value = display(field.name)
|
||||
if (value.isBlank()) return "—"
|
||||
if (!field.displayFormat.isNullOrBlank() && field.displayFormat != "%s") return value
|
||||
return runCatching {
|
||||
when (field.type) {
|
||||
QFieldType.DATE -> LocalDate.parse(value).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
|
||||
QFieldType.DATE_TIME -> Instant.parse(value).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("MMM d, yyyy · h:mm a z"))
|
||||
QFieldType.DECIMAL -> NumberFormat.getNumberInstance().apply { maximumFractionDigits = 8 }.format(value.toBigDecimal())
|
||||
QFieldType.BOOLEAN -> when (value) { "true" -> "Yes"; "false" -> "No"; else -> value }
|
||||
else -> value
|
||||
}
|
||||
}.getOrDefault(value)
|
||||
}
|
||||
|
||||
/** Compact summaries follow metadata order, omit the primary key and never expose hidden groups. */
|
||||
fun RecordData.summary(table: QTableMetaData): String = table.recordGroups().flatMap { it.fields }
|
||||
.filter { it.name != table.primaryKeyField && display(it.name).isNotBlank() && display(it.name).length <= 80 && recordLabel?.contains(display(it.name)) != true }
|
||||
.take(3).joinToString(" · ") { "${it.label}: ${formatted(it)}" }
|
||||
@@ -27,8 +27,9 @@ import kotlinx.serialization.Serializable
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class QHelpContent(
|
||||
val content: String,
|
||||
val format: String,
|
||||
val content: String? = null, // QQQ core now sends summary-only help entries
|
||||
val summary: String? = null,
|
||||
val format: String? = null,
|
||||
val roles: Set<String> = emptySet(),
|
||||
val contentAsText: String? = null
|
||||
) {
|
||||
|
||||
@@ -32,5 +32,36 @@ data class QTableMetaData(
|
||||
val label: String,
|
||||
val icon: QIcon? = null,
|
||||
val isHidden: Boolean = false,
|
||||
val primaryKeyField: String? = null,
|
||||
val fields: Map<String, QFieldMetaData> = emptyMap(),
|
||||
val sections: List<QRecordSection>? = null,
|
||||
val associations: List<QRecordAssociation>? = null,
|
||||
val searchFields: List<String>? = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class QRecordSection(
|
||||
val name: String,
|
||||
val label: String? = null,
|
||||
val fieldNames: List<String>? = null,
|
||||
val isHidden: Boolean = false,
|
||||
val hidden: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QRecordAssociation(
|
||||
val name: String,
|
||||
val associatedTableName: String,
|
||||
val join: QRecordJoin,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QRecordJoin(
|
||||
val leftTable: String,
|
||||
val rightTable: String,
|
||||
val joinOns: List<QRecordJoinOn> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QRecordJoinOn(val leftField: String, val rightField: String)
|
||||
@@ -23,6 +23,7 @@
|
||||
package com.kingsrook.qqq.frontend.android.core.model.metadata
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@@ -30,7 +31,7 @@ import kotlinx.serialization.Serializable
|
||||
data class QWidgetMetaData(
|
||||
val name: String,
|
||||
val label: String? = null,
|
||||
val icon: QIcon? = null,
|
||||
val icon: JsonElement? = null, // QQQ core serializes a widget icon as a plain string (e.g. "troubleshoot"); tolerate an object too
|
||||
val type: String,
|
||||
) {
|
||||
}
|
||||
@@ -31,4 +31,9 @@ data class ManageSessionResponse(
|
||||
val uuid: String? = null,
|
||||
val values: MapOfStringAny = emptyMap(),
|
||||
) {
|
||||
/*******************************************************************************
|
||||
** Display name from the optional values.user object; absent, non-object or blank names yield null.
|
||||
*******************************************************************************/
|
||||
val userName: String?
|
||||
get() = ((values["user"] as? Map<*, *>)?.get("name") as? String)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
@@ -15,6 +15,93 @@ import org.junit.Test
|
||||
** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped.
|
||||
*******************************************************************************/
|
||||
class NetworkQQQRepositoryTest {
|
||||
@Test
|
||||
fun searchAndCountKeepIdenticalPredicatesAndOpaqueParentKeys() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setBody("{\"count\":1}"))
|
||||
server.enqueue(MockResponse().setBody("{\"records\":[]}"))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin)
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
val search = com.kingsrook.qqq.frontend.android.core.model.RecordSearch("a\"b", listOf("reference", "name"), mapOf("parent" to kotlinx.serialization.json.JsonPrimitive("0007")))
|
||||
assertEquals(1L, repository.countRecords("items", search))
|
||||
repository.searchRecords("items", "id", search, 25)
|
||||
server.takeRequest()
|
||||
val counted = server.takeRequest()
|
||||
assertEquals("/qqq/v1/table/items/count", counted.path)
|
||||
val expected = org.json.JSONObject(counted.body.readUtf8()).getJSONObject("filter")
|
||||
val query = org.json.JSONObject(server.takeRequest().body.readUtf8()).getJSONObject("filter")
|
||||
assertEquals(25, query.remove("skip"))
|
||||
assertEquals(25, query.remove("limit"))
|
||||
query.remove("orderBys")
|
||||
assertTrue(expected.similar(query))
|
||||
assertEquals("0007", expected.getJSONArray("criteria").getJSONObject(0).getJSONArray("values").getString(0))
|
||||
assertEquals("OR", expected.getJSONArray("subFilters").getJSONObject(0).getString("booleanOperator"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relatedQueriesAreBoundedAndCannotDropTheParentFilter() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setBody("""{"records":[]}"""))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin)
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
repository.queryRelatedRecords("lines", "id", mapOf("parent" to kotlinx.serialization.json.JsonPrimitive("0007")), 10, 10)
|
||||
server.takeRequest()
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/qqq/v1/table/lines/query", request.path)
|
||||
val filter = org.json.JSONObject(request.body.readUtf8()).getJSONObject("filter")
|
||||
assertEquals(10, filter.getInt("skip"))
|
||||
assertEquals(10, filter.getInt("limit"))
|
||||
assertEquals("AND", filter.getString("booleanOperator"))
|
||||
val criteria = filter.getJSONArray("criteria").getJSONObject(0)
|
||||
assertEquals("parent", criteria.getString("fieldName"))
|
||||
assertEquals("EQUALS", criteria.getString("operator"))
|
||||
assertEquals("0007", criteria.getJSONArray("values").getString(0))
|
||||
assertTrue(runCatching { repository.queryRelatedRecords("lines", "id", emptyMap(), 0) }.isFailure)
|
||||
assertTrue(runCatching { repository.queryRelatedRecords("lines", "id", mapOf("parent" to kotlinx.serialization.json.JsonNull), 0) }.isFailure)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Native browsing retains server identity, opaque ids, bounded paging and read-only routes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun recordBrowsingUsesSemanticRoutesAndPreservesIds() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setBody("""{"name":"items","label":"Items","primaryKeyField":"key","fields":{"key":{"name":"key","label":"Key","type":"STRING"}}}"""))
|
||||
server.enqueue(MockResponse().setBody("""{"records":[{"tableName":"items","recordLabel":"Item","values":{"key":"0007","quantity":0},"displayValues":{"quantity":"Zero"}}]}"""))
|
||||
server.enqueue(MockResponse().setBody("""{"record":{"tableName":"items","values":{"key":"0007"}}}"""))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin, additionalHeaders = { mapOf("X-QQQ-Device-Id" to "fixture-device") })
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
repository.setSessionUUID("fixture-session")
|
||||
assertEquals("key", repository.getTableMetaData("items").primaryKeyField)
|
||||
val records = repository.queryRecords("items", "key", 25, 25)
|
||||
assertEquals("0007", records.single().id("key"))
|
||||
assertEquals("Zero", records.single().display("quantity"))
|
||||
assertEquals("0007", repository.getRecord("items", "0007").id("key"))
|
||||
assertEquals("/qqq/v1/version", server.takeRequest().path)
|
||||
assertEquals("/qqq/v1/metaData/table/items", server.takeRequest().path)
|
||||
val query = server.takeRequest()
|
||||
assertEquals("/qqq/v1/table/items/query", query.path)
|
||||
assertEquals("fixture-device", query.getHeader("X-QQQ-Device-Id"))
|
||||
assertEquals("sessionUUID=fixture-session", query.getHeader("Cookie"))
|
||||
val filter = org.json.JSONObject(query.body.readUtf8()).getJSONObject("filter")
|
||||
assertEquals(25, filter.getInt("skip"))
|
||||
assertEquals(25, filter.getInt("limit"))
|
||||
assertEquals("key", filter.getJSONArray("orderBys").getJSONObject(0).getString("fieldName"))
|
||||
val get = server.takeRequest()
|
||||
assertEquals("GET", get.method)
|
||||
assertEquals("/qqq/v1/table/items/0007", get.path)
|
||||
assertTrue(runCatching { repository.queryRecords("items", "key", 0, 1000) }.isFailure)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.core.model
|
||||
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordAssociation
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class RecordLayoutTest {
|
||||
@Test
|
||||
fun sectionsPreserveOrderWithoutLeakingHiddenOrDuplicatingFields() {
|
||||
val table = Json.decodeFromString<QTableMetaData>("""{
|
||||
"name":"orders","label":"Orders","fields":{
|
||||
"id":{"name":"id","label":"ID","type":"INTEGER"},
|
||||
"secret":{"name":"secret","label":"Secret","type":"STRING"},
|
||||
"other":{"name":"other","label":"Other","type":"STRING"}},
|
||||
"sections":[{"name":"hidden","hidden":true,"fieldNames":["secret"]},
|
||||
{"name":"overview","label":"Overview","fieldNames":["id","id","missing"]}]
|
||||
}""")
|
||||
assertEquals(listOf("Overview", "Other fields"), table.recordGroups().map { it.label })
|
||||
assertEquals(listOf("id", "other"), table.recordGroups().flatMap { it.fields }.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relationshipsRespectDirectionTypesAndMissingParentKeys() {
|
||||
val association = Json.decodeFromString<QRecordAssociation>("""{
|
||||
"name":"Lines","associatedTableName":"lines",
|
||||
"join":{"leftTable":"orders","rightTable":"lines","joinOns":[{"leftField":"key","rightField":"orderKey"}]}
|
||||
}""")
|
||||
val record = RecordData(values = mapOf("key" to JsonPrimitive("0007")))
|
||||
assertEquals(mapOf("orderKey" to JsonPrimitive("0007")), association.parentMatches("orders", record))
|
||||
assertNull(association.parentMatches("unrelated", record))
|
||||
assertNull(association.parentMatches("orders", RecordData()))
|
||||
val reversed = association.copy(join = association.join.copy(leftTable = "lines", rightTable = "orders",
|
||||
joinOns = association.join.joinOns.map { it.copy(leftField = it.rightField, rightField = it.leftField) }))
|
||||
assertEquals(association.parentMatches("orders", record), reversed.parentMatches("orders", record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.core.model
|
||||
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldType
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordSection
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RecordPresentationTest {
|
||||
@Test
|
||||
fun typedDatesFormatEvenWhenTheirRawJsonRepresentationDiffersFromDisplayText() {
|
||||
val record = RecordData(values = mapOf("date" to JsonArray(listOf(JsonPrimitive(2026), JsonPrimitive(1), JsonPrimitive(2)))),
|
||||
displayValues = mapOf("date" to JsonPrimitive("2026-01-02"), "time" to JsonPrimitive("2026-01-02T11:30:00Z")))
|
||||
// The semantic server supplies %s for fields without a custom display format.
|
||||
val date = record.formatted(QFieldMetaData("date", "Date", type = QFieldType.DATE, displayFormat = "%s"))
|
||||
assertTrue(date.contains("2026"))
|
||||
assertFalse(date.contains("2026-01-02"))
|
||||
assertFalse(record.formatted(QFieldMetaData("time", "Time", type = QFieldType.DATE_TIME, displayFormat = "%s")).contains("T11:30"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formattingPreservesIdentifiersAndServerCurrencyAndMakesEmptyValuesExplicit() {
|
||||
val record = RecordData(values = mapOf("key" to JsonPrimitive("000026"), "amount" to JsonPrimitive(3.5), "enabled" to JsonPrimitive(true)), displayValues = mapOf("amount" to JsonPrimitive("$3.50")))
|
||||
assertEquals("000026", record.formatted(QFieldMetaData("key", "Key", type = QFieldType.STRING)))
|
||||
assertEquals("$3.50", record.formatted(QFieldMetaData("amount", "Amount", type = QFieldType.DECIMAL, displayFormat = "$%,.2f")))
|
||||
assertEquals("Yes", record.formatted(QFieldMetaData("enabled", "Enabled", type = QFieldType.BOOLEAN)))
|
||||
assertEquals("—", record.formatted(QFieldMetaData("missing", "Missing", type = QFieldType.STRING)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchAndSummaryNeverExposeHiddenSectionFields() {
|
||||
val field = QFieldMetaData("visible", "Visible", type = QFieldType.STRING)
|
||||
val hidden = field.copy(name = "secret", label = "Secret")
|
||||
val table = QTableMetaData("test", "Test", fields = mapOf("visible" to field, "secret" to hidden),
|
||||
searchFields = listOf("secret", "visible"), sections = listOf(QRecordSection("hidden", fieldNames = listOf("secret"), hidden = true)))
|
||||
val record = RecordData(values = mapOf("visible" to JsonPrimitive("public"), "secret" to JsonPrimitive("private")))
|
||||
assertEquals(listOf("visible"), table.recordSearchFields())
|
||||
assertEquals("Visible: public", record.summary(table))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2004-2024. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.android.core.model.metadata
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/*******************************************************************************
|
||||
** Current QQQ core serializes widget icons as plain strings and sends
|
||||
** summary-only help entries; the decoder must accept those shapes and the
|
||||
** older object/content shapes.
|
||||
*******************************************************************************/
|
||||
class MetadataDecodingTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun widgetIconAsStringDecodes() {
|
||||
val widget = json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table","icon":"troubleshoot"}""")
|
||||
assertEquals(JsonPrimitive("troubleshoot"), widget.icon)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun widgetIconAsObjectStillDecodes() {
|
||||
val widget = json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table","icon":{"name":"troubleshoot"}}""")
|
||||
assertTrue(widget.icon is JsonObject)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun widgetWithoutIconDecodes() {
|
||||
assertNull(json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table"}""").icon)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun summaryOnlyHelpDecodes() {
|
||||
val help = json.decodeFromString<QHelpContent>("""{"summary":"short help","roles":["ALL_SCREENS"]}""")
|
||||
assertEquals("short help", help.summary)
|
||||
assertNull(help.content)
|
||||
assertNull(help.format)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun contentHelpStillDecodes() {
|
||||
val help = json.decodeFromString<QHelpContent>("""{"content":"body","format":"TEXT"}""")
|
||||
assertEquals("body", help.content)
|
||||
assertEquals("TEXT", help.format)
|
||||
assertNull(help.summary)
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,10 @@ plugins {
|
||||
publishing {
|
||||
repositories {
|
||||
maven {
|
||||
name = "githubQqqMavenRegistry"
|
||||
url = uri("https://maven.pkg.github.com/QRun-IO/qqq-maven-registry")
|
||||
// username and password (a personal Github access token) should be specified as
|
||||
// `githubPackagesUsername` and `githubPackagesPassword` Gradle properties
|
||||
// Gitea Maven registry of the repository owner. CI sets giteaMavenUrl, giteaUsername
|
||||
// and giteaPassword (ci-workflows android.yaml, publish-tasks on vX.Y.Z tags).
|
||||
name = "gitea"
|
||||
url = uri(providers.gradleProperty("giteaMavenUrl").getOrElse("https://git.kof22.com/api/packages/QRun-IO/maven"))
|
||||
credentials(PasswordCredentials::class)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,9 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -165,6 +165,10 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
|
||||
LaunchedEffect(qViewModel.isAuthenticated, qViewModel.isLogoutRequested, businessReady)
|
||||
{
|
||||
if (!businessReady) topLevelAppState.closeNavDrawer()
|
||||
if (!qViewModel.isAuthenticated || qViewModel.isLogoutRequested) {
|
||||
qViewModel.qNavigator.recordWorkBlock = null
|
||||
qViewModel.qNavigator.blockedNavigationMessage = null
|
||||
}
|
||||
if (qViewModel.isLogoutRequested) {
|
||||
settingsOpen.value = false
|
||||
diagnosticsOpen.value = false
|
||||
@@ -258,6 +262,11 @@ fun QMobileAppMain(qViewModel: QViewModel, title: String, splashImage: Painter?
|
||||
title = { Text("Diagnostics") }, text = { Text("Server: ${qViewModel.environment.baseUrl}\nApplication version: ${QViewModel.applicationVersion}") },
|
||||
confirmButton = { TextButton(onClick = { diagnosticsOpen.value = false }) { Text("Close") } })
|
||||
LeaveProcessDialog(qViewModel)
|
||||
qViewModel.qNavigator.blockedNavigationMessage?.let { message ->
|
||||
AlertDialog(onDismissRequest = { qViewModel.qNavigator.blockedNavigationMessage = null },
|
||||
title = { Text("Photo in progress") }, text = { Text(message) },
|
||||
confirmButton = { TextButton(onClick = { qViewModel.qNavigator.blockedNavigationMessage = null }) { Text("Return to photo") } })
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.AppRoute
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.HomeRoute
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.ProcessRoute
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.TableRoute
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.RecordRoute
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
|
||||
@@ -63,6 +65,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
|
||||
var leaveRequest: LeaveRequest? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var recordWorkBlock: String? by mutableStateOf(null)
|
||||
var blockedNavigationMessage: String? by mutableStateOf(null)
|
||||
|
||||
/*******************************************************************************
|
||||
** Dismiss confirmation only while cancellation is idle, preserving the in-flight process ownership decision.
|
||||
*******************************************************************************/
|
||||
@@ -74,6 +79,7 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
|
||||
** Apply the same guarded process-exit policy before every destination or sign-out action.
|
||||
*******************************************************************************/
|
||||
fun requestLeave(title: String, confirmLabel: String = "Leave process", alwaysConfirm: Boolean = false, action: () -> Unit) {
|
||||
recordWorkBlock?.let { blockedNavigationMessage = it; return }
|
||||
if (leaveRequest != null || qViewModel.logoutInProgress) return
|
||||
val process = qViewModel.activeProcessViewModel
|
||||
if (alwaysConfirm || process != null && !process.getNoMoreSteps())
|
||||
@@ -156,7 +162,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
|
||||
val instance = (qViewModel.qInstanceLoadState as? LoadState.Success<QInstance>)?.value
|
||||
val processName = if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().name else null
|
||||
val appName = if (entry.destination.hasRoute<AppRoute>()) entry.toRoute<AppRoute>().name else
|
||||
if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().appName else null
|
||||
if (entry.destination.hasRoute<ProcessRoute>()) entry.toRoute<ProcessRoute>().appName else
|
||||
if (entry.destination.hasRoute<TableRoute>()) entry.toRoute<TableRoute>().appName else
|
||||
if (entry.destination.hasRoute<RecordRoute>()) entry.toRoute<RecordRoute>().appName else null
|
||||
atHome = entry.destination.hasRoute<HomeRoute>()
|
||||
navDepth = if (atHome) 0 else 1
|
||||
currentAppName = if (atHome) null else appName
|
||||
@@ -187,13 +195,25 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
fun navigateToApp(name: String, label: String) {
|
||||
if (currentAppName == name && qViewModel.activeProcessViewModel == null) return
|
||||
if (currentAppName == name && navController?.currentDestination?.hasRoute<AppRoute>() == true) return
|
||||
requestLeave("Switch app?") {
|
||||
homeNow()
|
||||
navigateToRoute(AppRoute(name), label)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Records use ordinary navigation, preserving any active work before leaving.
|
||||
*******************************************************************************/
|
||||
fun navigateToTable(name: String, label: String) = requestLeave("Open table?") {
|
||||
navigateToRoute(TableRoute(name, currentAppName), label)
|
||||
}
|
||||
|
||||
fun navigateToRecord(table: String, id: String, hasParent: Boolean = false) = requestLeave("Open record?") {
|
||||
// Navigation titles feed session activity. Never place record values in telemetry.
|
||||
navigateToRoute(RecordRoute(table, id, currentAppName, hasParent), "Record")
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** As part of preserving the process view-model in case of activity restarts
|
||||
** (e.g., when screen orientation changes) - manage the lifecycle of the
|
||||
|
||||
@@ -137,9 +137,10 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app:
|
||||
}
|
||||
for (child in otherChildren) {
|
||||
val processViewModel: ProcessViewModel = viewModel(factory = ProcessViewModel.factory)
|
||||
Tile(child.label, child.icon, generalSize, "appHome.rowFor${child.type}:" + child.name)
|
||||
Tile(child.label, child.icon, generalSize, "appHome.rowFor${child.type.name.lowercase().replaceFirstChar { it.uppercase() }}:" + child.name)
|
||||
{
|
||||
if (child.type == QAppNodeType.PROCESS) qNavigator?.navigateToProcess(child.name, child.label, processViewModel)
|
||||
else if (child.type == QAppNodeType.TABLE) qNavigator?.navigateToTable(child.name, child.label)
|
||||
else {
|
||||
notYetImplementedDialogTitle.value = child.label
|
||||
notYetImplementedDialogBody.value = "This type of screen is unavailable in this client."
|
||||
@@ -193,9 +194,7 @@ fun QAppHomeGridLayout(qInstance: QInstance, modifier: Modifier = Modifier, app:
|
||||
{
|
||||
Tile(table.label, table.icon, generalSize, "appHome.rowForTable:" + tableName)
|
||||
{
|
||||
notYetImplementedDialogTitle.value = table.label
|
||||
notYetImplementedDialogBody.value = "Tables are not yet supported in this application frontend."
|
||||
isNotYetImplementedDialogOpen.value = true
|
||||
qNavigator?.navigateToTable(table.name, table.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,15 @@ fun QApplicationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier, q
|
||||
}
|
||||
}
|
||||
|
||||
composable<TableRoute>() { entry ->
|
||||
val route: TableRoute = entry.toRoute()
|
||||
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.TableScreen(qViewModel, route.name)
|
||||
}
|
||||
composable<RecordRoute>() { entry ->
|
||||
val route: RecordRoute = entry.toRoute()
|
||||
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.RecordScreen(qViewModel, route.tableName, route.recordId, route.hasParent)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,3 +145,9 @@ data class AppRoute(val name: String)
|
||||
@Serializable
|
||||
data class ProcessRoute(val name: String, val appName: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class TableRoute(val name: String, val appName: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class RecordRoute(val tableName: String, val recordId: String, val appName: String? = null, val hasParent: Boolean = false)
|
||||
|
||||
@@ -128,7 +128,7 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro
|
||||
OutlinedButton(onClick = { processViewModel.loadRemoteOptions(field) }) { Text("Retry choices") }
|
||||
}
|
||||
semanticFieldHint(field)?.let { Text(it) }
|
||||
field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: plainText(it.content)) }
|
||||
field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: it.content?.let { c -> plainText(c) } ?: it.summary.orEmpty()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.mobileapp.ui.records
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kingsrook.qqq.frontend.android.core.model.RecordData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.RecordSearch
|
||||
import com.kingsrook.qqq.frontend.android.core.model.formatted
|
||||
import com.kingsrook.qqq.frontend.android.core.model.parentMatches
|
||||
import com.kingsrook.qqq.frontend.android.core.model.recordGroups
|
||||
import com.kingsrook.qqq.frontend.android.core.model.recordSearchFields
|
||||
import com.kingsrook.qqq.frontend.android.core.model.summary
|
||||
import com.kingsrook.qqq.frontend.android.core.model.textFields
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QRecordAssociation
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QTableMetaData
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalNavigationBackAction
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** Attachment state stays mounted even when another tab is selected. */
|
||||
data class RecordAttachmentUi(val photosVisible: Boolean, val captureVisible: Boolean, val closeCapture: () -> Unit, val available: (Boolean) -> Unit, val recordLabel: String)
|
||||
val LocalRecordAttachments = compositionLocalOf<@Composable (QViewModel, String, String, RecordAttachmentUi) -> Unit> { { _, _, _, _ -> } }
|
||||
|
||||
/** Save page, search, filter and scroll state in this navigation entry. */
|
||||
@Composable
|
||||
fun TableScreen(host: QViewModel, tableName: String) {
|
||||
var offset by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf(0) }
|
||||
var search by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf("") }
|
||||
var filterField by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf("") }
|
||||
var filterValue by rememberSaveable(tableName, host.sessionUUID) { mutableStateOf("") }
|
||||
var filterOpen by remember { mutableStateOf(false) }
|
||||
var reload by remember { mutableStateOf(0) }
|
||||
var table by remember(tableName, host.sessionUUID) { mutableStateOf<QTableMetaData?>(null) }
|
||||
var rows by remember { mutableStateOf<List<RecordData>>(emptyList()) }
|
||||
var count by remember { mutableStateOf(0L) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
val scroll = rememberLazyListState()
|
||||
LaunchedEffect(tableName, offset, search, filterField, filterValue, reload, host.sessionUUID) {
|
||||
loading = true; failed = false
|
||||
try {
|
||||
if (search.isNotEmpty()) delay(300)
|
||||
val metadata = table ?: host.qqqRepository.getTableMetaData(tableName).also { table = it }
|
||||
val predicate = RecordSearch(search, metadata.recordSearchFields(), if (filterField.isNotEmpty() && filterValue.isNotEmpty()) mapOf(filterField to JsonPrimitive(filterValue)) else emptyMap())
|
||||
count = host.qqqRepository.countRecords(tableName, predicate)
|
||||
if (offset > 0 && offset >= count) { offset = 0; return@LaunchedEffect }
|
||||
rows = host.qqqRepository.searchRecords(tableName, requireNotNull(metadata.primaryKeyField), predicate, offset)
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (exception: Exception) { failed = true; rows = emptyList() }
|
||||
finally { loading = false }
|
||||
}
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
OutlinedTextField(search, { search = it; offset = 0 }, label = { Text("Search records") }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("records.search"))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = { filterOpen = true }, enabled = table?.textFields()?.isNotEmpty() == true) { Text(if (filterValue.isEmpty()) "Filter" else "Filter applied") }
|
||||
if (search.isNotEmpty() || filterValue.isNotEmpty()) TextButton(onClick = { search = ""; filterField = ""; filterValue = ""; offset = 0 }) { Text("Clear") }
|
||||
TextButton(onClick = { reload++ }) { Text("Refresh") }
|
||||
}
|
||||
if (loading) Text("Loading records…")
|
||||
if (failed) Text("Records could not be loaded. Check your connection and access.")
|
||||
if (!loading && !failed && rows.isEmpty()) Text("No matching records.")
|
||||
if (loading || failed) Spacer(Modifier.weight(1f)) else LazyColumn(state = scroll, modifier = Modifier.weight(1f)) {
|
||||
if (!loading && !failed) items(rows, key = { requireNotNull(table?.primaryKeyField?.let(it::id)) }) { row ->
|
||||
val id = requireNotNull(table?.primaryKeyField?.let(row::id))
|
||||
RecordRow(row, table!!, "records.row:$id") { host.qNavigator.navigateToRecord(tableName, id) }
|
||||
}
|
||||
}
|
||||
if (!failed) Paging(offset, rows.size, count, 25, loading, { offset = it })
|
||||
}
|
||||
if (filterOpen && table != null) RecordFilterDialog(table!!, filterField, filterValue, { filterOpen = false }) { field, value ->
|
||||
filterField = field; filterValue = value; offset = 0; filterOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordFilterDialog(table: QTableMetaData, initialField: String, initialValue: String, close: () -> Unit, apply: (String, String) -> Unit) {
|
||||
val fields = table.textFields()
|
||||
var field by remember { mutableStateOf(fields.firstOrNull { it.name == initialField } ?: fields.first()) }
|
||||
var value by remember { mutableStateOf(initialValue) }
|
||||
var choose by remember { mutableStateOf(false) }
|
||||
AlertDialog(onDismissRequest = close, title = { Text("Filter records") }, text = {
|
||||
Column {
|
||||
Box {
|
||||
TextButton(onClick = { choose = true }) { Text("Field: ${field.label}") }
|
||||
DropdownMenu(choose, { choose = false }) { fields.forEach { item -> DropdownMenuItem(text = { Text(item.label) }, onClick = { field = item; choose = false }) } }
|
||||
}
|
||||
OutlinedTextField(value, { value = it }, label = { Text("Equals") }, singleLine = true, modifier = Modifier.testTag("records.filter.value"))
|
||||
}
|
||||
}, confirmButton = { TextButton(onClick = { apply(field.name, value) }) { Text("Apply filter") } }, dismissButton = { TextButton(onClick = close) { Text("Cancel") } })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Paging(offset: Int, size: Int, count: Long, pageSize: Int, loading: Boolean, change: (Int) -> Unit) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(if (loading) "Loading…" else if (count == 0L) "0 records" else "${offset + 1}–${offset + size} of $count", style = MaterialTheme.typography.labelLarge, modifier = Modifier.testTag("records.count"))
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(enabled = !loading && offset > 0, onClick = { change((offset - pageSize).coerceAtLeast(0)) }) { Text("Previous") }
|
||||
TextButton(enabled = !loading && offset + size < count, onClick = { change(offset + pageSize) }) { Text("Next") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordRow(record: RecordData, table: QTableMetaData, tag: String, open: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().testTag(tag).clickable(onClickLabel = "Open record", onClick = open).padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(record.recordLabel ?: table.primaryKeyField?.let(record::id).orEmpty(), style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(record.summary(table), style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text("›", Modifier.padding(start = 8.dp), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
/** Identity and Actions remain fixed above content. Record values never enter navigation telemetry. */
|
||||
@Composable
|
||||
fun RecordScreen(host: QViewModel, tableName: String, recordId: String, hasParent: Boolean = false) {
|
||||
// Register inside the destination, after NavHost's own callback, as process screens do.
|
||||
val hostBack = LocalNavigationBackAction.current
|
||||
BackHandler { if (hostBack != null) hostBack() else host.qNavigator.popStack() }
|
||||
var record by remember(tableName, recordId, host.sessionUUID) { mutableStateOf<RecordData?>(null) }
|
||||
var table by remember(tableName, recordId, host.sessionUUID) { mutableStateOf<QTableMetaData?>(null) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
var reload by remember { mutableStateOf(0) }
|
||||
var tab by rememberSaveable(tableName, recordId, host.sessionUUID) { mutableStateOf(0) }
|
||||
var group by rememberSaveable(tableName, recordId, host.sessionUUID) { mutableStateOf(0) }
|
||||
var actions by remember { mutableStateOf(false) }
|
||||
var sections by remember { mutableStateOf(false) }
|
||||
var capture by rememberSaveable(tableName, recordId, host.sessionUUID) { mutableStateOf(false) }
|
||||
var photoAvailable by remember(tableName, recordId, host.sessionUUID) { mutableStateOf(false) }
|
||||
val attachments = LocalRecordAttachments.current
|
||||
val tabState = rememberSaveableStateHolder()
|
||||
LaunchedEffect(tableName, recordId, host.sessionUUID, reload) {
|
||||
failed = false
|
||||
try {
|
||||
val metadata = host.qqqRepository.getTableMetaData(tableName)
|
||||
val loaded = host.qqqRepository.getRecord(tableName, recordId)
|
||||
check(metadata.primaryKeyField?.let(loaded::id) == recordId)
|
||||
check(loaded.tableName == null || loaded.tableName == tableName)
|
||||
table = metadata; record = loaded
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (exception: Exception) { failed = true }
|
||||
}
|
||||
val current = record
|
||||
if (failed) Column(Modifier.padding(16.dp)) {
|
||||
Text("This record could not be loaded. It may be unavailable or access may have changed.")
|
||||
Button(onClick = { reload++ }) { Text("Retry") }
|
||||
} else if (current == null) Text("Loading record…", Modifier.padding(16.dp))
|
||||
else {
|
||||
val metadata = table!!
|
||||
val related = metadata.associations.orEmpty()
|
||||
val groups = metadata.recordGroups()
|
||||
val photoTab = related.size + 1
|
||||
Column(Modifier.fillMaxSize().testTag("record.details")) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(current.recordLabel ?: recordId, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(metadata.label, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
Box {
|
||||
TextButton(onClick = { actions = true }) { Text("Actions") }
|
||||
DropdownMenu(actions, { actions = false }) {
|
||||
DropdownMenuItem(text = { Text("Add photo") }, enabled = photoAvailable, onClick = { actions = false; capture = true })
|
||||
DropdownMenuItem(text = { Text("Refresh record") }, onClick = { actions = false; reload++ })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasParent) TextButton(onClick = { host.qNavigator.requestLeave("Back to parent?") { host.qNavigator.navController?.popBackStack() } }) { Text("Back to parent record") }
|
||||
ScrollableTabRow(tab.coerceAtMost(photoTab), edgePadding = 0.dp) {
|
||||
(listOf("Details") + related.map { it.name } + "Photos").forEachIndexed { index, label ->
|
||||
Tab(tab == index, onClick = { tab = index }, text = { Text(label) })
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f)) {
|
||||
if (tab == 0) Column(Modifier.fillMaxSize()) {
|
||||
Box {
|
||||
TextButton(onClick = { sections = true }) { Text("Sections (${groups.size})") }
|
||||
DropdownMenu(sections, { sections = false }) { groups.forEachIndexed { index, value ->
|
||||
DropdownMenuItem(text = { Text(value.label) }, onClick = { group = index; sections = false })
|
||||
} }
|
||||
}
|
||||
Column(Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 16.dp)) {
|
||||
groups.forEachIndexed { index, value ->
|
||||
TextButton(onClick = { group = if (group == index) -1 else index }, modifier = Modifier.fillMaxWidth().testTag("record.section:${value.label}")) {
|
||||
Text("${if (group == index) "−" else "+"} ${value.label} (${value.fields.size})", Modifier.fillMaxWidth(), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
if (group == index) value.fields.forEach { field ->
|
||||
Column(Modifier.padding(vertical = 6.dp)) {
|
||||
Text(field.label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(current.formatted(field))
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tab in 1..related.size) tabState.SaveableStateProvider("related:$tab") { RelatedRecords(host, tableName, recordId, current, related[tab - 1]) }
|
||||
attachments(host, tableName, recordId, RecordAttachmentUi(tab == photoTab, capture, { capture = false }, { photoAvailable = it }, current.recordLabel ?: recordId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Child queries and counts always use the declared parent join. */
|
||||
@Composable
|
||||
private fun RelatedRecords(host: QViewModel, parentTable: String, parentId: String, parent: RecordData, association: QRecordAssociation) {
|
||||
var offset by rememberSaveable(parentTable, parentId, association.name, host.sessionUUID) { mutableStateOf(0) }
|
||||
var reload by remember { mutableStateOf(0) }
|
||||
var rows by remember { mutableStateOf<List<RecordData>>(emptyList()) }
|
||||
var table by remember { mutableStateOf<QTableMetaData?>(null) }
|
||||
var count by remember { mutableStateOf(0L) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
val scroll = rememberLazyListState()
|
||||
LaunchedEffect(parentTable, parentId, association, offset, reload, host.sessionUUID) {
|
||||
loading = true; failed = false
|
||||
try {
|
||||
val matches = requireNotNull(association.parentMatches(parentTable, parent))
|
||||
check(matches.isNotEmpty())
|
||||
val child = host.qqqRepository.getTableMetaData(association.associatedTableName)
|
||||
check(matches.keys.all { it in child.fields })
|
||||
table = child
|
||||
val predicate = RecordSearch(matches = matches)
|
||||
count = host.qqqRepository.countRecords(child.name, predicate)
|
||||
rows = host.qqqRepository.searchRecords(child.name, requireNotNull(child.primaryKeyField), predicate, offset, 10)
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (exception: Exception) { failed = true; rows = emptyList() }
|
||||
finally { loading = false }
|
||||
}
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp).testTag("record.related:${association.associatedTableName}")) {
|
||||
if (loading) Text("Loading related records…")
|
||||
if (failed) { Text("Related records are unavailable."); TextButton(onClick = { reload++ }) { Text("Retry") } }
|
||||
if (!loading && !failed && rows.isEmpty()) Text("No related records.")
|
||||
LazyColumn(state = scroll, modifier = Modifier.weight(1f)) {
|
||||
if (!loading && !failed) items(rows) { row ->
|
||||
val id = table?.primaryKeyField?.let(row::id)
|
||||
if (id != null) RecordRow(row, table!!, "record.related.row:${association.associatedTableName}:$id") { host.qNavigator.navigateToRecord(association.associatedTableName, id, true) }
|
||||
}
|
||||
}
|
||||
if (!failed) Paging(offset, rows.size, count, 10, loading, { offset = it })
|
||||
}
|
||||
}
|
||||
@@ -582,8 +582,23 @@ open class QViewModel(
|
||||
*******************************************************************************/
|
||||
fun logOut(): Job = performLogout()
|
||||
|
||||
/*******************************************************************************
|
||||
** Host session resources end in a bounded, best-effort step; core logout and local clearing never depend on it.
|
||||
*******************************************************************************/
|
||||
private suspend fun endHostSession() {
|
||||
try {
|
||||
kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() }
|
||||
} catch (exception: kotlinx.coroutines.CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) { /* Core logout and local clearing still run. */
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional provider material is captured only by the initiating coroutine, never by ViewModel state.
|
||||
** Ordinary logout ends host resources first. Provider logout runs first instead: it both returns the
|
||||
** transient hint and ends the QQQ session, and a host session end (such as the scanner device-session end,
|
||||
** which itself runs the core logout server-side) would otherwise remove that session before the hint is read.
|
||||
*******************************************************************************/
|
||||
private fun performLogout(providerLogout: (suspend () -> Unit)? = null): Job {
|
||||
logoutJob?.takeIf {
|
||||
@@ -606,16 +621,15 @@ open class QViewModel(
|
||||
var serverConfirmed = false
|
||||
try {
|
||||
settingsMutex.withLock { settingsRepository.setSessionUUID(""); settingsRepository.setSessionUserFullName("") }
|
||||
if (providerLogout == null) endHostSession()
|
||||
try {
|
||||
kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() }
|
||||
} catch (exception: kotlinx.coroutines.CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) { /* Core logout and local clearing still run. */
|
||||
serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) {
|
||||
if (providerLogout == null) qqqRepository.logout() else providerLogout()
|
||||
true
|
||||
} ?: false
|
||||
} finally {
|
||||
if (providerLogout != null) endHostSession()
|
||||
}
|
||||
serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) {
|
||||
if (providerLogout == null) qqqRepository.logout() else providerLogout()
|
||||
true
|
||||
} ?: false
|
||||
} catch (exception: kotlinx.coroutines.CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) { /* Local sign-out still completes when the network is unavailable. */
|
||||
@@ -672,7 +686,8 @@ open class QViewModel(
|
||||
binding.login.server == environment.baseUrl && isLogoutRequested
|
||||
|
||||
/*******************************************************************************
|
||||
** Local invalidation finishes before the browser sees the optional transient hint.
|
||||
** Local invalidation finishes before the browser sees the optional transient hint: the provider logout
|
||||
** request runs inside performLogout, whose completion clears local state before this returns.
|
||||
*******************************************************************************/
|
||||
suspend fun logOutWithProvider(generation: Long, binding: OAuth2LogoutBinding): NativeLogoutData? {
|
||||
if (!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.PREPARING) return null
|
||||
@@ -846,6 +861,7 @@ open class QViewModel(
|
||||
if (!isCurrentOAuth2Login(generation, binding)) return@launch
|
||||
sessionUUID = requireNotNull(newSession)
|
||||
qqqRepository.setSessionUUID(sessionUUID)
|
||||
response.userName?.let { doSetSessionUserFullName(it) }
|
||||
manageSessionLoadState = LoadState.Success(response)
|
||||
logInSuccessful()
|
||||
adopted = true
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package com.kingsrook.qqq.frontend.android.mobileapp.viewmodel
|
||||
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
|
||||
import com.kingsrook.qqq.frontend.android.core.model.Environment
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionRequest
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.NativeLogoutData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LoginBinding
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private const val SERVER = "https://example.invalid"
|
||||
private const val ISSUER = "https://idp.example.invalid"
|
||||
private const val REDIRECT = "io.qrun.test.oauth://callback/oauth2redirect"
|
||||
private const val STATE = "state-0123456789abcdef"
|
||||
private const val NONCE = "nonce-0123456789abcdef"
|
||||
private const val VERIFIER = "verifier-0123456789abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordering of host session end, provider logout and local clearing; identity adoption after browser login.
|
||||
*******************************************************************************/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class QViewModelLogoutTest {
|
||||
/*******************************************************************************
|
||||
** (a) Provider logout must read the hint before the host ends its device session (which ends the QQQ session).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun companyLogoutRequestsProviderMaterialBeforeHostSessionEnd() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
val repository = LogoutRepository()
|
||||
val model = signedInModel(repository)
|
||||
model.beforeLogout = { repository.calls += "beforeLogout" }
|
||||
val material = companyLogout(model)
|
||||
assertNotNull(material)
|
||||
assertEquals(listOf("logoutWithProvider", "beforeLogout"), repository.calls)
|
||||
assertEquals("Signed out.", model.logoutMessage)
|
||||
assertFalse(model.isAuthenticated)
|
||||
assertEquals("", model.sessionUUID)
|
||||
assertEquals("", model.sessionUserFullName)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** (b) A failing host session end after the provider logout neither loses the hint nor leaves local state.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun failingHostSessionEndAfterProviderLogoutKeepsMaterialAndClearsLocally() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
val repository = LogoutRepository()
|
||||
val model = signedInModel(repository)
|
||||
model.beforeLogout = { repository.calls += "beforeLogout"; throw IllegalStateException("401 Unauthorized") }
|
||||
val material = companyLogout(model)
|
||||
assertNotNull(material)
|
||||
assertEquals(listOf("logoutWithProvider", "beforeLogout"), repository.calls)
|
||||
assertEquals("Signed out.", model.logoutMessage)
|
||||
assertFalse(model.isAuthenticated)
|
||||
assertFalse(model.logoutInProgress)
|
||||
assertEquals("", model.sessionUUID)
|
||||
assertEquals("", repository.activeSession)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** (c) Ordinary sign-out keeps ending host resources before the core logout.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun ordinaryLogoutEndsHostSessionBeforeCoreLogout() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
val repository = LogoutRepository()
|
||||
val model = signedInModel(repository)
|
||||
model.beforeLogout = { repository.calls += "beforeLogout" }
|
||||
model.requestLogout()
|
||||
model.logOut().join()
|
||||
assertEquals(listOf("beforeLogout", "logout"), repository.calls)
|
||||
assertEquals("Signed out.", model.logoutMessage)
|
||||
assertFalse(model.isAuthenticated)
|
||||
assertEquals("", model.sessionUUID)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Browser login adopts the server-reported display name, as password login already does.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun browserLoginAdoptsServerReportedUserName() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
val repository = LogoutRepository()
|
||||
val model = signedInModel(repository)
|
||||
assertTrue(model.isAuthenticated)
|
||||
assertEquals("scanner-b", model.sessionUserFullName)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The user object is optional and unknown keys stay tolerated; blank or non-object users yield no name.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun manageSessionResponseExposesOptionalUserName() {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val full = json.decodeFromString<ManageSessionResponse>(
|
||||
"""{"uuid":"s","values":{"user":{"name":"scanner-b","email":"scanner-b@example.invalid","extra":1},"other":true},"unknown":"x"}"""
|
||||
)
|
||||
assertEquals("scanner-b", full.userName)
|
||||
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s"}""").userName)
|
||||
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":"scanner-b"}}""").userName)
|
||||
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete a native browser login against the fake repository so sign-out tests start authenticated.
|
||||
*******************************************************************************/
|
||||
private fun TestScope.signedInModel(repository: LogoutRepository): QViewModel {
|
||||
QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER))
|
||||
val model = QViewModel(repository, MockPreferencesDataStore())
|
||||
advanceUntilIdle()
|
||||
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
|
||||
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
|
||||
model.completeOAuth2Login(loginBinding, STATE, STATE, NONCE, "client", REDIRECT, "login", "code")
|
||||
advanceUntilIdle()
|
||||
assertTrue(model.isAuthenticated)
|
||||
assertEquals("session-1", model.sessionUUID)
|
||||
repository.calls.clear()
|
||||
return model
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the company sign-out preparation exactly as QOAuth2Logout does before opening the browser.
|
||||
*******************************************************************************/
|
||||
private suspend fun companyLogout(model: QViewModel): NativeLogoutData? {
|
||||
val binding = OAuth2LogoutBinding(loginBinding, REDIRECT)
|
||||
model.requestCompanyLogout(binding)
|
||||
assertEquals(OAuth2LogoutPhase.PREPARING, model.companyLogoutPhase)
|
||||
return model.logOutWithProvider(model.companyLogoutGeneration, binding)
|
||||
}
|
||||
|
||||
private val loginBinding = OAuth2LoginBinding(
|
||||
SERVER, "oauth", ISSUER, "$ISSUER/.well-known/openid-configuration", "client", "openid", REDIRECT, "login", "REQUIRED"
|
||||
)
|
||||
|
||||
/*******************************************************************************
|
||||
** Records the order of logout-related calls; any call after the session ended returns no provider material.
|
||||
*******************************************************************************/
|
||||
private class LogoutRepository : QQQRepository {
|
||||
val calls = mutableListOf<String>()
|
||||
var activeSession = ""
|
||||
|
||||
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData(
|
||||
"oauth", values = OAuth2AuthenticationMetaData.Values(
|
||||
clientId = "client", scopes = "openid",
|
||||
nativeSessionLogin = OAuth2AuthenticationMetaData.NativeSessionLogin(
|
||||
OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", ISSUER, "$ISSUER/.well-known/openid-configuration", listOf(REDIRECT), "login", "REQUIRED"
|
||||
),
|
||||
nativeSessionLogout = OAuth2AuthenticationMetaData.NativeSessionLogout(OAuth2AuthenticationMetaData.LOGOUT_FLOW, listOf(REDIRECT))
|
||||
)
|
||||
)
|
||||
|
||||
override suspend fun exchangeNativeSession(body: ManageSessionRequest): ManageSessionResponse =
|
||||
ManageSessionResponse("session-1", mapOf("user" to mapOf("name" to "scanner-b", "email" to "scanner-b@example.invalid")))
|
||||
|
||||
override suspend fun logout() {
|
||||
calls += "logout"
|
||||
activeSession = ""
|
||||
}
|
||||
|
||||
override suspend fun logoutWithProvider(postLogoutRedirectUri: String): NativeLogoutData? {
|
||||
calls += "logoutWithProvider"
|
||||
val material = if (activeSession.isBlank()) null else NativeLogoutData(ISSUER, "$ISSUER/end-session", postLogoutRedirectUri, "hint")
|
||||
activeSession = ""
|
||||
return material
|
||||
}
|
||||
|
||||
override fun setSessionUUID(sessionUUID: String) {
|
||||
activeSession = sessionUUID
|
||||
}
|
||||
|
||||
override fun setEnvironment(environment: Environment) {}
|
||||
override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Not used")
|
||||
override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance = QInstance()
|
||||
override suspend fun getProcessMetaData(processName: String): QProcessMetaData = error("Not used")
|
||||
override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
|
||||
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
|
||||
override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult = error("Not used")
|
||||
override fun getURI(path: String): String = error("Not used")
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertHasNoClickAction
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertTextContains
|
||||
@@ -74,6 +77,44 @@ class ScannerEndToEndTest {
|
||||
private val client = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build()
|
||||
private lateinit var origin: String
|
||||
private lateinit var adminCookie: String
|
||||
/*******************************************************************************
|
||||
** Explicitly prepare a disposable QA app for an operator's manual record/photo test.
|
||||
** Uses real sign-in and approval; does not alter the ordinary scanner application.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun prepareManualRecordPhotoPreview() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
check(context.packageName == "io.qrun.mobile.scanner.qa")
|
||||
val configFile = File(context.filesDir, "manual-photo-preview.json")
|
||||
val config = try { JSONObject(configFile.readText()) } finally { configFile.delete() }
|
||||
check(config.optBoolean("manualPhotoPreview"))
|
||||
origin = config.getString("serverUrl").trimEnd('/')
|
||||
val endpoint = URI(origin)
|
||||
check(endpoint.scheme == "http" && endpoint.host == "127.0.0.1" && endpoint.port in 1024..65535 &&
|
||||
endpoint.port !in setOf(8080, 57832, 51631, 18081, 18086) && endpoint.path in setOf("", "/") &&
|
||||
endpoint.userInfo == null && endpoint.query == null && endpoint.fragment == null)
|
||||
waitFor("QQQ server URL")
|
||||
ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/")
|
||||
ui.onNodeWithText("Connect", substring = false).performClick()
|
||||
waitFor("Username")
|
||||
ui.onNodeWithText("Username").performTextReplacement("scanner")
|
||||
ui.onNodeWithText("Password").performTextReplacement(config.getString("scannerPassword"))
|
||||
ui.onNodeWithText("Password").performImeAction()
|
||||
ui.onNode(hasText("Sign in") and hasClickAction()).performScrollTo().performClick()
|
||||
waitFor("An administrator must approve this device before you can run mobile apps.")
|
||||
val device = DeviceCredentials(context, "$origin/")
|
||||
adminCookie = loginAdmin(config.getString("adminPassword"))
|
||||
runAdmin("mobileDeviceApprove", device.deviceId)
|
||||
ui.onNodeWithText("Check approval").performScrollTo().performClick()
|
||||
waitForTag("appHome.rowForApp:mobileScanApp")
|
||||
ui.onNodeWithTag("appHome.rowForApp:mobileScanApp").performClick()
|
||||
waitForTag("appHome.rowForTable:document")
|
||||
ui.onNodeWithTag("appHome.rowForTable:document").performClick()
|
||||
waitForTag("records.row:1")
|
||||
ui.onNodeWithTag("records.row:1").performClick()
|
||||
waitFor("Take photo")
|
||||
stage("manual-photo-preview-ready")
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise real Echo process/PVS persistence with explicitly simulated print acceptance.
|
||||
@@ -263,11 +304,17 @@ class ScannerEndToEndTest {
|
||||
stage("echo-setup-selected")
|
||||
|
||||
var receipt = ""
|
||||
for ((index, sample) in listOf("001234567890" to "LABEL-TYPE-CODE128", "000-ECHO-QR" to "QRCODE").withIndex()) {
|
||||
val gs1Payload = "01095011015300031120101421TEST000123"
|
||||
for ((index, sample) in listOf("001234567890" to "LABEL-TYPE-CODE128", "000-ECHO-QR" to "QRCODE", gs1Payload to "EAN128").withIndex()) {
|
||||
waitFor("Scanner ready. Press the handheld trigger.")
|
||||
context.sendBroadcast(Intent("io.qrun.mobile.scanner.SCAN").setPackage(context.packageName)
|
||||
.addCategory(Intent.CATEGORY_DEFAULT).putExtra("com.symbol.datawedge.data_string", sample.first)
|
||||
.putExtra("com.symbol.datawedge.label_type", sample.second))
|
||||
.putExtra("com.symbol.datawedge.label_type", sample.second).apply {
|
||||
if (sample.second == "EAN128") {
|
||||
putExtra("io.qrun.mobile.scanner.e2e.raw_data", sample.first.toByteArray(Charsets.US_ASCII))
|
||||
putExtra("io.qrun.mobile.scanner.e2e.aim_identifier", "]C1")
|
||||
}
|
||||
})
|
||||
var accepted: JSONObject? = null
|
||||
waitForServer {
|
||||
accepted = rows("echoScan").singleOrNull { it.optString("runId") == runId && it.optString("barcode") == sample.first && it.optString("state") == "QUEUED" }
|
||||
@@ -278,24 +325,29 @@ class ScannerEndToEndTest {
|
||||
waitFor("Print queued $receipt", substring = true)
|
||||
assertScanDisplay(sample.first, sample.second, "Confirmed by server")
|
||||
assertTrue("Accepted Echo scans must preserve exact type and approved-device attribution",
|
||||
row.getString("symbology") == sample.second && row.getString("rendererSymbology") == (if (index == 0) "code128" else "qr") &&
|
||||
row.getString("symbology") == sample.second && row.getString("rendererSymbology") == listOf("code128", "qr", "gs1-128")[index] &&
|
||||
row.getString("scanSource") == "e2e-fixture" && row.getString("deviceId") == device.deviceId && row.getString("actorId") == "scanner")
|
||||
assertTrue("Only a synthetic accepted job may acknowledge this fixture scan",
|
||||
row.getString("id") == receipt && row.getString("printJobId") == "fixture-job-$receipt" && row.getString("eventId").isNotBlank())
|
||||
assertTrue("Each event must create exactly one accepted Echo scan", rows("echoScan").count { it.optString("runId") == runId } == index + 1)
|
||||
assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId })
|
||||
stage(if (index == 0) "echo-first-queued" else "echo-second-queued")
|
||||
if (index == 2) {
|
||||
assertEquals("]C1", row.getString("aimIdentifier"))
|
||||
assertEquals("[01]09501101530003[11]201014[21]TEST000123", row.getString("rendererValue"))
|
||||
assertEquals("(01)09501101530003(11)201014(21)TEST000123", row.getString("humanReadable"))
|
||||
}
|
||||
stage(listOf("echo-first-queued", "echo-second-queued", "echo-gs1-queued")[index])
|
||||
}
|
||||
|
||||
ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) }
|
||||
waitFor("Print queued $receipt", substring = true)
|
||||
ui.activityRule.scenario.recreate()
|
||||
waitFor("Print queued $receipt", substring = true)
|
||||
assertScanDisplay("000-ECHO-QR", "QRCODE", "Confirmed by server")
|
||||
assertScanDisplay(gs1Payload, "EAN128", "Confirmed by server")
|
||||
assertTrue("Recreation must retain scan phase without showing printer selection", ui.onAllNodesWithText("Choose a value").fetchSemanticsNodes().isEmpty())
|
||||
val acceptedRows = rows("echoScan").filter { it.optString("runId") == runId }
|
||||
assertTrue("Rotation/recreation must not accept another event", acceptedRows.size == 2 && acceptedRows.map { it.getString("eventId") }.toSet().size == 2 &&
|
||||
acceptedRows.map { it.getString("printJobId") }.toSet().size == 2)
|
||||
assertTrue("Rotation/recreation must not accept another event", acceptedRows.size == 3 && acceptedRows.map { it.getString("eventId") }.toSet().size == 3 &&
|
||||
acceptedRows.map { it.getString("printJobId") }.toSet().size == 3)
|
||||
assertSetupPinned(rows("echoFixtureRun").single { it.getString("id") == runId })
|
||||
stage("echo-copies-persisted")
|
||||
stage("echo-recreation-stable")
|
||||
@@ -305,9 +357,17 @@ class ScannerEndToEndTest {
|
||||
ui.onNodeWithText("Leave process", substring = false).performClick()
|
||||
waitForTag("appHome.rowForApp:echo")
|
||||
waitForServer { rows("echoFixtureRun").single { it.getString("id") == runId }.getString("state") == "CLOSED" }
|
||||
assertTrue("Leaving must close this run without accepting extra print work", rows("echoScan").count { it.optString("runId") == runId } == 2)
|
||||
assertTrue("Leaving must close this run without accepting extra print work", rows("echoScan").count { it.optString("runId") == runId } == 3)
|
||||
assertCopiesPinned(rows("echoFixtureRun").single { it.getString("id") == runId })
|
||||
stage("echo-run-closed")
|
||||
ui.onNodeWithTag("appHome.rowForApp:echo").performClick()
|
||||
waitForTag("appHome.rowForTable:echoScan")
|
||||
ui.onNodeWithTag("appHome.rowForTable:echoScan").performClick()
|
||||
waitForTag("records.row:$receipt")
|
||||
ui.onNodeWithTag("records.row:$receipt").performClick()
|
||||
waitForTag("record.details")
|
||||
ui.onNodeWithText("(01)09501101530003(11)201014(21)TEST000123").performScrollTo().assertIsDisplayed()
|
||||
stage("echo-record-browsed")
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -315,6 +375,25 @@ class ScannerEndToEndTest {
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout() {
|
||||
exercisePhotoCapture(false)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record browsing selects the parent, and the same upload/lifecycle contract still applies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun recordDetailsCameraAndGalleryAttachToSelectedRecord() {
|
||||
exercisePhotoCapture(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun richRecordsPageGroupNavigateAndAttachPhotos() {
|
||||
exercisePhotoCapture(fromRecord = true, rich = true)
|
||||
}
|
||||
|
||||
private fun exercisePhotoCapture(fromRecord: Boolean, rich: Boolean = false) {
|
||||
val parentTable = if (rich) "testOrder" else "document"
|
||||
val parentId = if (rich) "26" else "1"
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
check(context.packageName == "io.qrun.mobile.scanner.e2e")
|
||||
@@ -322,6 +401,14 @@ class ScannerEndToEndTest {
|
||||
val config = JSONObject(configFile.readText())
|
||||
configFile.delete()
|
||||
origin = config.getString("serverUrl").trimEnd('/')
|
||||
// Explicit fixture expectations, not production host classification: the rich-records scenario (rich=true) runs
|
||||
// against the configured EchoDemo host, which admits an approved device's captures immediately
|
||||
// (Record Files quarantineCapturedFiles=false) and so expects AVAILABLE. The generic document/process photo
|
||||
// fixtures (rich=false) expect QUARANTINED.
|
||||
val echoHost = rich
|
||||
val savedMessage = if (echoHost) "Saved photo." else "Saved photo. Awaiting server review."
|
||||
val expectedLifecycle = if (echoHost) "AVAILABLE" else "QUARANTINED"
|
||||
val expectedBadge = if (echoHost) "Available" else "Awaiting review"
|
||||
val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val bytes = ByteArrayOutputStream().use { output -> bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output); output.toByteArray() }
|
||||
bitmap.recycle()
|
||||
@@ -364,34 +451,139 @@ class ScannerEndToEndTest {
|
||||
adminCookie = loginAdmin(config.getString("adminPassword"))
|
||||
runAdmin("mobileDeviceApprove", device.deviceId)
|
||||
ui.onNodeWithText("Check approval").performScrollTo().performClick()
|
||||
waitForTag("appHome.rowForApp:mobileScanApp")
|
||||
ui.onNodeWithTag("appHome.rowForApp:mobileScanApp").performClick()
|
||||
waitForTag("appHome.rowForProcess:mobileRecordPhotos")
|
||||
ui.onNodeWithTag("appHome.rowForProcess:mobileRecordPhotos").performClick()
|
||||
val app = if (rich) "mobileTestData" else "mobileScanApp"
|
||||
waitForTag("appHome.rowForApp:$app")
|
||||
// Accessible activation avoids a system floating control covering a tile on physical devices.
|
||||
ui.onNodeWithTag("appHome.rowForApp:$app").performScrollTo().assertIsDisplayed()
|
||||
.performSemanticsAction(SemanticsActions.OnClick) { it() }
|
||||
if (fromRecord) {
|
||||
waitForTag("appHome.rowForTable:$parentTable")
|
||||
ui.onNodeWithTag("appHome.rowForTable:$parentTable").performScrollTo().assertIsDisplayed()
|
||||
.performSemanticsAction(SemanticsActions.OnClick) { it() }
|
||||
waitForTag("records.row:1")
|
||||
if (rich) {
|
||||
ui.onNodeWithText("Next").performClick()
|
||||
waitForTag("records.row:26")
|
||||
ui.onNodeWithTag("records.row:1").assertDoesNotExist()
|
||||
}
|
||||
ui.onNodeWithTag("records.row:$parentId").performClick()
|
||||
waitForTag("record.details")
|
||||
if (rich) {
|
||||
ui.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
|
||||
waitForTag("records.row:26")
|
||||
ui.onNodeWithText("26–50 of 1000").assertIsDisplayed()
|
||||
ui.onNodeWithTag("records.row:1").assertDoesNotExist()
|
||||
ui.onNodeWithTag("records.search").performTextReplacement("TEST-000026")
|
||||
ui.waitForIdle()
|
||||
waitFor("1–1 of 1")
|
||||
ui.onNodeWithTag("records.row:26").performClick()
|
||||
waitForTag("record.details")
|
||||
ui.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
|
||||
waitFor("1–1 of 1")
|
||||
ui.onNodeWithTag("records.search").assertTextContains("TEST-000026")
|
||||
ui.onNodeWithText("Clear", substring = false).performClick()
|
||||
waitFor("1–25 of 1000")
|
||||
ui.onNodeWithText("Filter", substring = false).performClick()
|
||||
waitFor("Filter records")
|
||||
ui.onNodeWithTag("records.filter.value").performTextReplacement("TEST-000026")
|
||||
ui.onNodeWithText("Apply filter").performClick()
|
||||
waitFor("1–1 of 1")
|
||||
ui.onNodeWithTag("records.row:26").performClick()
|
||||
waitForTag("record.details")
|
||||
listOf("Order overview", "Customer", "Shipping", "Amounts", "Warehouse", "Notes and audit").forEach {
|
||||
ui.onNodeWithText("Sections (6)").performClick()
|
||||
ui.onNodeWithText(it, substring = false).performClick()
|
||||
ui.onNodeWithTag("record.section:$it").performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
ui.onNodeWithText("Items", substring = false).performClick()
|
||||
waitForTag("record.related.row:testOrderItem:301")
|
||||
ui.onNodeWithText("1–10 of 12").assertIsDisplayed()
|
||||
ui.onNodeWithTag("record.related.row:testOrderItem:1").assertDoesNotExist()
|
||||
ui.onNodeWithText("Next", substring = false).performClick()
|
||||
waitForTag("record.related.row:testOrderItem:311")
|
||||
ui.onNodeWithTag("record.related.row:testOrderItem:311").performClick()
|
||||
waitForTag("record.section:Pricing and handling")
|
||||
ui.onNodeWithText("Back to parent record").performClick()
|
||||
waitForTag("record.related.row:testOrderItem:311")
|
||||
ui.onNodeWithText("History", substring = false).performClick()
|
||||
waitForTag("record.related.row:testOrderEvent:76")
|
||||
ui.onNodeWithText("1–3 of 3").assertIsDisplayed()
|
||||
stage("rich-records-grouped-related-paged")
|
||||
stage("rich-records-search-filter-back-preserved")
|
||||
}
|
||||
ui.onNodeWithText("Take photo").assertDoesNotExist()
|
||||
ui.onNodeWithText("Actions", substring = false).performClick()
|
||||
waitFor("Add photo")
|
||||
ui.onNodeWithText("Add photo", substring = false).performClick()
|
||||
} else {
|
||||
waitForTag("appHome.rowForProcess:mobileRecordPhotos")
|
||||
ui.onNodeWithTag("appHome.rowForProcess:mobileRecordPhotos").performClick()
|
||||
}
|
||||
waitFor("Take photo")
|
||||
ui.onNodeWithText("Take photo").performScrollTo().performClick()
|
||||
waitFor("Saved photo. Awaiting server review.")
|
||||
waitFor(savedMessage)
|
||||
if (echoHost) ui.onNodeWithText("Saved photo. Awaiting server review.").assertDoesNotExist()
|
||||
assertEquals("Camera result must automatically upload without a second tap", 1, cameraCalls.get())
|
||||
assertTrue(directory.listFiles().orEmpty().none { it.name.startsWith("photo-") })
|
||||
val savedRows = jsonRequest("/qqq/v1/table/record_files_file/query", JSONObject()).getJSONArray("records")
|
||||
val savedFile = (0 until savedRows.length()).map { savedRows.getJSONObject(it).getJSONObject("values") }
|
||||
.single { it.optString("deviceId") == device.deviceId && it.optString("sha256") == RecordFilesApi.sha256(bytes) }
|
||||
assertEquals("document", savedFile.getString("tableName"))
|
||||
assertEquals("1", savedFile.getString("recordId"))
|
||||
assertEquals(parentTable, savedFile.getString("tableName"))
|
||||
assertEquals(parentId, savedFile.getString("recordId"))
|
||||
assertEquals("CAPTURE", savedFile.getString("origin"))
|
||||
assertTrue(savedFile.getString("originDetail").endsWith("source CAMERA"))
|
||||
assertEquals("QUARANTINED", savedFile.getString("lifecycle"))
|
||||
assertEquals(expectedLifecycle, savedFile.getString("lifecycle"))
|
||||
assertEquals(bytes.size.toLong(), savedFile.getLong("sizeBytes"))
|
||||
assertTrue(savedFile.getLong("attachmentId") > 0)
|
||||
val associations = jsonRequest("/qqq/v1/table/record_files_collection_item/query", JSONObject()).getJSONArray("records")
|
||||
// Collection storage is one-based; the upload manifest/receipt positions are zero-based.
|
||||
assertTrue((0 until associations.length()).map { associations.getJSONObject(it).getJSONObject("values") }
|
||||
.any { it.optString("fileId") == savedFile.getString("id") && it.optString("tableName") == "document" && it.optString("recordId") == "1" && it.optInt("position") == 1 })
|
||||
.any { it.optString("fileId") == savedFile.getString("id") && it.optString("tableName") == parentTable && it.optString("recordId") == parentId && it.optInt("position") == 1 })
|
||||
stage("native-photo-camera-saved")
|
||||
|
||||
ui.onNodeWithText("Choose photo").performScrollTo().performClick()
|
||||
waitFor("Photo ready to upload.")
|
||||
assertEquals(1, galleryCalls.get())
|
||||
ui.onNodeWithText("Upload photo").performScrollTo().performClick()
|
||||
waitFor(savedMessage)
|
||||
val galleryRows = jsonRequest("/qqq/v1/table/record_files_file/query", JSONObject()).getJSONArray("records")
|
||||
val gallerySaved = (0 until galleryRows.length()).map { galleryRows.getJSONObject(it).getJSONObject("values") }
|
||||
.single { it.optString("deviceId") == device.deviceId && it.optString("originDetail").endsWith("source GALLERY") }
|
||||
assertEquals(parentTable, gallerySaved.getString("tableName"))
|
||||
assertEquals(parentId, gallerySaved.getString("recordId"))
|
||||
assertEquals(RecordFilesApi.sha256(bytes), gallerySaved.getString("sha256"))
|
||||
assertEquals(expectedLifecycle, gallerySaved.getString("lifecycle"))
|
||||
assertTrue(gallerySaved.getLong("attachmentId") > 0)
|
||||
stage("native-photo-gallery-saved")
|
||||
if (fromRecord) {
|
||||
ui.onNodeWithText("Done", substring = false).performClick()
|
||||
ui.onNodeWithText("Photos", substring = false).performClick()
|
||||
waitFor(if (rich) "3 photos" else "2 photos")
|
||||
if (rich) {
|
||||
ui.onNodeWithText("Preview example 27.jpg").assertDoesNotExist()
|
||||
// Seeded, camera and gallery photos are all AVAILABLE, so scope thumbnail and Preview to the newly captured camera row.
|
||||
val cameraRow = "record.photo:${savedFile.getString("id")}"
|
||||
ui.onNodeWithTag("record.photos.list").performScrollToNode(hasTestTag(cameraRow))
|
||||
val cameraThumbnail = hasTestTag("photo.image:256") and hasAnyAncestor(hasTestTag(cameraRow))
|
||||
ui.waitUntil(timeoutMillis = 60_000) { runCatching { ui.onNode(cameraThumbnail).fetchSemanticsNode() }.isSuccess }
|
||||
ui.onNode(hasText("Preview photo") and hasAnyAncestor(hasTestTag(cameraRow))).performClick()
|
||||
waitFor("Photo preview")
|
||||
waitForTag("photo.image:1600")
|
||||
ui.onNodeWithText("Close preview").performClick()
|
||||
stage("native-photo-approved-preview")
|
||||
} else ui.onNodeWithText("Preview photo").assertDoesNotExist()
|
||||
listOf(savedFile, gallerySaved).forEach { file ->
|
||||
val row = "record.photo:${file.getString("id")}"
|
||||
ui.onNodeWithTag("record.photos.list").performScrollToNode(hasTestTag(row))
|
||||
ui.onNode(hasText(expectedBadge) and hasAnyAncestor(hasTestTag(row))).assertIsDisplayed()
|
||||
}
|
||||
stage("native-photo-gallery-parent-scoped")
|
||||
ui.onNodeWithText("Actions", substring = false).performClick()
|
||||
ui.onNodeWithText("Add photo", substring = false).performClick()
|
||||
}
|
||||
ui.onNodeWithText("Choose photo").performScrollTo().performClick()
|
||||
waitFor("Photo ready to upload.")
|
||||
assertEquals(2, galleryCalls.get())
|
||||
val pending = directory.listFiles().orEmpty().single { it.name.startsWith("photo-") }
|
||||
assertTrue(bytes.contentEquals(pending.readBytes()))
|
||||
ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) }
|
||||
@@ -399,10 +591,18 @@ class ScannerEndToEndTest {
|
||||
ui.activityRule.scenario.recreate()
|
||||
waitFor("Photo ready to upload.")
|
||||
assertTrue("Rotation retains the same private original", pending.exists())
|
||||
ui.onNodeWithTag("topBar.menuIcon").performClick()
|
||||
ui.onNodeWithTag("drawer.home").performClick()
|
||||
ui.onNodeWithText("Leave process", substring = false).assertIsNotEnabled()
|
||||
ui.onNodeWithText("Stay").performClick()
|
||||
if (fromRecord) {
|
||||
ui.onNodeWithText("Done", substring = false).assertIsNotEnabled()
|
||||
ui.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
|
||||
waitFor("Photo in progress")
|
||||
ui.onNodeWithText("Return to photo").performClick()
|
||||
waitFor("Photo ready to upload.")
|
||||
} else {
|
||||
ui.onNodeWithTag("topBar.menuIcon").performClick()
|
||||
ui.onNodeWithTag("drawer.home").performClick()
|
||||
ui.onNodeWithText("Leave process", substring = false).assertIsNotEnabled()
|
||||
ui.onNodeWithText("Stay").performClick()
|
||||
}
|
||||
stage("native-photo-rotation-preserved")
|
||||
|
||||
var sessionId = ""
|
||||
|
||||
@@ -24,7 +24,7 @@ internal class SessionAcceptanceClient(
|
||||
*******************************************************************************/
|
||||
fun json(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject = execute(
|
||||
Request.Builder().url("$origin$path")
|
||||
.header("Cookie", cookie).post(body.toString().toRequestBody("application/json".toMediaType())).build(), expectedStatus
|
||||
.header("Cookie", cookie).header("Origin", origin).post(body.toString().toRequestBody("application/json".toMediaType())).build(), expectedStatus
|
||||
)
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -32,7 +32,7 @@ internal class SessionAcceptanceClient(
|
||||
*******************************************************************************/
|
||||
fun process(name: String, suffix: String, values: JSONObject): JSONObject {
|
||||
var result = execute(
|
||||
Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", cookie)
|
||||
Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", cookie).header("Origin", origin)
|
||||
.post(
|
||||
MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString())
|
||||
.addFormDataPart("stepTimeoutMillis", "10000").build()
|
||||
@@ -77,7 +77,7 @@ internal class SessionAcceptanceClient(
|
||||
*******************************************************************************/
|
||||
fun logout() {
|
||||
client.newCall(
|
||||
Request.Builder().url("$origin/qqq/v1/logout").header("Cookie", cookie)
|
||||
Request.Builder().url("$origin/qqq/v1/logout").header("Cookie", cookie).header("Origin", origin)
|
||||
.post("{}".toRequestBody("application/json".toMediaType())).build()
|
||||
).execute().use {
|
||||
assertTrue("Fixture admin logout must succeed", it.isSuccessful)
|
||||
|
||||
@@ -29,7 +29,7 @@ internal object AimOutputProfile {
|
||||
|
||||
// AIM symbology character each known EMDK label must report; the modifier character is not checked here.
|
||||
private val FAMILY = mapOf(
|
||||
"CODE128" to 'C', "CODE39" to 'A', "CODE93" to 'G', "I2OF5" to 'I', "QRCODE" to 'Q', "DATAMATRIX" to 'd',
|
||||
"CODE128" to 'C', "EAN128" to 'C', "GS1_128" to 'C', "GS1-128" to 'C', "CODE39" to 'A', "CODE93" to 'G', "I2OF5" to 'I', "QRCODE" to 'Q', "DATAMATRIX" to 'd',
|
||||
"PDF417" to 'L', "AZTEC" to 'z', "EAN13" to 'E', "EAN8" to 'E', "UPCA" to 'E', "UPCE0" to 'E', "UPCE1" to 'E'
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -45,8 +44,6 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import coil3.ImageLoader
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.svg.SvgDecoder
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -63,7 +60,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
|
||||
var brand by remember(server) { mutableStateOf(HostBrand()) }
|
||||
var logo by remember(server) { mutableStateOf<ByteArray?>(null) }
|
||||
var connection by remember(server) { mutableStateOf("Checking") }
|
||||
var checkedAt by remember(server) { mutableStateOf("not checked") }
|
||||
var showDiagnostics by remember(server) { mutableStateOf(false) }
|
||||
val parentInputAllowed = LocalProcessInputAllowed.current
|
||||
val context = LocalContext.current
|
||||
@@ -114,7 +110,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
|
||||
connection = "Checking"
|
||||
while (isActive) {
|
||||
connection = if (runtime.connected()) "Connected" else "Disconnected"
|
||||
checkedAt = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
|
||||
withTimeoutOrNull(5000) { changed.receive() }
|
||||
}
|
||||
} finally {
|
||||
@@ -139,6 +134,7 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
|
||||
{
|
||||
logo?.let { AsyncImage(model = it, imageLoader = imageLoader, contentDescription = "${brand.displayName} logo", modifier = Modifier.size(40.dp)) }
|
||||
Text(brand.displayName, Modifier.weight(1f), style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (compatible) TextButton(onClick = { showDiagnostics = true }) { Text(connection, style = MaterialTheme.typography.labelMedium) }
|
||||
if (!compatible) TextButton(onClick = onChangeServer) { Text("Change server") }
|
||||
}
|
||||
Box(Modifier.weight(1f))
|
||||
@@ -153,11 +149,6 @@ internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Co
|
||||
if (compatibilityError != null) Button(onClick = { attempt++ }) { Text("Retry compatibility check") }
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = { showDiagnostics = true }, modifier = Modifier.fillMaxWidth())
|
||||
{
|
||||
Text("$connection · $checkedAt · Diagnostics", color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showDiagnostics) DiagnosticScreen(runtime.diagnostics, onBack = { showDiagnostics = false })
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterce
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException
|
||||
import android.os.Build
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
@@ -52,7 +53,15 @@ internal class MobileSessionApi(
|
||||
private val headers: () -> Map<String, String>,
|
||||
private val compatibility: UiApiCompatibility,
|
||||
onOutcome: (RequestOutcome) -> Unit = {},
|
||||
private val deviceInfo: Map<String, String?> = installedVersions(),
|
||||
) : MobileSessionTransport {
|
||||
companion object {
|
||||
/*******************************************************************************
|
||||
** The same software versions enrollment reported, so an in-place update refreshes the device record and later scan receipts.
|
||||
*******************************************************************************/
|
||||
fun installedVersions(): Map<String, String?> = mapOf("osVersion" to Build.VERSION.RELEASE, "appVersion" to BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
|
||||
@@ -65,6 +74,8 @@ internal class MobileSessionApi(
|
||||
val json = JSONObject().put("foreground", state.foreground).put("screenAvailable", state.available)
|
||||
.put("locationStatus", locationStatus)
|
||||
if (state.captureMode != CaptureMode.APP_CONTENT) json.put("captureMode", state.captureMode.name)
|
||||
// Omitted versions leave the enrolled values unchanged on the server (mobile-scanners 1.6); blank ones are never sent.
|
||||
deviceInfo.forEach { (name, value) -> if (!value.isNullOrBlank()) json.put(name, value.take(200)) }
|
||||
state.location.fix?.takeIf { locationAvailable }?.let { fix ->
|
||||
json.put(
|
||||
"location", JSONObject().put("latitude", fix.latitude).put("longitude", fix.longitude)
|
||||
|
||||
@@ -246,7 +246,7 @@ internal class RecordFilesApi(
|
||||
/*******************************************************************************
|
||||
** Cancellation closes transport; bounded JSON parsing cannot allocate an arbitrary server body.
|
||||
*******************************************************************************/
|
||||
private suspend fun execute(session: String?, builder: Request.Builder): JSONObject = withContext(Dispatchers.IO)
|
||||
private suspend fun send(session: String?, builder: Request.Builder): Response = withContext(Dispatchers.IO)
|
||||
{
|
||||
require(session?.isNotBlank() != false && compatibility.server == server)
|
||||
val extension = compatibility.verify().extensions.singleOrNull { it.name == "record-files" }
|
||||
@@ -276,7 +276,11 @@ internal class RecordFilesApi(
|
||||
}
|
||||
})
|
||||
}
|
||||
response.use {
|
||||
response
|
||||
}
|
||||
|
||||
private suspend fun execute(session: String?, builder: Request.Builder): JSONObject = withContext(Dispatchers.IO) {
|
||||
send(session, builder).use {
|
||||
if (!it.isSuccessful) throw UiApiException.from(it)
|
||||
require(it.code == 200) { "Unexpected upload response status." }
|
||||
val source = it.body?.source() ?: error("Missing upload response.")
|
||||
@@ -286,6 +290,24 @@ internal class RecordFilesApi(
|
||||
}
|
||||
}
|
||||
|
||||
/** Tracked owner delivery rechecks the parent and lifecycle. Never follow presigned or external URLs. */
|
||||
suspend fun image(session: String, file: SavedRecordPhoto): ByteArray = withContext(Dispatchers.IO) {
|
||||
require(file.lifecycle == "AVAILABLE" && file.mediaType in setOf("image/jpeg", "image/png", "image/webp"))
|
||||
require(file.id.toLong() > 0 && file.size in 1..20L * 1024 * 1024)
|
||||
val builder = Request.Builder().url(endpoint("/files/" + file.id + "/downloads/" + UUID.randomUUID()))
|
||||
.header("X-Record-Files-Download", "1").get()
|
||||
send(session, builder).use { response ->
|
||||
if (!response.isSuccessful) throw UiApiException.from(response)
|
||||
require(response.code == 200)
|
||||
val source = response.body?.source() ?: error("Missing photo")
|
||||
source.request(file.size + 1)
|
||||
require(source.buffer.size == file.size) { "Invalid photo length" }
|
||||
val bytes = source.readByteArray()
|
||||
require(sha256(bytes) == file.sha256) { "Invalid photo checksum" }
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep record-file boundary validation and manifest utilities alongside their transport data contracts.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -7,6 +7,10 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.records.RecordAttachmentUi
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -42,16 +46,29 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
|
||||
Text("This photo control has unsupported metadata or no active process.", modifier.padding(16.dp))
|
||||
return
|
||||
}
|
||||
RecordPhotoCapture(host, target, api, modifier, disabled, parameters.widgetBlock.blockId, process)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record details and process widgets share the same capability-gated upload implementation.
|
||||
*******************************************************************************/
|
||||
@Composable
|
||||
internal fun RecordPhotoCapture(
|
||||
host: com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel,
|
||||
target: RecordPhotoTarget, api: RecordFilesApi, modifier: Modifier = Modifier, disabled: Boolean = false,
|
||||
blockId: String = "record-photos",
|
||||
process: com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessViewModel? = null,
|
||||
recordUi: RecordAttachmentUi? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val application = context.applicationContext
|
||||
val session = host.sessionUUID
|
||||
val processId = process.processUUID ?: return
|
||||
val processId = if (process == null) "record" else process.processUUID ?: return
|
||||
val processOwner = LocalViewModelStoreOwner.current as? NavBackStackEntry
|
||||
if (processOwner == null) {
|
||||
Text("Photo capture requires a process navigation scope.", modifier.padding(16.dp))
|
||||
Text("Photo capture requires a record or process navigation scope.", modifier.padding(16.dp))
|
||||
return
|
||||
}
|
||||
val blockId = parameters.widgetBlock.blockId
|
||||
val key = "photo:$session:$processId:$blockId:${target.tableName.length}:${target.tableName}:${target.recordId}"
|
||||
val factory = remember(key, api)
|
||||
{
|
||||
@@ -63,14 +80,18 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return PhotoUploadViewModel(api, session, target, File(application.cacheDir, "record-photos")) {
|
||||
host.sessionUUID == session && host.isAuthenticated && !host.isLogoutRequested && host.activeProcessViewModel === process
|
||||
&& processOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)
|
||||
} as T
|
||||
}
|
||||
}
|
||||
}
|
||||
val upload: PhotoUploadViewModel = viewModel(viewModelStoreOwner = processOwner, key = key, factory = factory)
|
||||
val owner = LocalLifecycleOwner.current
|
||||
fun setWork(message: String?) {
|
||||
if (process != null) process.setClientWork(key, message) else host.qNavigator.recordWorkBlock = message
|
||||
}
|
||||
SideEffect {
|
||||
process.setClientWork(key, if (upload.pending || upload.busy) "Finish, retry/check, or discard the pending photo before leaving or continuing." else null)
|
||||
setWork(if (upload.pending || upload.busy) "Finish, retry/check, or discard the pending photo before leaving or continuing." else null)
|
||||
}
|
||||
DisposableEffect(owner, upload)
|
||||
{
|
||||
@@ -79,7 +100,9 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
|
||||
onDispose {
|
||||
owner.lifecycle.removeObserver(observer)
|
||||
upload.pause()
|
||||
if (!upload.pending) process.setClientWork(key, null)
|
||||
// A disposed configuration must not clear the retained upload's navigation guard.
|
||||
// Authentication teardown clears record work separately in the host.
|
||||
if (!upload.pending) setWork(null)
|
||||
}
|
||||
}
|
||||
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> upload.cameraResult(success) }
|
||||
@@ -89,9 +112,13 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
|
||||
upload.galleryResult(media) { application.contentResolver.openInputStream(uri) }
|
||||
}
|
||||
}
|
||||
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||
SideEffect { recordUi?.available?.invoke(upload.limits != null) }
|
||||
if (recordUi?.photosVisible == true) SavedRecordPhotos(host, target, api, upload.saved)
|
||||
val controls: @Composable () -> Unit = {
|
||||
Column((if (recordUi == null) modifier else modifier.verticalScroll(rememberScrollState())).padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
Text(target.title ?: "Record photos", style = MaterialTheme.typography.titleMedium)
|
||||
if (recordUi == null) Text(target.title ?: "Record photos", style = MaterialTheme.typography.titleMedium)
|
||||
if (recordUi != null) Text(recordUi.recordLabel, style = MaterialTheme.typography.labelLarge)
|
||||
target.instructions?.let { Text(it) }
|
||||
when {
|
||||
upload.checking -> Text("Checking photo permissions…")
|
||||
@@ -105,7 +132,7 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
|
||||
if ("image/jpeg" in upload.limits!!.mediaTypes)
|
||||
Button(enabled = available, onClick = {
|
||||
val file = upload.prepareCamera()
|
||||
process.setClientWork(key, "Return from the camera and finish or discard the photo before leaving.")
|
||||
setWork("Return from the camera and finish or discard the photo before leaving.")
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".photos", file)
|
||||
try {
|
||||
camera.launch(uri)
|
||||
@@ -123,6 +150,13 @@ internal fun RecordPhotoWidget(parameters: WidgetBlockParameters, api: RecordFil
|
||||
}
|
||||
if (upload.busy) Text("Working on photo…")
|
||||
if (upload.message.isNotBlank()) Text(upload.message)
|
||||
if (upload.pending) Text("This app uploads while open and connected. Keep this process open until the server confirms the photo.")
|
||||
if (upload.pending) Text("This app uploads while open and connected. Keep this screen open until the server confirms the photo.")
|
||||
}
|
||||
}
|
||||
if (recordUi == null) controls()
|
||||
else if (recordUi.captureVisible) AlertDialog(
|
||||
onDismissRequest = { if (!upload.pending && !upload.busy) recordUi.closeCapture() },
|
||||
title = { Text("Add photo") }, text = controls,
|
||||
confirmButton = { TextButton(enabled = !upload.pending && !upload.busy, onClick = recordUi.closeCapture) { Text("Done") } }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
|
||||
package io.qrun.mobile.scanner
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kingsrook.qqq.frontend.android.core.model.RecordData
|
||||
import com.kingsrook.qqq.frontend.android.core.model.RecordSearch
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** Only retained file identity and public display metadata reach the gallery. */
|
||||
internal data class SavedRecordPhoto(val id: String, val name: String, val mediaType: String, val size: Long, val sha256: String, val lifecycle: String) {
|
||||
companion object {
|
||||
fun parse(row: RecordData, target: RecordPhotoTarget): SavedRecordPhoto {
|
||||
require(row.id("tableName") == target.tableName && row.id("recordId") == target.recordId)
|
||||
return SavedRecordPhoto(requireNotNull(row.id("id")), row.display("fileName"), row.id("mediaType").orEmpty(),
|
||||
requireNotNull(row.id("sizeBytes")).toLong(), row.id("sha256").orEmpty(), row.id("lifecycle").orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parent-scoped metadata is visible even while the original awaits review. */
|
||||
@Composable
|
||||
internal fun SavedRecordPhotos(host: QViewModel, target: RecordPhotoTarget, api: RecordFilesApi, saved: Boolean) {
|
||||
var offset by rememberSaveable(target.tableName, target.recordId, host.sessionUUID) { mutableStateOf(0) }
|
||||
var refresh by remember { mutableStateOf(0) }
|
||||
var rows by remember { mutableStateOf<List<SavedRecordPhoto>>(emptyList()) }
|
||||
var hasNext by remember { mutableStateOf(false) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
var preview by remember { mutableStateOf<SavedRecordPhoto?>(null) }
|
||||
val admission = remember { Semaphore(1) }
|
||||
LaunchedEffect(target, host.sessionUUID, offset, refresh, saved) {
|
||||
loading = true; failed = false
|
||||
try {
|
||||
val predicate = RecordSearch("image/", listOf("mediaType"), mapOf("tableName" to JsonPrimitive(target.tableName), "recordId" to JsonPrimitive(target.recordId)))
|
||||
// Record Files intentionally denies aggregate counts. One look-ahead row keeps paging bounded.
|
||||
val page = host.qqqRepository.searchRecords("record_files_file", "id", predicate, offset, 11).map { SavedRecordPhoto.parse(it, target) }
|
||||
hasNext = page.size > 10
|
||||
rows = page.take(10)
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (exception: Exception) { failed = true; rows = emptyList() }
|
||||
finally { loading = false }
|
||||
}
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp).testTag("record.photos")) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (loading) "Loading photos…" else if (failed) "Photos unavailable" else "${offset + rows.size}${if (hasNext) "+" else ""} photos", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium)
|
||||
TextButton(onClick = { refresh++ }) { Text("Refresh photos") }
|
||||
}
|
||||
if (failed) Text("Photos could not be loaded. Check your connection and access, then refresh.")
|
||||
if (!failed && !loading && rows.isEmpty()) Text("No photos attached. Use Actions → Add photo.")
|
||||
LazyColumn(Modifier.weight(1f).testTag("record.photos.list"), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items(rows, key = { it.id }) { file ->
|
||||
Row(Modifier.testTag("record.photo:${file.id}"), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (file.lifecycle == "AVAILABLE") PhotoImage(api, host.sessionUUID, file, admission, 256, Modifier.size(72.dp))
|
||||
else Text("Photo", Modifier.size(72.dp).padding(8.dp), style = MaterialTheme.typography.labelMedium)
|
||||
Column(Modifier.weight(1f).padding(start = 8.dp)) {
|
||||
Text(file.name, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(if (file.lifecycle == "QUARANTINED") "Awaiting review" else if (file.lifecycle == "AVAILABLE") "Available" else "Unavailable", style = MaterialTheme.typography.labelMedium)
|
||||
if (file.lifecycle == "AVAILABLE") TextButton(onClick = { preview = file }) { Text("Preview photo") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
TextButton(enabled = !loading && offset > 0, onClick = { offset = (offset - 10).coerceAtLeast(0) }) { Text("Previous photos") }
|
||||
TextButton(enabled = !loading && !failed && hasNext, onClick = { offset += 10 }) { Text("Next photos") }
|
||||
}
|
||||
}
|
||||
preview?.let { file ->
|
||||
AlertDialog(onDismissRequest = { preview = null }, title = { Text("Photo preview") }, text = {
|
||||
Column { Text(file.name); PhotoImage(api, host.sessionUUID, file, admission, 1600, Modifier.fillMaxWidth()) }
|
||||
}, confirmButton = { TextButton(onClick = { preview = null }) { Text("Close preview") } })
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a bounded bitmap in memory, without an image-loader disk cache or external URL. */
|
||||
@Composable
|
||||
private fun PhotoImage(api: RecordFilesApi, session: String, file: SavedRecordPhoto, admission: Semaphore, edge: Int, modifier: Modifier) {
|
||||
var bitmap by remember(session, file, edge) { mutableStateOf<Bitmap?>(null) }
|
||||
var failed by remember(session, file) { mutableStateOf(false) }
|
||||
var retry by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(session, file, edge, retry) {
|
||||
failed = false
|
||||
try {
|
||||
bitmap = admission.withPermit {
|
||||
val bytes = api.image(session, file)
|
||||
withContext(Dispatchers.Default) {
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
require(options.outWidth in 1..100000 && options.outHeight in 1..100000)
|
||||
options.inSampleSize = 1
|
||||
while (maxOf(options.outWidth, options.outHeight) / options.inSampleSize > edge) options.inSampleSize *= 2
|
||||
options.inJustDecodeBounds = false
|
||||
requireNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options))
|
||||
}
|
||||
}
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (exception: Exception) { failed = true }
|
||||
}
|
||||
val image = bitmap
|
||||
if (image != null) Image(image.asImageBitmap(), "Attached photo", modifier.testTag("photo.image:$edge"), contentScale = ContentScale.Fit)
|
||||
else if (failed) TextButton(onClick = { retry++ }, modifier = modifier) { Text("Retry image") }
|
||||
else Text("Loading image…", modifier, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
@@ -338,6 +338,10 @@ class ScannerActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
},
|
||||
com.kingsrook.qqq.frontend.android.mobileapp.ui.records.LocalRecordAttachments provides { model, tableName, recordId, recordUi ->
|
||||
RecordPhotoCapture(model, RecordPhotoTarget(tableName, recordId, "Record photos", null), recordFiles, recordUi = recordUi,
|
||||
disabled = !com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed.current())
|
||||
},
|
||||
LocalCustomWidgetRenderer provides { parameters, modifier, disabled ->
|
||||
if (parameters.widgetBlock.blockType == "SCANNER") {
|
||||
ScannerWidget(
|
||||
|
||||
@@ -311,7 +311,10 @@ class MobileSessionTest {
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
server.enqueue(MockResponse().setBody("{}"))
|
||||
val origin = server.url("/").toString()
|
||||
val api = MobileSessionApi(origin, { mapOf("X-QQQ-Device-Token" to "synthetic-token") }, UiApiCompatibility(origin, true))
|
||||
val api = MobileSessionApi(
|
||||
origin, { mapOf("X-QQQ-Device-Token" to "synthetic-token") }, UiApiCompatibility(origin, true),
|
||||
deviceInfo = mapOf("osVersion" to "14", "appVersion" to "1.0.1", "manufacturer" to null, "model" to " ")
|
||||
)
|
||||
api.heartbeat("synthetic-session", ready())
|
||||
api.screen("synthetic-session", "lease-one", 4, frame)
|
||||
api.end("synthetic-session", "SIGN_OUT")
|
||||
@@ -324,7 +327,10 @@ class MobileSessionTest {
|
||||
val body = JSONObject(heartbeat.body.readUtf8())
|
||||
assertTrue(body.getBoolean("screenAvailable"))
|
||||
assertEquals("count", body.getString("processName"))
|
||||
listOf("user", "deviceId", "barcode", "sessionUUID", "password", "values").forEach { assertFalse(body.has(it)) }
|
||||
// Installed versions ride on every heartbeat so an in-place update refreshes the device record; blank/null ones are omitted.
|
||||
assertEquals("14", body.getString("osVersion"))
|
||||
assertEquals("1.0.1", body.getString("appVersion"))
|
||||
listOf("user", "deviceId", "barcode", "sessionUUID", "password", "values", "manufacturer", "model").forEach { assertFalse(body.has(it)) }
|
||||
val screen = server.takeRequest()
|
||||
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/screen", screen.path)
|
||||
assertEquals(4, JSONObject(screen.body.readUtf8()).getLong("sequence"))
|
||||
|
||||
@@ -60,6 +60,54 @@ class PhotoUploadViewModelTest {
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that does not opt in (generic servers) commits QUARANTINED: the saved photo
|
||||
** is reported as awaiting server review, never as available.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun quarantinedReceiptOnOtherHostsReportsAwaitingReview() = runTest()
|
||||
{
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
Dispatchers.setMain(dispatcher)
|
||||
try {
|
||||
val transport = FakeTransport().apply { lifecycle = "QUARANTINED" }
|
||||
val model = PhotoUploadViewModel(transport, "session", target, files.newFolder(), dispatcher) { true }
|
||||
advanceUntilIdle()
|
||||
model.prepareCamera().writeBytes(original)
|
||||
model.cameraResult(true)
|
||||
advanceUntilIdle()
|
||||
assertTrue(model.saved)
|
||||
assertEquals("Saved photo. Awaiting server review.", model.message)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An Echo host that admits approved-device captures commits AVAILABLE: the same unchanged
|
||||
** receipt flow reports a plain saved photo, with the exact original still verified.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun availableReceiptOnEchoHostReportsSavedWithoutReviewWait() = runTest()
|
||||
{
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
Dispatchers.setMain(dispatcher)
|
||||
try {
|
||||
val transport = FakeTransport().apply { lifecycle = "AVAILABLE" }
|
||||
val model = PhotoUploadViewModel(transport, "session", target, files.newFolder(), dispatcher) { true }
|
||||
advanceUntilIdle()
|
||||
model.prepareCamera().writeBytes(original)
|
||||
model.cameraResult(true)
|
||||
advanceUntilIdle()
|
||||
assertTrue(model.saved)
|
||||
assertEquals("Saved photo.", model.message)
|
||||
assertArrayEquals(original, transport.uploaded)
|
||||
assertEquals("CAMERA", model.intent!!.origin)
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clearing the process cancels in-flight transport before removing only the app-owned temporary original.
|
||||
*******************************************************************************/
|
||||
@@ -215,6 +263,7 @@ class PhotoUploadViewModelTest {
|
||||
var finalizes = 0
|
||||
var cancels = 0
|
||||
var statusCalls = 0
|
||||
var lifecycle = "QUARANTINED"
|
||||
var loseCreate = false
|
||||
var loseFinalize = false
|
||||
var chunkGate: CompletableDeferred<Unit>? = null
|
||||
@@ -282,7 +331,7 @@ class PhotoUploadViewModelTest {
|
||||
"items", JSONArray().put(
|
||||
JSONObject()
|
||||
.put("itemId", intent.itemId).put("position", 0).put("fileId", "file").put("versionId", 2).put("attachmentId", 3)
|
||||
.put("sha256", intent.sha256).put("sizeBytes", intent.size).put("lifecycleAtCommit", "QUARANTINED")
|
||||
.put("sha256", intent.sha256).put("sizeBytes", intent.size).put("lifecycleAtCommit", lifecycle)
|
||||
)
|
||||
) else JSONObject.NULL
|
||||
return JSONObject().put("uploadId", id).put("state", state).put("receipt", receipt)
|
||||
|
||||
@@ -20,6 +20,33 @@ import org.junit.Test
|
||||
** Verify discovery, immutable request replay and exact persistence receipts without a device.
|
||||
*******************************************************************************/
|
||||
class RecordFilesApiTest {
|
||||
@Test
|
||||
fun previewsUseTrackedSameOriginDeliveryAndVerifyBytes() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(version(true)))
|
||||
server.enqueue(MockResponse().setBody("""{"version":"1.0.0","blockType":"RECORD_FILES_CAPTURE","widgetType":"composite"}"""))
|
||||
server.enqueue(MockResponse().setBody("""{"canUpload":false}"""))
|
||||
val origin = server.url("/").toString()
|
||||
val api = RecordFilesApi(origin, { mapOf("X-QQQ-Device-Token" to "synthetic") }, UiApiCompatibility(origin))
|
||||
api.capabilities("synthetic-session", target)
|
||||
repeat(3) { server.takeRequest() }
|
||||
val file = SavedRecordPhoto("12", "test.jpg", "image/jpeg", bytes.size.toLong(), RecordFilesApi.sha256(bytes), "AVAILABLE")
|
||||
server.enqueue(MockResponse().setBody(okio.Buffer().write(bytes)))
|
||||
assertArrayEquals(bytes, api.image("synthetic-session", file))
|
||||
val request = server.takeRequest()
|
||||
assertTrue(request.path!!.startsWith("/qqq/v1/qbit/record-files/files/12/downloads/"))
|
||||
assertEquals("1", request.getHeader("X-Record-Files-Download"))
|
||||
assertEquals("android/qqq-mobile", request.getHeader("X-QQQ-Client"))
|
||||
assertEquals("synthetic", request.getHeader("X-QQQ-Device-Token"))
|
||||
assertTrue(runCatching { api.image("synthetic-session", file.copy(lifecycle = "QUARANTINED")) }.isFailure)
|
||||
server.enqueue(MockResponse().setBody(okio.Buffer().write(byteArrayOf(4, 5, 6))))
|
||||
assertTrue(runCatching { api.image("synthetic-session", file) }.isFailure)
|
||||
server.takeRequest()
|
||||
server.enqueue(MockResponse().setBody(okio.Buffer().write(byteArrayOf(1, 2, 3, 4))))
|
||||
assertTrue(runCatching { api.image("synthetic-session", file) }.isFailure)
|
||||
}
|
||||
}
|
||||
|
||||
private val target = RecordPhotoTarget("document", "opaque/key", "Photos", null)
|
||||
private val bytes = byteArrayOf(1, 2, 3)
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
|
||||
// Record details and synthetic camera/gallery results against an isolated owner fixture.
|
||||
import { runScannerE2e } from './scanner-e2e-runner.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 4 || args[0] !== '--serial' || args[2] !== '--fixture' || !args[1] || !args[3]) {
|
||||
throw new Error('Usage: node scripts/ci/scanner-device-record-photo-e2e.mjs --serial DEVICE_SERIAL --fixture PRIVATE_RECORD_FILES_FIXTURE.json');
|
||||
}
|
||||
await runScannerE2e({ physical: true, serial: args[1], scenario: 'record-photos', fixture: args[3] });
|
||||
@@ -72,14 +72,15 @@ export async function runScannerE2e(options = {}) {
|
||||
if (fixture) {
|
||||
const url = echo ? validateEchoFixtureDescriptor(fixture) : new URL(fixture.baseUrl);
|
||||
const purposeValid = deviceScreen ? fixture.purpose === 'device-screen-qa' && !['57832', '51631'].includes(url.port) :
|
||||
echo || selectedScenario === 'photos' && fixture.processName === 'mobileRecordPhotos' && fixture.tableName === 'document' && String(fixture.recordId) === '1';
|
||||
echo || selectedScenario === 'rich-records' && fixture.purpose === 'rich-records-e2e' && !['57832', '51631'].includes(url.port) ||
|
||||
['photos', 'record-photos'].includes(selectedScenario) && fixture.processName === 'mobileRecordPhotos' && fixture.tableName === 'document' && String(fixture.recordId) === '1';
|
||||
if (!physical || !purposeValid || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
|
||||
fixture.username !== 'scanner' || fixture.adminUsername !== 'admin' ||
|
||||
typeof fixture.password !== 'string' || fixture.password.length < 16 || typeof fixture.adminPassword !== 'string' || fixture.adminPassword.length < 16) {
|
||||
throw new Error('Physical acceptance requires its purpose-matched isolated loopback fixture and private credential file.');
|
||||
}
|
||||
}
|
||||
if (selectedScenario === 'photos' && !fixture) throw new Error('The photos scenario requires an explicit isolated Record Files fixture.');
|
||||
if (['photos', 'record-photos', 'rich-records'].includes(selectedScenario) && !fixture) throw new Error('The photos scenario requires an explicit isolated Record Files fixture.');
|
||||
const adbPrefix = serial ? ['-s', serial] : [];
|
||||
const physicalSession = physical && selectedScenario === 'session-monitor';
|
||||
const packageName = deviceScreen || physicalSession ? 'io.qrun.mobile.scanner.qa' : 'io.qrun.mobile.scanner.e2e';
|
||||
@@ -138,7 +139,7 @@ export async function runScannerE2e(options = {}) {
|
||||
}
|
||||
const manufacturer = (await adbRun('shell', 'getprop', 'ro.product.manufacturer')).trim();
|
||||
const model = (await adbRun('shell', 'getprop', 'ro.product.model')).trim();
|
||||
if (!/^zebra/i.test(manufacturer) || !/^TC22(?:$|[ _-])/i.test(model)) throw new Error('This physical acceptance entry point is limited to the approved Zebra TC22.');
|
||||
if (!/^zebra/i.test(manufacturer) || !/^(?:TC22|MC9400)(?:$|[ _-])/i.test(model)) throw new Error('This physical acceptance entry point is limited to Zebra TC22 and MC9400.');
|
||||
const installed = (await adbRun('shell', 'pm', 'list', 'packages', '-u', packageName)).split(/\r?\n/).map(line => line.trim());
|
||||
if (installed.includes('package:' + packageName) || installed.includes('package:' + packageName + '.test')) {
|
||||
throw new Error('Disposable test packages already exist. Refusing to replace or clear an existing installation.');
|
||||
@@ -164,7 +165,7 @@ export async function runScannerE2e(options = {}) {
|
||||
}
|
||||
console.log(`Native ${scenario} acceptance passed (local UI, no server).`);
|
||||
};
|
||||
const allowedScenarios = [...(physical ? ['navigation', 'photos', 'device-screen', 'echo'] : []), ...Object.keys(localScenarios), 'pixel-copy', 'unsupported-api', 'missing-extension', 'missing-channel', 'compatible', 'additive', 'admin-forms', 'session-monitor'];
|
||||
const allowedScenarios = [...(physical ? ['navigation', 'photos', 'record-photos', 'device-screen', 'echo', 'rich-records'] : []), ...Object.keys(localScenarios), 'pixel-copy', 'unsupported-api', 'missing-extension', 'missing-channel', 'compatible', 'additive', 'admin-forms', 'session-monitor'];
|
||||
if (selectedScenario && !allowedScenarios.includes(selectedScenario)) throw new Error('Unknown native E2E scenario.');
|
||||
if (Object.hasOwn(localScenarios, selectedScenario)) {
|
||||
const temporary = [];
|
||||
@@ -324,6 +325,8 @@ export async function runScannerE2e(options = {}) {
|
||||
{ mode: 'compatible', name: 'admin-forms', test: 'adminFormsApproveRevokeAndCancel', requireCancel: true },
|
||||
{ mode: 'compatible', name: 'session-monitor', test: 'sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin', requireSession: true },
|
||||
...(fixture ? [{ mode: 'compatible', name: 'photos', test: 'cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout', requirePhotos: true }] : []),
|
||||
...(fixture ? [{ mode: 'compatible', name: 'record-photos', test: 'recordDetailsCameraAndGalleryAttachToSelectedRecord', requirePhotos: true }] : []),
|
||||
...(fixture ? [{ mode: 'compatible', name: 'rich-records', test: 'richRecordsPageGroupNavigateAndAttachPhotos', requirePhotos: true }] : []),
|
||||
...(echo ? [{ mode: 'compatible', name: 'echo', test: 'echoSelectsOnceQueuesTwoScansAndClosesOnAppsLeave', requireCancel: true }] : []),
|
||||
]) {
|
||||
if (selectedScenario && selectedScenario !== (phase.name ?? phase.mode)) continue;
|
||||
@@ -361,16 +364,22 @@ export async function runScannerE2e(options = {}) {
|
||||
}
|
||||
reports.push({ scenario: phase.name ?? phase.mode, ...proxy.verify(!phase.expectedError, Boolean(phase.requireCancel), Boolean(phase.requireSession), Boolean(phase.requirePhotos)) });
|
||||
if (echo) {
|
||||
const expectedStages = ['echo-copies-defaulted', 'echo-document-selected', 'echo-stale-document-cleared', 'echo-copies-selected', 'echo-setup-selected', 'echo-first-queued', 'echo-second-queued', 'echo-recreation-stable', 'echo-copies-persisted', 'echo-run-closed'];
|
||||
const expectedStages = ['echo-copies-defaulted', 'echo-document-selected', 'echo-stale-document-cleared', 'echo-copies-selected', 'echo-setup-selected', 'echo-first-queued', 'echo-second-queued', 'echo-gs1-queued', 'echo-record-browsed', 'echo-recreation-stable', 'echo-copies-persisted', 'echo-run-closed'];
|
||||
if (expectedStages.some(stage => !stages.has(stage))) throw new Error('Echo did not complete every setup/queue/recreation/cancellation stage.');
|
||||
await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ status: 'PASSED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true,
|
||||
artifacts: echoArtifacts, completedStages: expectedStages, acceptedScans: 2, ...reports.at(-1), physicalScannerQualified: false, physicalPrintingQualified: false }, null, 2));
|
||||
artifacts: echoArtifacts, completedStages: expectedStages, acceptedScans: 3, ...reports.at(-1), physicalScannerQualified: false, physicalPrintingQualified: false }, null, 2));
|
||||
}
|
||||
console.log(`Native semantic E2E passed: ${phase.name ?? phase.mode}.`);
|
||||
}
|
||||
await writeFile(join(evidence, 'semantic-contract.json'), `${JSON.stringify(reports, null, 2)}\n`);
|
||||
console.log('All native requests in the selected scenarios matched the published semantic API.');
|
||||
} catch (error) {
|
||||
if (physical && selectedScenario === 'rich-records' && installedByRun.has(packageName)) {
|
||||
const screenshot = '/data/local/tmp/qqq-rich-records-failure.png';
|
||||
await adbRun('shell', 'screencap', '-p', screenshot)
|
||||
.then(() => adbRun('pull', screenshot, join(repository, 'scanner-host/build/native-device-e2e/rich-records-failure.png')))
|
||||
.finally(() => adbRun('shell', 'rm', '-f', screenshot)).catch(() => {});
|
||||
}
|
||||
if (echo && echoEvidence) await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({
|
||||
status: 'FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts, completedStages: [...stages],
|
||||
}, null, 2));
|
||||
|
||||