Compare commits

...
Author SHA1 Message Date
james.maes 9bae976138 Merge pull request 'fix(auth): end the identity-provider session on sign-out and force re-authentication on sign-in' (#27) from fix/signout-idp-session into main
android / android (push) Failing after 3m53s
build / android (push) Failing after 3m53s
2026-11-09 20:10:27 +00:00
james.maesandClaude Sonnet 5.5 b87171cafc fix(auth): end the identity-provider session on sign-out and force re-authentication on sign-in
android / android (push) Failing after 3m53s
build / android (push) Failing after 3m53s
Ordinary Sign out cleared only the QQQ session; the provider session left in the
browser silently signed the same person back in (prompt=login alone was already
observed reusing retained SSO on real Authentik). Sign out now runs the provider
end-session flow when the server supports it, and every sign-in also sends
max_age=0. Stored-session restore after sign-out was checked: not a cause.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:10:00 -06:00
james.maes 3b15183f52 Merge pull request 'fix(core): allow 60 s for the metadata load and name the URL on timeout' (#26) from fix/metadata-load-timeout into main
android / android (push) Failing after 3m47s
build / android (push) Failing after 3m47s
2026-11-09 20:01:50 +00:00
james.maes b165dd34a8 Merge pull request 'fix(android): accept string widget icons and summary-only help content' (#24) from fix/widget-icon-string-main into main
android / android (push) Failing after 3m28s
build / android (push) Failing after 3m28s
2026-11-09 20:01:24 +00:00
james.maesandClaude Sonnet 5.5 4467d8ec64 fix(core): allow 60 s for the metadata load and name the URL on timeout
android / android (push) Successful in 15m7s
build / android (push) Successful in 15m7s
GET /qqq/v1/metaData returns about 1.7 MB and takes the server 4 to 8 s to
build; the default 10 s read timeout surfaced as 'Error loading QInstance:
timeout' on handhelds. Only the metadata call gets the long limit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:01:07 -06:00
james.maesandClaude Sonnet 5.5 9208416fa4 test(android): cover string widget icons and summary-only help decoding
android / android (push) Failing after 3m21s
build / android (push) Failing after 3m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 07:20:39 -06:00
james.maesandClaude Sonnet 5.5 778ac58612 fix(android): accept string widget icons and summary-only help content from current QQQ core
android / android (push) Failing after 3m27s
build / android (push) Failing after 3m27s
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 06:19:19 -06:00
james.maes e8b6be00e8 Merge pull request 'fix: restore main (revert commit that deleted the whole tree)' (#23) from fix/restore-main-tree into main
android / android (push) Failing after 3m29s
build / android (push) Failing after 3m29s
2026-11-09 12:19:07 +00:00
james.maesandClaude Sonnet 5.5 089f57f759 Revert "ci: first run on ci-workflows v1 (native workflows)"
android / android (push) Failing after 3m29s
build / android (push) Failing after 3m29s
Commit 1d3847d deleted the entire tree (2584 files) and added nothing, so main was empty. This restores main to dc43a6a (the PR 22 merge) exactly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 06:18:45 -06:00
James MaesandClaude Opus 5.5 1d3847d070 ci: first run on ci-workflows v1 (native workflows)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:19 -06:00
james.maes dc43a6a747 Merge pull request 'ci: build on the shared ci-workflows Android workflow' (#22) from ci/shared-android into main 2026-11-08 13:52:39 +00:00
James MaesandClaude Opus 5.5 21c952ea81 ci: pin the shared workflow to v1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:52:19 -06:00
James MaesandClaude Opus 5.5 3bfafa5bac ci: call the shared workflow from ci-workflows main
android / android (push) Successful in 7m2s
build / android (push) Successful in 7m2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:45:02 -06:00
James MaesandClaude Opus 5.5 8e64d9a83c style: add the missing final newline (K22-CODE-07)
android / android (push) Successful in 6m38s
build / android (push) Successful in 6m38s
The source-standards check flags scanner-device-record-photo-e2e.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:28:48 -06:00
James MaesandClaude Opus 5.5 af38def2d2 ci: build on the shared ci-workflows Android workflow
android / android (push) Failing after 16s
build / android (push) Failing after 16s
Source checks, JVM unit tests, scanner lint, debug and QA APK assembly and
connected emulator tests run on every push via ci-workflows android.yaml.
vX.Y.Z tags publish the SDK AARs to the QRun-IO Gitea Maven registry.
Removes the retired CircleCI/qqq-orb configuration and the legacy GitHub
workflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:28:03 -06:00
james.maes f739a96f7a Merge pull request 'ci: remove workflows that call the retired CI system' (#21) from retire/old-ci into main 2026-11-07 13:29:37 +00:00
james.maesandClaude Opus 5.5 a2f8f2b5fd ci: remove workflows that call the retired CI system
Owner decision 2026-11-07: only KofTwentyTwo/ci-workflows remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:29:36 +00:00
james.maes 5ddd5a3317 Merge pull request 'release(scanner): 1.0.2 (versionCode 4) notes and build instructions' (#20) from release/scanner-1.0.2-notes into main
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 5s
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Android CI / unit-test (push) Successful in 4m7s
Shared Android CI / android-test (push) Successful in 9m44s
Shared Android CI / secrets (push) Successful in 19s
QRun-IO/ci-policy: Shared Android CI / unit-test (push) Successful in 4m10s
QRun-IO/ci-policy: Shared Android CI / android-test (push) Successful in 9m48s
QRun-IO/ci-policy: Shared Android CI / secrets (push) Successful in 20s
Shared Android CI / package (push) Successful in 3m49s
QRun-IO/ci-policy: Shared Android CI / package (push) Successful in 3m40s
Shared Android CI / gate (push) Successful in 14s
QRun Android lifecycle / release (push) Successful in 36m17s
QRun-IO/ci-policy: Shared Android CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / android-0 (push) Successful in 22m19s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
2026-11-07 01:32:24 +00:00
james.maesandClaude Fable 5.1 cf9d53d786 release(scanner): prepare 1.0.2 (versionCode 4) notes and build instructions
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 9s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (push) Successful in 4m6s
QRun-IO/ci-policy: Shared Android CI / android-test (push) Successful in 9m58s
QRun-IO/ci-policy: Shared Android CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 4m6s
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 10m0s
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: Shared Android CI / package (push) Successful in 15s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 15s
QRun-IO/ci-policy: Shared Android CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / android-0 (push) Successful in 41m35s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 27m25s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Supersedes the unpublished signed 1.0.1 (versionCode 3) candidate. The
version is passed at build time (-PscannerVersionName=1.0.2
-PscannerVersionCode=4); the docs now name that pair everywhere 1.0.1 / 3
was the next build. Release notes in CHANGELOG.md: company sign-out now
completes (device-session end ran before provider logout), user name shown
after browser sign-in, app/OS version reported on heartbeat (effective with
mobile-scanners server 1.6.0).

Nothing is built, signed, tagged or published. This branch carries the
local merges of PRs 17 and 18 and must be rebased after they merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 17:25:35 -06:00
29 changed files with 353 additions and 382 deletions

No files matched your search

-14
View File
@@ -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: /.*/
+19
View File
@@ -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
-138
View File
@@ -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/*
-163
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
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. 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.
+14
View File
@@ -1,5 +1,19 @@
# 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
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.
+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. 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; 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
+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)
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.
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.
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. When 1.0.1 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. 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 |
| --- | --- |
+18 -3
View File
@@ -8,8 +8,23 @@
- **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.
@@ -26,7 +41,7 @@ Dated evidence below is preserved. As of November 4: a QRun-owned production sig
## 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.
@@ -34,7 +49,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 (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)
@@ -65,7 +80,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. 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.
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
+8 -4
View File
@@ -1,10 +1,14 @@
# Current status
As of **2026-11-05**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is not a final-release or full-qualification claim. See [Release facts](#release-facts-2026-11-05) below.
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.
## Open fix (2026-11-06, uncommitted branch `fix/refresh-device-versions`)
## Release candidate 1.0.2 (versionCode 4), 2026-11-06
- 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.
- 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).
## Release facts (2026-11-05)
@@ -14,7 +18,7 @@ As of **2026-11-05**. 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 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).
- **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.
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.
- [ ] 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.
- [ ] 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.
+4 -4
View File
@@ -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()
}
/*******************************************************************************
@@ -309,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)
}
}
/*******************************************************************************
@@ -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
) {
@@ -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,
) {
}
@@ -303,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,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)
}
}
+4 -4
View File
@@ -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)
}
}
@@ -132,7 +132,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
/*******************************************************************************
** Route sign-out through explicit process-exit confirmation so pending work is not silently abandoned.
*******************************************************************************/
fun requestSignOut() = requestLeave("Sign out?", "Sign out", alwaysConfirm = true) { qViewModel.requestLogout() }
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() }
/*******************************************************************************
** The same guarded leave/cancel policy applies to explicitly enabled provider sign-out.
@@ -43,6 +43,13 @@ 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.
*******************************************************************************/
@@ -93,7 +100,8 @@ 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).setResponseMode("query").build()
).setScope(binding.scopes).setPrompt(binding.prompt).setAdditionalParameters(OAUTH2_FORCE_REAUTH_PARAMETERS)
.setResponseMode("query").build()
check(request.codeVerifierChallengeMethod == "S256")
if (viewModel.beginOAuth2Browser(
generation, binding, requireNotNull(request.state),
@@ -24,6 +24,7 @@ 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
/*******************************************************************************
@@ -34,10 +35,20 @@ val LocalOpenDiagnostics = compositionLocalOf<(() -> Unit)?> { null }
val LocalBusinessNavigationEnabled = compositionLocalOf { true }
/*******************************************************************************
** Expose account actions while keeping optional company sign-out separate from ordinary local sign-out.
** 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.
*******************************************************************************/
@Composable
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
fun rememberProviderSignOutBinding(model: QViewModel): OAuth2LogoutBinding? {
val companyLogout = if (LocalCompanySignOutEnabled.current)
((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let {
oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName)
@@ -53,6 +64,12 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
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 = {
@@ -60,13 +77,8 @@ 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()
model.qNavigator.requestSignOut(providerSignOut)
}) { 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") } })
}
@@ -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()) }
}
}
}
@@ -13,7 +13,9 @@ 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
@@ -145,12 +147,60 @@ 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): QViewModel {
private fun TestScope.signedInModel(repository: LogoutRepository, store: MockPreferencesDataStore = MockPreferencesDataStore()): QViewModel {
QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER))
val model = QViewModel(repository, MockPreferencesDataStore())
val model = QViewModel(repository, store)
advanceUntilIdle()
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
@@ -182,6 +232,7 @@ 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(
@@ -214,7 +265,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 = QInstance()
override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance { metaDataCalls++; return 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,6 +30,7 @@ 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
@@ -405,6 +406,7 @@ 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("") }
@@ -444,6 +446,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() }) { Text("Sign out") }
Button(onClick = { viewModel.qNavigator.requestSignOut(providerSignOut) }) { Text("Sign out") }
}
}
@@ -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] });