Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4467d8ec64
|
||
|
|
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
|
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,13 +0,0 @@
|
||||
name: QRun Android lifecycle
|
||||
'on':
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
release:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/android.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: android-build
|
||||
@@ -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
|
||||
@@ -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`; pull-request checks come from central Gitea policy, not from that release workflow. It is not a scanner-APK signing pipeline, and no automatic signing CI is configured. 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. 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, put key material, passwords or vault item details in the repository, or use the legacy GitHub release workflows; create no tags or releases without current authorization. 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,18 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.4 (versionCode 6)
|
||||
|
||||
- Loading the server's metadata no longer fails with "Error loading QInstance: timeout" on a handheld. The metadata response is about 1.7 MB and takes the server 4 to 8 seconds to build, and the default 10 second read timeout expired. That one call now allows 60 seconds; every other call keeps its short timeout. A timeout now reads "Timed out after 60.0s loading the server's metadata from <server>/qqq/v1/metaData".
|
||||
- Includes 1.0.3 (versionCode 5): string widget icons and summary-only help content from current QQQ core are accepted. (PR 24)
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ See [emulator testing](docs/EMULATOR.md) for an emulator demo, synthetic scans a
|
||||
|
||||
### Signed APK (direct install / MDM)
|
||||
|
||||
For real devices. Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). A signed 1.0.0 (versionCode 2) APK exists and was installed and launched on one MC9400, but it has not been qualified end to end, distributed or published, and no release exists. The development-signed and production-signed builds cannot update each other in place.
|
||||
Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). The published download remains the [signed 1.0.0 preview](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease, forge sign-in required). The signed 1.0.1 candidate has passed trusted-HTTPS enrollment, physical scanning, navigation and an in-place update on the MC9400, preserving login and enrollment. Its publication, native Authentik qualification and final dependency-cohort checks remain open; 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 and test
|
||||
|
||||
@@ -73,7 +73,7 @@ Limits: JVM, emulator and synthetic-scan tests do not prove physical trigger, li
|
||||
|
||||
## CI and automation
|
||||
|
||||
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive and receives no ordinary writes. Protected `main` runs [`.gitea/workflows/qrun-android-release.yaml`](.gitea/workflows/qrun-android-release.yaml) on push and manual dispatch; it calls the shared `KofTwentyTwo/platform-ci` Android workflow (`android.yaml@qrun-release-v1`). That workflow builds, verifies and publishes the SDK libraries; it is not a scanner-APK signing or release pipeline. Pull-request checks come from central Gitea policy. The managed CircleCI profile (`.qqq-ci.yml`) and its main-only library publication are retained. The `.github` workflows are legacy and not a release path. Shared CI design is out of scope for this repository's local use: the demo above needs none of it.
|
||||
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
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
# Finish ledger for 1.0
|
||||
|
||||
As of 2026-11-04. This is not a release and no 1.0 is published. Older ledgers are in [docs/history](history/README.md); the checklist is [TODO](TODO.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).
|
||||
|
||||
| Area | State | Evidence / limit |
|
||||
| --- | --- | --- |
|
||||
@@ -8,8 +8,8 @@ As of 2026-11-04. This is not a release and no 1.0 is published. Older ledgers a
|
||||
| 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 | Built and verified locally, not distributed | 1.0.0 / versionCode 2; installed and launched once on an MC9400 before the demo switch; no end-to-end qualification |
|
||||
| Release | Not done | Needs HTTPS server, device qualification and distribution; no tag, no publication, no signing CI |
|
||||
| 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 |
|
||||
|
||||
@@ -4,26 +4,38 @@ Use this for the **production-signed** APK on a small number of devices. Move to
|
||||
|
||||
This guide is not the local demo. The demo uses the development-signed debug APK, plain HTTP and `adb reverse` port forwarding to a workstation server; see [README](../README.md#installing-on-a-device). The two builds have different signers and cannot update each other.
|
||||
|
||||
**Current release status (November 5, 2026):** the only published release is the signed preview 1.0.0 below. A signed **1.0.1 (versionCode 3) candidate** exists but is **not published and has no download link**; this guide does not point to one. Use the table below for what is actually hosted. 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 |
|
||||
| --- | --- |
|
||||
| File | `QQQ-Mobile-1.0.0.apk` (locally signed) |
|
||||
| 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. Compare the SHA-256 before installing. The checksum shows the file is intact, not who signed it.
|
||||
The same APK is used for every customer; branding comes from the server at runtime. Download the APK and `SHA256SUMS` from the release page and compare the SHA-256 before installing, then verify the signing certificate matches the value above. The checksum shows the file is intact, not who signed it. This is a preview, not a stable release. Only the APK and `SHA256SUMS` are hosted; there is no hosted install kit.
|
||||
|
||||
This APK was installed and launched once on a fresh Zebra MC9400 (Android 13): install succeeded, the Connect screen appeared and the installed bytes matched the hash above. It has **not** been enrolled, scanned or navigated, because no HTTPS server was available, so these steps are not hardware-qualified until the first install is confirmed (below). That MC9400 now runs the development demo build instead.
|
||||
**Device evidence.** *Historical (November 4):* this APK was installed and launched once on a fresh Zebra MC9400 (Android 13) and showed the Connect screen; it was not yet enrolled. *Current (November 5):* with the user's explicit authorization, the MC9400 was migrated: 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready were observed, and the operator confirmed a physical Saved scan that the backend verified. These checks are done and need not be repeated for this device. The 1.0.1 candidate is still unpublished, native Authentik login/logout/account switching is not yet qualified with the production APK, and the TC22 keeps its original development-signed installation.
|
||||
|
||||
## Before you start
|
||||
|
||||
- The device is connected to Wi-Fi.
|
||||
- You have an **HTTPS** QQQ server URL the device can reach. Release builds reject HTTP. The server is not chosen yet: use the address you are given, and do not guess one.
|
||||
- You have a customer **HTTPS** QQQ server URL with a trusted certificate that the device can reach. Release builds reject HTTP, and certificate checks must not be weakened. Use the address you are given (`https://<your-server>`); do not guess one. Local-network qualification used a temporary private network and is not a customer deployment.
|
||||
- You have the QQQ credentials for the sign-in the server offers. None are in this guide.
|
||||
|
||||
## Install: copy the file (recommended)
|
||||
|
||||
1. Copy `QQQ-Mobile-1.0.0.apk` to the device's **Downloads** folder.
|
||||
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.
|
||||
|
||||
@@ -50,7 +62,7 @@ Grant location when Android asks; scanning works without it. Zebra devices with
|
||||
|
||||
## Confirm the first new install
|
||||
|
||||
On the first device only, check each of these:
|
||||
On the first device of a new customer deployment, check each of these (already done for the MC9400 qualification above):
|
||||
|
||||
- The footer shows **Connected**.
|
||||
- One scan is captured and the server keeps it (**Saved scan**).
|
||||
@@ -65,7 +77,7 @@ For a later build signed by the same key with a higher versionCode, run the same
|
||||
|
||||
## Devices running the development-signed build
|
||||
|
||||
Devices running the development-signed demo build (the TC22, and the MC9400 in demo mode) cannot be updated by this APK, because the signer differs. Android is expected to refuse the install. This is not a fault: do not uninstall the app or reset the device to get around it. Moving that device needs a planned one-time migration that includes re-enrollment.
|
||||
Devices running the development-signed demo build (the TC22) cannot be updated by this APK, because the signer differs. Android is expected to refuse the install. This is not a fault: do not uninstall the app or reset the device to get around it. Moving that device needs an explicitly authorized, planned one-time migration that includes re-enrollment.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -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,6 +1,33 @@
|
||||
# Scanner APK release readiness
|
||||
|
||||
Status (November 4, 2026): **a QRun-owned production signing key exists with a verified backup, and a locally signed, verified 1.0.0 (versionCode 2) APK exists. It was installed and launched once on a fresh MC9400, but it has not been qualified end to end, distributed or published, and no release exists.** The owner selected direct signed-APK / MDM distribution. Current local demo devices (TC22 and, by the user's later choice, the MC9400) run the development-signed debug APK (0.1.0, versionCode 1) against the local HTTP server; that is not a production release. Manual TC22 acceptance was reported all-pass by the operator on November 4 (operator report, no per-case artifacts) for the development-signed build; it does not qualify the production signer and is not requested again for that build.
|
||||
## Current status (November 5, 2026)
|
||||
|
||||
- **Published:** the only published release is still [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), a **signed preview** (Gitea prerelease, not a stable release). It is unchanged.
|
||||
- **Candidate, not published:** QQQ Mobile **1.0.1 (versionCode 3)** is a signed candidate. Its release qualification is incomplete, and no tag or download link is available yet.
|
||||
- **Signer:** the candidate is signed with the existing permanent QRun key (certificate SHA-256 `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`). No new or replacement key was created; the existing recovery backup was verified earlier.
|
||||
- **Candidate APK:** SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes.
|
||||
- **Source:** Android PR 15 `ab6e44c`, merged as `c782aaaaba3f62422232436a9000af9d4b38fc82` with the same app-source tree. Installed APK bytes, signature, non-debuggable flag and 16 KiB alignment were verified. `lintRelease` reported 0 errors, 0 warnings and 5 informational findings; the release build passed.
|
||||
- **Distribution:** maintainers use the existing manual signing path for direct APK/MDM distribution. Automated signing CI is optional.
|
||||
- **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).
|
||||
|
||||
## Next candidate: 1.0.2 (versionCode 4)
|
||||
|
||||
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`.
|
||||
|
||||
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).
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
./gradlew --no-daemon --console=plain :scanner-host:lintRelease :scanner-host:assembleRelease -PscannerVersionName=1.0.2 -PscannerVersionCode=4
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Historical status (November 4, 2026, superseded where noted)
|
||||
|
||||
Dated evidence below is preserved. As of November 4: a QRun-owned production signing key existed with a verified backup, and the signed 1.0.0 (versionCode 2) APK was published as a signed preview: [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), release ID 1361, tag on protected `main` `3eef6431ca39b6450cf764424d921a02b2afb9ab`. It had been installed and launched once on a fresh MC9400; at that time enrollment, scanning and update were unqualified. *(Superseded November 5: the MC9400 was migrated to the signed build; see below. The statement that the MC9400 ran the development-signed debug APK is no longer current. The TC22 still runs its original development-signed installation.)* Manual TC22 acceptance was reported all-pass by the operator on November 4 (operator report, no per-case artifacts) for the development-signed build; it does not qualify the production signer and is not rerun.
|
||||
|
||||
## Decisions (made)
|
||||
|
||||
@@ -14,7 +41,7 @@ Status (November 4, 2026): **a QRun-owned production signing key exists with a v
|
||||
|
||||
## What exists today
|
||||
|
||||
- **Gitea CI (actual).** `.gitea/workflows/qrun-android-release.yaml` is in the repository, runs on push to `main` (and manual dispatch) and calls the central `KofTwentyTwo/platform-ci` Android workflow at `qrun-release-v1`. A central main run (3634) passed and published the SDK AARs. It is **not** a scanner-APK signing or release pipeline: it uses no scanner signing secrets and produces no production-signed scanner APK.
|
||||
- **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.
|
||||
@@ -22,22 +49,38 @@ Status (November 4, 2026): **a QRun-owned production signing key exists with a v
|
||||
- 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 (historical only).** `.github/workflows/release-scanner.yml` and `scanner.yml` predate Gitea as primary forge. They are not a release path; do not run or follow them. The [November 1 review](RELEASE-AUTOMATION-REVIEW.md) covered those files only.
|
||||
- **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. The APK is not published or distributed and is kept outside this repository.
|
||||
**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.
|
||||
|
||||
Remaining steps:
|
||||
*(The November 4 MC9400 paragraph above is historical: the fresh install was later replaced by the migration recorded below.)*
|
||||
|
||||
1. Qualify the **actual production-signed APK** on a device: install, enrollment, scanner path ([Zebra scanning](ZEBRA-SCANNING.md)), Back/Apps navigation, and update over a prior production build. Use a planned device migration or a spare device; do not use a demo device implicitly, and note that moving a demo device to the signed build removes its development install and enrollment.
|
||||
2. Provide an **HTTPS** server. Source release builds disallow cleartext; HTTP is limited to debug builds. The local demo does not need HTTPS.
|
||||
3. Use the first direct installation to confirm the device flow, then distribute the same APK to the remaining devices. MDM can be introduced later.
|
||||
4. Automated scanner-APK signing/release workflow is not configured and is separate from manual distribution; create no tags or releases without current authorization.
|
||||
## 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
|
||||
|
||||
@@ -49,8 +92,8 @@ Remaining steps:
|
||||
|
||||
Android updates in place only when the signer matches and `versionCode` does not decrease. Losing this key, or its backup, means installed copies can no longer be updated in place; they would have to be uninstalled and reinstalled, which removes enrollment and session state. Keep the vault backup recoverable and re-verify it (hash, open, certificate comparison) after any change to it.
|
||||
|
||||
Development-signed demo installations (TC22, and the MC9400 in demo mode) cannot be upgraded by the production key. Moving one requires an explicit migration: preserve its test evidence, plan re-enrollment, and never uninstall or reset a device or application implicitly. No update or key-rotation policy beyond keeping this one permanent signer is defined here.
|
||||
Development-signed demo installations (the TC22) cannot be upgraded by the production key. Moving one requires an explicit migration: preserve its test evidence, plan re-enrollment, and never uninstall or reset a device or application implicitly. The MC9400 was migrated under explicit user authorization (see above). No update or key-rotation policy beyond keeping this one permanent signer is defined here.
|
||||
|
||||
## Not yet established
|
||||
|
||||
Production-signed-APK device qualification, first distribution, an HTTPS production server, an automated scanner release workflow and its secret handling, and key-rotation planning. SDK publication by the central workflow is done and its completed audits are not reopened here. Other pending items are in [TODO](TODO.md).
|
||||
Publication of the 1.0.1 candidate, native Authentik qualification with the production APK, a customer production HTTPS deployment, stable-release promotion, an automated scanner release workflow (optional) and its secret handling, and key-rotation planning. SDK publication by the central workflow is done and its completed audits are not reopened here. Other pending items are in [TODO](TODO.md).
|
||||
@@ -1,36 +1,50 @@
|
||||
# Current status
|
||||
|
||||
As of 2026-11-04. 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](history/README.md). Nothing here claims a finished or released 1.0. Checklist: [TODO](TODO.md). Ledger: [FINISH-1.0](FINISH-1.0.md).
|
||||
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.
|
||||
|
||||
## Source
|
||||
## Release candidate 1.0.2 (versionCode 4), 2026-11-06
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Local demo: accepted
|
||||
## Heartbeat version fix (2026-11-06, Android PR 18)
|
||||
|
||||
- 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).
|
||||
- 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).
|
||||
|
||||
## MC9400 (second device)
|
||||
## Release facts (2026-11-05)
|
||||
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
## Signed release
|
||||
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").
|
||||
|
||||
- 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 **not** published, tagged or distributed, and no production release exists. No automatic signing CI is configured. The signed artifact is kept outside the repository.
|
||||
- Production qualification 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.
|
||||
## Record browsing and photos
|
||||
|
||||
## Open (not claimed)
|
||||
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.
|
||||
|
||||
- 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)).
|
||||
**Record → Actions → Add photo** opens Take photo / Choose photo. Saved photos show parent-scoped lifecycle status and approved thumbnails/previews. Quarantined originals remain unavailable. Availability is decided only by the server receipt/lifecycle; focused tests cover both `AVAILABLE` and `QUARANTINED`, and the native E2E expects `AVAILABLE` for the configured Echo fixture while retaining quarantine assertions for the generic fixture. Thirteen focused photo/API tests passed. A fresh synthetic gallery upload through the signed MC9400 app returned "Saved photo."; server readback verified availability, parent, type, byte count and SHA256. The three earlier demo captures were released through the normal audited admin process. Updated TC22 native photo acceptance passed synthetic camera/gallery upload, the captured photo thumbnail/full preview, parent scoping, rotation and forced-logout cleanup, with all requests checked against the semantic API. The disposable test packages were removed and the normal app reopened. Public captured-photo dependency publication and native Authentik qualification remain open. Existing upload identity, retry handling, server authorization, pending-navigation guards and logout cleanup are preserved.
|
||||
|
||||
The maintained companion demo supplies Test Data: 1,000 orders, 39 fields in six groups, 12,000 items and 3,000 history entries. See [screenshots and verification](RECORD-UX-2026-11-05.md).
|
||||
|
||||
## Verification and installation
|
||||
|
||||
- **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.
|
||||
|
||||
## Source and runtime
|
||||
|
||||
Protected `main` contains PRs 8/10, with signing/documentation through PR 14 (`464814f`). The user authorized commit, push and merge of the GS1/record/UX work through normal Gitea checks. No new artifact release was requested. The maintained server uses public QQQ RC.21 and Printing 1.0.0 plus qualified document/files/scripting owner inputs. Contract versions remain UI API 1.10, mobile-scanners 1.5, printing 2.3 and mobile-record-files 1.0. Device loopback 8080 forwards to the existing host port 57832.
|
||||
|
||||
The QRun-owned signing key and [signed preview `scanner-v1.0.0`](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) remain unchanged. That preview predates the record UX update; candidate 1.0.1 is unpublished. The preview is not a stable release or an automatic signing/publication pipeline; see [RELEASING](RELEASING.md).
|
||||
|
||||
## Accepted and outstanding hardware work
|
||||
|
||||
The November 4 TC22 operator group pass remains accepted; do not request it again. MC9400'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,35 +1,25 @@
|
||||
# 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](history/README.md).
|
||||
Open work only, as of **2026-11-05**. Current evidence: [SESSION-STATE](SESSION-STATE.md). Completed and superseded items: [history](history/README.md).
|
||||
|
||||
## Local demo (accepted)
|
||||
## Hardware and release qualification
|
||||
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
## MC9400 remaining check
|
||||
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] 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).
|
||||
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.
|
||||
|
||||
## Release qualification
|
||||
## Dependencies and broader coverage
|
||||
|
||||
- [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).
|
||||
- [ ] First distribution to the remaining devices; MDM later. No tag or release exists.
|
||||
- [ ] 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).
|
||||
- [ ] 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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
@@ -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,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.
|
||||
|
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,18 +44,30 @@ import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Retrofit
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/*******************************************************************************
|
||||
** Ceiling for loading the server's metadata (GET /qqq/v1/metaData). That response is large (about 1.7 MB for a
|
||||
** platform administrator on the staging server) and the server needs 4 to 8 seconds to build it, so the default
|
||||
** 10 second OkHttp read timeout expired on handhelds. Only this one call gets the long limit; every other call
|
||||
** keeps the short defaults.
|
||||
*******************************************************************************/
|
||||
const val METADATA_LOAD_TIMEOUT_SECONDS = 60L
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val compatibility: UiApiCompatibility? = null, private val additionalHeaders: (String) -> Map<String, String> = { emptyMap() }) : QQQRepository {
|
||||
class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val compatibility: UiApiCompatibility? = null, private val metaDataTimeoutMillis: Long = METADATA_LOAD_TIMEOUT_SECONDS * 1000, private val additionalHeaders: (String) -> Map<String, String> = { emptyMap() }) : QQQRepository {
|
||||
private val sessionUUIDs = ConcurrentHashMap<String, String>()
|
||||
private val retroJson = Json { ignoreUnknownKeys = true }
|
||||
private val compatibilityByServer = ConcurrentHashMap<String, UiApiCompatibility>()
|
||||
|
||||
private val qqqApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
|
||||
private val metaDataApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@@ -63,9 +75,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
baseUrl = environment.baseUrl
|
||||
|
||||
if (qqqApiServiceMap[baseUrl] == null) {
|
||||
val thisRetrofit = buildRetrofit(baseUrl)
|
||||
val thisQQQApiService = thisRetrofit.create(QQQApiService::class.java)
|
||||
qqqApiServiceMap[baseUrl] = thisQQQApiService
|
||||
val client = buildClient(baseUrl)
|
||||
qqqApiServiceMap[baseUrl] = buildRetrofit(baseUrl, client).create(QQQApiService::class.java)
|
||||
val metaDataClient = client.newBuilder()
|
||||
.callTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
metaDataApiServiceMap[baseUrl] = buildRetrofit(baseUrl, metaDataClient).create(QQQApiService::class.java)
|
||||
}
|
||||
|
||||
// retrofit = buildRetrofit()
|
||||
@@ -74,10 +90,19 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
private fun buildRetrofit(baseUrl: String): Retrofit {
|
||||
private fun buildRetrofit(baseUrl: String, client: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType()))
|
||||
.client(
|
||||
.client(client)
|
||||
.baseUrl(baseUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the shared client (same-origin, session, compatibility and error interceptors) with default timeouts.
|
||||
*******************************************************************************/
|
||||
private fun buildClient(baseUrl: String): OkHttpClient {
|
||||
return (
|
||||
OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
@@ -110,8 +135,6 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
.addInterceptor(SameOriginInterceptor())
|
||||
.build()
|
||||
)
|
||||
.baseUrl(baseUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -120,6 +143,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 {
|
||||
@@ -267,7 +332,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
override suspend fun getMetaData(
|
||||
frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String
|
||||
): QInstance {
|
||||
return qqqApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion)
|
||||
try {
|
||||
return metaDataApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion)
|
||||
} catch (e: InterruptedIOException) {
|
||||
// OkHttp reports read and call timeouts as InterruptedIOException (SocketTimeoutException); the bare "timeout" is not diagnosable.
|
||||
// Coroutine cancellation is a CancellationException, not an IOException, so it is unaffected.
|
||||
throw IOException("Timed out after ${metaDataTimeoutMillis / 1000.0}s loading the server's metadata from ${baseUrl.trimEnd('/')}/qqq/v1/metaData", e)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -216,4 +303,60 @@ class NetworkQQQRepositoryTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The metadata response is large and slow to build; a response that starts after the old 10 second read timeout must still load.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun metaDataLoadSurvivesAServerThatAnswersAfterTheOldDefaultTimeout() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setHeadersDelay(11, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"tables":{},"processes":{},"widgets":{},"apps":{}}"""))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin)
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
val instance = repository.getMetaData("qqq-android", "1.0", "app", "1.0")
|
||||
assertTrue(instance.tables!!.isEmpty())
|
||||
server.takeRequest()
|
||||
assertEquals("/qqq/v1/metaData", server.takeRequest().requestUrl!!.encodedPath)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A metadata timeout names what timed out and the URL, without any query values, tokens or cookies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun metaDataTimeoutNamesTheMetadataLoadAndUrlWithoutSecrets() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setHeadersDelay(3, java.util.concurrent.TimeUnit.SECONDS).setBody("{}"))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin, metaDataTimeoutMillis = 500)
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
repository.setSessionUUID("fixture-session-value")
|
||||
val failure = runCatching { repository.getMetaData("qqq-android", "1.0", "app", "1.0") }.exceptionOrNull()
|
||||
assertTrue(failure is java.io.IOException)
|
||||
val message = failure!!.message!!
|
||||
assertTrue(message, message.startsWith("Timed out after 0.5s loading the server's metadata from "))
|
||||
assertTrue(message, message.endsWith(origin.trimEnd('/') + "/qqq/v1/metaData"))
|
||||
assertFalse(message.contains("fixture-session-value"))
|
||||
assertFalse(message.contains("?"))
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only metadata gets the long limit: other calls keep the short default and still fail fast.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun otherCallsStillUseTheShortDefaultTimeout() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setHeadersDelay(12, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"records":[]}"""))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin)
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
val failure = runCatching { repository.queryRecords("items", "id", 0, 10) }.exceptionOrNull()
|
||||
assertTrue(failure is java.io.InterruptedIOException)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||