Compare commits

..
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 c2c47a22bb fix(android): accept string widget icons and summary-only help content from current QQQ core
Core serializes QWidgetMetaData.icon as a string and QHelpContent may carry only a summary; the strict models failed loading the whole instance and echoPrint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 05:50:15 -06:00
31 changed files with 383 additions and 576 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
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: /.*/
-19
View File
@@ -1,19 +0,0 @@
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
@@ -0,0 +1,13 @@
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
+138
View File
@@ -0,0 +1,138 @@
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/*
+163
View File
@@ -0,0 +1,163 @@
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
+3
View File
@@ -0,0 +1,3 @@
profile: android
orb_version: '0.8'
default_branch: main
+1 -1
View File
@@ -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. `.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.
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.
-14
View File
@@ -1,19 +1,5 @@
# 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".
- Sign-out now also ends the identity-provider session in the browser when the server supports it (the same flow as "Sign out of company", now behind the single **Sign out** action), and every browser sign-in sends `max_age=0` next to `prompt=login`. Before, ordinary Sign out cleared only the QQQ session, so the provider session left in the browser silently signed the same person straight back in on the next sign-in. Servers without provider logout still get local plus QQQ sign-out. Stored-session restore after sign-out was checked and is not a cause (test added).
- 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.
+2 -2
View File
@@ -23,9 +23,9 @@ The smoke script clears application data only after verifying the target is an e
## CI and delivery
`.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.
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.
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).
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.
Production signing and distribution require a separately configured release procedure. Do not treat the development APK as a production release.
+2 -2
View File
@@ -59,7 +59,7 @@ See [emulator testing](docs/EMULATOR.md) for an emulator demo, synthetic scans a
### Signed APK (direct install / MDM)
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.
Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). The published download remains the [signed 1.0.0 preview](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease, forge sign-in required). The signed 1.0.1 candidate has passed trusted-HTTPS enrollment, physical scanning, navigation and an in-place update on the MC9400, preserving login and enrollment. Its publication, native Authentik qualification and final dependency-cohort checks remain open. Development-signed installations require an explicit migration to the production signer.
## Build and test
@@ -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. `.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.
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.
## Extension points
+1 -1
View File
@@ -63,7 +63,7 @@ The fresh-login prompt is a request to the provider, not cryptographic proof of
## Optional company sign-out (implementation awaiting qualification)
Since 1.0.4, **Sign out** in Account ends the QQQ session and, when the server advertises API1.10 or later within major1, feature `nativeSessionLogout`, and OAuth2 metadata `nativeSessionLogout` with flow `OIDC_RP_INITIATED_LOGOUT` and `postLogoutRedirectUris`, also ends the provider session in the browser (formerly the separate **Sign out of company** action; without that support Sign out stays QQQ-only). Other library hosts opt into provider sign-out with `LocalCompanySignOutEnabled`; it is disabled by default. No automatic provider-logout policy is selected. The confirmation explains that company sign-out may affect other applications in the browser.
Ordinary **Sign out** continues to end only the QQQ session. The scanner also exposes a separate **Sign out of company** action in Account when the server advertises API1.10 or later within major1, feature `nativeSessionLogout`, and OAuth2 metadata `nativeSessionLogout` with flow `OIDC_RP_INITIATED_LOGOUT` and `postLogoutRedirectUris`. Other library hosts opt into that separate action with `LocalCompanySignOutEnabled`; it is disabled by default. No automatic provider-logout policy is selected. The confirmation explains that company sign-out may affect other applications in the browser.
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.
+1 -1
View File
@@ -4,7 +4,7 @@ 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.
**Current release status (November 5, 2026):** the only published release is the signed preview 1.0.0 below. A signed **1.0.1 (versionCode 3) candidate** exists but is **not published and has no download link**; this guide does not point to one. Use the table below for what is actually hosted. When 1.0.1 is published, use the file, checksum and link given by the release owner, and verify the same signer.
| Candidate (not published) | Value |
| --- | --- |
+3 -18
View File
@@ -8,23 +8,8 @@
- **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.
@@ -41,7 +26,7 @@ Dated evidence below is preserved. As of November 4: a QRun-owned production sig
## What exists today
- **Gitea CI (actual).** `.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. It is **not** a scanner-APK signing or release pipeline: it uses no scanner signing secrets and produces no production-signed scanner APK.
- **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.
- **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.
@@ -49,7 +34,7 @@ Dated evidence below is preserved. As of November 4: a QRun-owned production sig
- Debug, `e2e` and `qa` builds keep the development debug signing.
- Version comes from `-PscannerVersionName` (semantic version, default `0.1.0`) and `-PscannerVersionCode` (1 to 2100000000, default `1`).
- **Identity.** One APK serves all customers; branding is applied at runtime from the selected server's metadata, so no per-customer key or build is needed.
- **Legacy GitHub workflows (removed).** `.github/workflows/release-scanner.yml` and `scanner.yml` predated Gitea as primary forge and were removed with the CircleCI configuration. The [November 1 review](RELEASE-AUTOMATION-REVIEW.md) covered those files only.
- **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.
## First release: 1.0.0 (versionCode 2)
@@ -80,7 +65,7 @@ Open:
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.
5. Publish the 1.0.1 tag, release, APK, checksum and installation instructions after its remaining qualification. The published download remains the 1.0.0 signed preview; automated signing CI is optional.
## Release requirements
+4 -8
View File
@@ -1,14 +1,10 @@
# Current status
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.
As of **2026-11-05**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is not a final-release or full-qualification claim. See [Release facts](#release-facts-2026-11-05) below.
## Release candidate 1.0.2 (versionCode 4), 2026-11-06
## Open fix (2026-11-06, uncommitted branch `fix/refresh-device-versions`)
- 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.
## Heartbeat version fix (2026-11-06, Android PR 18)
- 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).
- 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 on the same-named server branch. Older servers ignore the keys; the installed 1.0.1 APK does not include this change.
## Release facts (2026-11-05)
@@ -18,7 +14,7 @@ As of **2026-11-06**. Scope: scanner host and maintained local Echo workflow. [T
- **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).
- **Company sign-out bug (2026-11-05):** on the physical MC9400 with the production APK against the real Authentik fixture, **Sign out of company** never opened the browser and reported "Company sign-out did not complete". Cause: `QViewModel.performLogout` ran the host `beforeLogout` hook (scanner device-session end, which runs the core logout server-side) before the provider logout request, so `nativeLogoutData` was never returned. A fix reordering provider logout before the host hook, plus adoption of the server-reported user name after browser login (Account showed a blank "Current User"), is prepared on branch `fix/company-signout-before-session-end` with unit tests; it is **not released** and no new APK is built. See [AUTHENTICATION](AUTHENTICATION.md#optional-company-sign-out-implementation-awaiting-qualification).
- **Open:** public compatible Core/Collab/document/files/scripting/bridges, final public cohort gate, GS1 physical print/rescan, full release-asset publication/distribution. Company profiles/wallpaper are tracked separately; signing CI is optional and not blocking a direct APK release. Release authorizations already exist.
Statements below were written before these facts and are historical where they conflict (for example "debug update", MC9400 trigger check "outstanding", signed-build HTTPS qualification "open").
+1 -1
View File
@@ -6,7 +6,7 @@ Open work only, as of **2026-11-05**. Current evidence: [SESSION-STATE](SESSION-
- [ ] 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 signed candidate 1.0.1 (versionCode 3, APK SHA-256 `5124716B...AC2717F`, same permanent signer), checksum and installation instructions after remaining qualification. The published release is still the 1.0.0 signed preview.
- [ ] Publish and distribute full release assets to remaining devices; validate MDM/company profile and wallpaper separately. A customer deployment needs a customer HTTPS URL with a trusted certificate.
- [ ] Printing: the next installer remains with the active printing owner (print nonce fix PR 11 postdates published print 1.1.1).
- [ ] Optional, non-blocking: automatic APK signing CI. Direct APK release does not depend on it.
+4 -4
View File
@@ -30,10 +30,10 @@ plugins {
publishing {
repositories {
maven {
// 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"))
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
credentials(PasswordCredentials::class)
}
}
@@ -44,30 +44,18 @@ 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 metaDataTimeoutMillis: Long = METADATA_LOAD_TIMEOUT_SECONDS * 1000, 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 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()
/*******************************************************************************
*******************************************************************************/
@@ -75,13 +63,9 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
baseUrl = environment.baseUrl
if (qqqApiServiceMap[baseUrl] == null) {
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)
val thisRetrofit = buildRetrofit(baseUrl)
val thisQQQApiService = thisRetrofit.create(QQQApiService::class.java)
qqqApiServiceMap[baseUrl] = thisQQQApiService
}
// retrofit = buildRetrofit()
@@ -90,19 +74,10 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
/*******************************************************************************
*******************************************************************************/
private fun buildRetrofit(baseUrl: String, client: OkHttpClient): Retrofit {
private fun buildRetrofit(baseUrl: String): Retrofit {
return Retrofit.Builder()
.addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType()))
.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 (
.client(
OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
@@ -135,6 +110,8 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
.addInterceptor(SameOriginInterceptor())
.build()
)
.baseUrl(baseUrl)
.build()
}
/*******************************************************************************
@@ -332,13 +309,7 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
override suspend fun getMetaData(
frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String
): QInstance {
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)
}
return qqqApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion)
}
/*******************************************************************************
@@ -20,9 +20,7 @@ data class OAuth2AuthenticationMetaData(
val clientId: String = "", val baseUrl: String = "", val scopes: String = "",
val nativeClientId: String? = null, val nativeClientBaseUrl: String? = null,
val bearerTokensAccepted: Boolean = false, val nativeSessionLogin: NativeSessionLogin? = null,
val nativeSessionLogout: NativeSessionLogout? = null,
// Automatic logoff (UI API 1.12.0): seconds without a user action after which the server ends the session.
val userInactivityTimeoutSeconds: Int? = null
val nativeSessionLogout: NativeSessionLogout? = null
)
/*******************************************************************************
@@ -39,8 +39,6 @@ class NativeSessionLoginTest {
assertEquals("login", metadata.values.nativeSessionLogin!!.prompt)
assertEquals("REQUIRED", metadata.values.nativeSessionLogin!!.nonceValidation)
assertNull((Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.nativeSessionLogin)
assertNull((Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.userInactivityTimeoutSeconds)
assertEquals(900, (Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"logoff","values":{"clientId":"server-client","userInactivityTimeoutSeconds":900}}""") as OAuth2AuthenticationMetaData).values.userInactivityTimeoutSeconds)
val wire = Json.encodeToString(request)
assertTrue(wire.contains("\"authenticationFlow\":\"AUTHORIZATION_CODE_PKCE_SESSION\""))
assertTrue(wire.contains("\"expectedNonce\":\"original-nonce-canary-1234\""))
@@ -303,60 +303,4 @@ 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)
}
}
}
@@ -1,88 +0,0 @@
/*
* 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)
}
}
+4 -4
View File
@@ -30,10 +30,10 @@ plugins {
publishing {
repositories {
maven {
// 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"))
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
credentials(PasswordCredentials::class)
}
}
@@ -132,9 +132,7 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
/*******************************************************************************
** Route sign-out through explicit process-exit confirmation so pending work is not silently abandoned.
*******************************************************************************/
fun requestSignOut(providerSignOut: com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding? = null) =
if (providerSignOut != null) requestCompanySignOut(providerSignOut)
else requestLeave("Sign out?", "Sign out", alwaysConfirm = true) { qViewModel.requestLogout() }
fun requestSignOut() = requestLeave("Sign out?", "Sign out", alwaysConfirm = true) { qViewModel.requestLogout() }
/*******************************************************************************
** The same guarded leave/cancel policy applies to explicitly enabled provider sign-out.
@@ -43,13 +43,6 @@ import kotlin.coroutines.resumeWithException
*******************************************************************************/
val LocalOAuth2Discovery = staticCompositionLocalOf<suspend (OAuth2LoginBinding) -> AuthorizationServiceConfiguration> { ::discoverOAuth2 }
/*******************************************************************************
** Sent with every interactive sign-in next to prompt=login. OIDC max_age=0 independently requires the provider to
** re-authenticate, so a provider session left in the browser cannot silently sign the next person in; the real
** Authentik test showed prompt=login alone reusing retained SSO (docs/AUTHENTICATION.md).
*******************************************************************************/
val OAUTH2_FORCE_REAUTH_PARAMETERS: Map<String, String> = mapOf("max_age" to "0")
/*******************************************************************************
** Browser authorization is standard AppAuth; API1.10 exchanges the code and validates the retained original nonce.
*******************************************************************************/
@@ -100,8 +93,7 @@ fun QOAuth2Login(viewModel: QViewModel, metadata: OAuth2AuthenticationMetaData,
val request = AuthorizationRequest.Builder(
configuration, binding.clientId, ResponseTypeValues.CODE,
Uri.parse(binding.redirectUri)
).setScope(binding.scopes).setPrompt(binding.prompt).setAdditionalParameters(OAUTH2_FORCE_REAUTH_PARAMETERS)
.setResponseMode("query").build()
).setScope(binding.scopes).setPrompt(binding.prompt).setResponseMode("query").build()
check(request.codeVerifierChallengeMethod == "S256")
if (viewModel.beginOAuth2Browser(
generation, binding, requireNotNull(request.state),
@@ -24,7 +24,6 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.oauth2LogoutBinding
/*******************************************************************************
@@ -35,20 +34,10 @@ val LocalOpenDiagnostics = compositionLocalOf<(() -> Unit)?> { null }
val LocalBusinessNavigationEnabled = compositionLocalOf { true }
/*******************************************************************************
** Expose account actions; sign-out also ends the provider session where the host and server support it.
*******************************************************************************/
/*******************************************************************************
** Sign-out ends the identity-provider session too whenever the host enables it and the server supports it;
** otherwise a session left in the browser silently signs the next sign-in back in. Null means local-only sign-out.
*******************************************************************************/
fun providerSignOutBinding(binding: OAuth2LogoutBinding?, serverSupportsProviderLogout: Boolean): OAuth2LogoutBinding? =
if (serverSupportsProviderLogout) binding else null
/*******************************************************************************
** Resolve the provider sign-out binding for the current server, or null when it is disabled or unsupported.
** Expose account actions while keeping optional company sign-out separate from ordinary local sign-out.
*******************************************************************************/
@Composable
fun rememberProviderSignOutBinding(model: QViewModel): OAuth2LogoutBinding? {
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
val companyLogout = if (LocalCompanySignOutEnabled.current)
((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let {
oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName)
@@ -64,12 +53,6 @@ fun rememberProviderSignOutBinding(model: QViewModel): OAuth2LogoutBinding? {
supportsCompanyLogout = false
}
}
return providerSignOutBinding(companyLogout, supportsCompanyLogout)
}
@Composable
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
val providerSignOut = rememberProviderSignOutBinding(model)
AlertDialog(
onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) },
text = {
@@ -77,8 +60,13 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
Text(if (model.isAuthenticated) "Current User: ${model.sessionUserFullName}" else "Not currently logged in")
TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = {
dismiss()
model.qNavigator.requestSignOut(providerSignOut)
model.qNavigator.requestSignOut()
}) { Text("Sign out") }
if (companyLogout != null && supportsCompanyLogout)
TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = {
dismiss()
model.qNavigator.requestCompanySignOut(companyLogout)
}) { Text("Sign out of company") }
}
}, confirmButton = { TextButton(onClick = dismiss) { Text("Close") } })
}
@@ -13,9 +13,7 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAu
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.OAUTH2_FORCE_REAUTH_PARAMETERS
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LoginBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.providerSignOutBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase
import kotlinx.coroutines.Dispatchers
@@ -147,60 +145,12 @@ class QViewModelLogoutTest {
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName)
}
/*******************************************************************************
** After sign-out (ordinary or provider) a restarted app finds no stored session and does not resume one.
*******************************************************************************/
@Test
fun restartAfterSignOutDoesNotRestoreTheSession() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
for (provider in listOf(false, true)) {
val store = MockPreferencesDataStore()
val repository = LogoutRepository()
val model = signedInModel(repository, store)
assertEquals("session-1", store.preferences[com.kingsrook.qqq.frontend.android.mobileapp.data.SESSION_UUID_KEY])
if (provider) assertNotNull(companyLogout(model))
else { model.requestLogout(); model.logOut().join() }
assertNull(store.preferences[com.kingsrook.qqq.frontend.android.mobileapp.data.SESSION_UUID_KEY]?.takeIf { it.isNotEmpty() })
val restartedRepository = LogoutRepository()
val restarted = QViewModel(restartedRepository, store)
advanceUntilIdle()
assertFalse(restarted.isAuthenticated)
assertEquals("", restarted.sessionUUID)
assertEquals(0, restartedRepository.metaDataCalls)
}
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
** Sign-out reaches the identity provider whenever the server supports it, never silently local-only.
*******************************************************************************/
@Test
fun signOutUsesProviderLogoutWhenSupported() {
val binding = OAuth2LogoutBinding(loginBinding, REDIRECT)
assertEquals(binding, providerSignOutBinding(binding, true))
assertNull(providerSignOutBinding(binding, false))
assertNull(providerSignOutBinding(null, true))
}
/*******************************************************************************
** Every interactive sign-in forces re-authentication, so a lingering provider session cannot sign the next user in.
*******************************************************************************/
@Test
fun authorizationRequestForcesReauthentication() {
assertEquals(mapOf("max_age" to "0"), OAUTH2_FORCE_REAUTH_PARAMETERS)
assertEquals("login", loginBinding.prompt)
}
/*******************************************************************************
** Complete a native browser login against the fake repository so sign-out tests start authenticated.
*******************************************************************************/
private fun TestScope.signedInModel(repository: LogoutRepository, store: MockPreferencesDataStore = MockPreferencesDataStore()): QViewModel {
private fun TestScope.signedInModel(repository: LogoutRepository): QViewModel {
QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER))
val model = QViewModel(repository, store)
val model = QViewModel(repository, MockPreferencesDataStore())
advanceUntilIdle()
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
@@ -232,7 +182,6 @@ class QViewModelLogoutTest {
private class LogoutRepository : QQQRepository {
val calls = mutableListOf<String>()
var activeSession = ""
var metaDataCalls = 0
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData(
"oauth", values = OAuth2AuthenticationMetaData.Values(
@@ -265,7 +214,7 @@ class QViewModelLogoutTest {
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 { metaDataCalls++; return QInstance() }
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")
@@ -30,7 +30,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.rememberProviderSignOutBinding
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.SideEffect
@@ -136,13 +135,6 @@ class ScannerActivity : ComponentActivity() {
watchingManagedConfiguration = false
}
/*******************************************************************************
** Every touch and key dispatched to this activity is a user action for the server's inactivity timeout.
*******************************************************************************/
override fun onUserInteraction() {
super.onUserInteraction(); UserActivity.record()
}
/*******************************************************************************
** Reflect focus changes so obscured windows cannot keep accepting scanner input.
*******************************************************************************/
@@ -238,7 +230,7 @@ class ScannerActivity : ComponentActivity() {
val hash = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) }
val dataStore = storesByServer.getOrPut(server) { PreferenceDataStoreFactory.create { File(noBackupFilesDir, "session-$hash.preferences_pb") } }
val repository = NetworkQQQRepository(server, requestObserver = diagnostics::nativeRequest, compatibility = compatibility) { requestedServer ->
if (requestedServer == server) credentials.headers() + UserActivity.header() else emptyMap()
if (requestedServer == server) credentials.headers() else emptyMap()
}
@Suppress("UNCHECKED_CAST")
return QViewModel(repository, dataStore).apply { supportedCustomWidgetTypes = setOf("SCANNER", "RECORD_FILES_CAPTURE") } as T
@@ -258,9 +250,7 @@ class ScannerActivity : ComponentActivity() {
return
}
val api = remember(server, diagnostics) { DeviceApi(server, credentials, diagnostics::deviceRequest, compatibility) }
// Every request of the session states the age of the operator's last action; heartbeats alone keep nothing alive.
val sessionHeaders = remember(server) { { credentials.headers() + UserActivity.header() } }
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, sessionHeaders, compatibility, diagnostics::nativeRequest) }
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, credentials::headers, compatibility, diagnostics::nativeRequest) }
var approved by remember(qViewModel.sessionUUID) { mutableStateOf(false) }
val location = rememberForegroundLocation(resumed && approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested)
val currentScreen = sessionScreen(qViewModel, resumed, focused, approved, captureBounds != null)
@@ -282,7 +272,7 @@ class ScannerActivity : ComponentActivity() {
else WallpaperStatus.store.signedOutIfSignedIn()
}
DisposableEffect(server) { onDispose { WallpaperStatus.store.clear() } }
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, sessionHeaders, compatibility, diagnostics::sessionRequest) }
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, credentials::headers, compatibility, diagnostics::sessionRequest) }
val deviceSharing = remember(server, qViewModel.sessionUUID) { DeviceScreenSharing(applicationContext) }
val sessions = remember(sessionApi, qViewModel.sessionUUID)
{
@@ -319,10 +309,6 @@ class ScannerActivity : ComponentActivity() {
{
if (approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested) sessions.run()
}
InactivityLogoff(
((authentication as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.values?.userInactivityTimeoutSeconds,
qViewModel.isAuthenticated && !qViewModel.isLogoutRequested
) { qViewModel.logOut() }
CompositionLocalProvider(
LocalCompanySignOutEnabled provides true,
LocalBusinessNavigationEnabled provides approved,
@@ -419,7 +405,6 @@ private fun DeviceGate(
viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, onApproval: (Boolean) -> Unit,
content: @Composable () -> Unit
) {
val providerSignOut = rememberProviderSignOutBinding(viewModel)
var attempt by remember { mutableIntStateOf(0) }
var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") }
var error by remember { mutableStateOf("") }
@@ -459,6 +444,6 @@ private fun DeviceGate(
Text("Your administrator can edit this device’s name, short name and description in QQQ before approval.")
Button(enabled = status != "CHECKING", onClick = { attempt++ }) { Text("Check approval") }
if (enrollmentConflict) Button(onClick = { credentials.restartPendingEnrollment(); attempt++ }) { Text("Start new registration") }
Button(onClick = { viewModel.qNavigator.requestSignOut(providerSignOut) }) { Text("Sign out") }
Button(onClick = { viewModel.qNavigator.requestSignOut() }) { Text("Sign out") }
}
}
@@ -118,8 +118,7 @@ internal fun ScannerWidget(
{
ScannerInputSession(
{ driverFactory(context) },
// A hardware scan is a user action even when no key event reaches the activity.
{ capture -> UserActivity.record(); currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
{ capture -> currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
{ message, reconnect -> scannerStatus = message; canReconnect = reconnect },
canAcceptInput = { currentInputReady() })
}
@@ -1,104 +0,0 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.os.SystemClock
import androidx.compose.material3.AlertDialog
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.setValue
import kotlinx.coroutines.delay
/*******************************************************************************
** Automatic logoff (UI API 1.12.0). A request is not a user action: this app sends heartbeats. When the
** server has a user inactivity timeout, only what the operator does (a touch, a key, a scan) keeps the
** session alive. This object remembers when that last happened and states its age on every session request.
**
** The time base counts device sleep, so a handheld left in a cradle ages while its screen is off. Until the
** first user action of this process the age is the device's uptime: nothing is assumed in the user's favour.
*******************************************************************************/
object UserActivity {
const val HEADER = "X-QQQ-User-Activity-Age"
const val WARNING_SECONDS = 60
@Volatile
internal var clock: () -> Long = { SystemClock.elapsedRealtime() }
@Volatile
private var lastActionAt = 0L
/*******************************************************************************
** Record a user action. Call it only from a real touch, key or scan, never from a timer or a response.
*******************************************************************************/
fun record() {
lastActionAt = clock()
}
/*******************************************************************************
** The current time on this object's time base, for a caller that needs a starting point.
*******************************************************************************/
fun now(): Long = clock()
/*******************************************************************************
** Whole seconds since the last user action, never negative. A caller that only started watching at
** notBefore (the on-screen timer) counts from there when no action has been seen since.
*******************************************************************************/
fun ageSeconds(notBefore: Long = 0L): Long = ((clock() - maxOf(lastActionAt, notBefore)) / 1000).coerceAtLeast(0)
/*******************************************************************************
** The header that states the user's activity to the server; sent with every request of a session.
*******************************************************************************/
fun header(): Map<String, String> = mapOf(HEADER to ageSeconds().toString())
/*******************************************************************************
** Seconds left before the server's timeout runs out; zero or less means the session is over.
*******************************************************************************/
fun remainingSeconds(timeoutSeconds: Int, notBefore: Long = 0L): Long = timeoutSeconds - ageSeconds(notBefore)
/*******************************************************************************
** Forget the last user action; for tests.
*******************************************************************************/
internal fun reset() {
lastActionAt = 0L
}
}
/*******************************************************************************
** Sign the operator out when the server's user inactivity timeout runs out, and warn a minute before. The
** server enforces the same timeout at the next request; this is what the operator sees. Shows nothing
** unless the server has such a timeout and somebody is signed in.
*******************************************************************************/
@Composable
fun InactivityLogoff(timeoutSeconds: Int?, signedIn: Boolean, onLogoff: () -> Unit) {
if (timeoutSeconds == null || timeoutSeconds <= 0 || !signedIn) return
var remaining by remember(timeoutSeconds) { mutableStateOf<Long?>(null) }
// A session resumed by a fresh process has seen no action yet: the on-screen timer starts now, while the
// header still states the full age and the server decides from what it recorded.
val shownAt = remember(timeoutSeconds) { UserActivity.now() }
LaunchedEffect(timeoutSeconds)
{
while (true) {
val left = UserActivity.remainingSeconds(timeoutSeconds, shownAt)
if (left <= 0) {
remaining = null
onLogoff()
break
}
remaining = left.takeIf { it <= UserActivity.WARNING_SECONDS }
delay(1000)
}
}
val left = remaining ?: return
// A dialog has its own window: touches in it do not reach the activity, so they are recorded here.
val stay = { UserActivity.record(); remaining = null }
AlertDialog(
onDismissRequest = stay,
confirmButton = { TextButton(onClick = stay) { Text("Stay signed in") } },
title = { Text("Are you still there?") },
text = { Text("You will be signed out in $left ${if (left == 1L) "second" else "seconds"} because of inactivity.") }
)
}
@@ -1,102 +0,0 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/*******************************************************************************
**
*******************************************************************************/
class UserActivityTest {
private var now = 0L
private val systemClock = UserActivity.clock
/*******************************************************************************
**
*******************************************************************************/
@Before
fun useAFakeClock() {
now = 7_200_000L
UserActivity.clock = { now }
UserActivity.reset()
}
/*******************************************************************************
**
*******************************************************************************/
@After
fun restoreTheClock() {
UserActivity.clock = systemClock
UserActivity.reset()
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun beforeAnyUserActionNothingIsAssumedInTheUsersFavour() {
assertEquals(7200L, UserActivity.ageSeconds())
assertTrue(UserActivity.remainingSeconds(900) <= 0)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun ageIsWholeSecondsSinceTheLastUserActionAndNeverNegative() {
UserActivity.record()
assertEquals(0L, UserActivity.ageSeconds())
now += 999
assertEquals(0L, UserActivity.ageSeconds())
now += 60_501
assertEquals(61L, UserActivity.ageSeconds())
now -= 120_000
assertEquals(0L, UserActivity.ageSeconds())
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun timePassingAloneNeverCountsOnlyARecordedActionDoes() {
UserActivity.record()
now += 899_000
assertEquals(1L, UserActivity.remainingSeconds(900))
now += 1_000
assertEquals(0L, UserActivity.remainingSeconds(900))
now += 600_000
assertEquals(-600L, UserActivity.remainingSeconds(900))
UserActivity.record()
assertEquals(900L, UserActivity.remainingSeconds(900))
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun theOnScreenTimerOfAResumedSessionStartsWhenItIsShownTheHeaderDoesNot() {
val shownAt = UserActivity.now()
now += 30_000
assertEquals(870L, UserActivity.remainingSeconds(900, shownAt))
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "7230"), UserActivity.header())
UserActivity.record()
now += 10_000
assertEquals(890L, UserActivity.remainingSeconds(900, shownAt))
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "10"), UserActivity.header())
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun everySessionRequestStatesTheAgeInTheUiApiHeader() {
UserActivity.record()
now += 42_000
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "42"), UserActivity.header())
assertEquals("X-QQQ-User-Activity-Age", UserActivity.HEADER)
assertEquals(60, UserActivity.WARNING_SECONDS)
}
}
@@ -5,4 +5,4 @@ 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] });
await runScannerE2e({ physical: true, serial: args[1], scenario: 'record-photos', fixture: args[3] });