Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bae976138
|
||
|
|
b87171cafc
|
||
|
|
3b15183f52
|
||
|
|
b165dd34a8
|
||
|
|
4467d8ec64
|
||
|
|
9208416fa4
|
||
|
|
778ac58612
|
||
|
|
e8b6be00e8
|
||
|
|
089f57f759
|
||
|
|
1d3847d070 | ||
|
|
dc43a6a747
|
||
|
|
21c952ea81 | ||
|
|
3bfafa5bac | ||
|
|
8e64d9a83c | ||
|
|
af38def2d2 | ||
|
|
f739a96f7a
|
||
|
|
a2f8f2b5fd
|
||
|
|
5ddd5a3317
|
No files matched your search
@@ -1,14 +0,0 @@
|
|||||||
version: 2.1
|
|
||||||
setup: true
|
|
||||||
orbs:
|
|
||||||
qqq-orb: kingsrook/qqq-orb@0.8
|
|
||||||
workflows:
|
|
||||||
setup:
|
|
||||||
jobs:
|
|
||||||
- qqq-orb/generate_pipeline:
|
|
||||||
orb_version: '0.8'
|
|
||||||
filters:
|
|
||||||
branches:
|
|
||||||
ignore: automation/qbit-rc-pending
|
|
||||||
tags:
|
|
||||||
only: /.*/
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags: ['v*']
|
||||||
|
jobs:
|
||||||
|
android:
|
||||||
|
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/android.yaml@v1
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
node-version: '24.11.1'
|
||||||
|
commands: |
|
||||||
|
node scripts/ci/check-source-standards.mjs
|
||||||
|
node --test scripts/ci/scanner-echo-guards.test.mjs
|
||||||
|
tasks: |
|
||||||
|
testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleDebug
|
||||||
|
-PscannerTestBuildType=qa :scanner-host:assembleQa :scanner-host:assembleQaAndroidTest
|
||||||
|
emulator-tasks: connectedDebugAndroidTest
|
||||||
|
publish-tasks: :qqq:android:core:publishAllPublicationsToGiteaRepository :qqq:android:mobileapp:publishAllPublicationsToGiteaRepository
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
name: QRun Android lifecycle
|
|
||||||
'on':
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/android.yaml@qrun-release-v1
|
|
||||||
with:
|
|
||||||
runner: android-build
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
name: Release scanner APK
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['scanner-v*']
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: scanner-release
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
verify:
|
|
||||||
uses: ./.github/workflows/scanner.yml
|
|
||||||
|
|
||||||
package:
|
|
||||||
needs: verify
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 30
|
|
||||||
environment: scanner-release
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Require main ancestry and a strictly newer Android version
|
|
||||||
id: release-version
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
GH_REPO: ${{ github.repository }}
|
|
||||||
SCANNER_VERSION_CODE: ${{ vars.SCANNER_VERSION_CODE }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main
|
|
||||||
[[ "$SCANNER_VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]] || exit 1
|
|
||||||
(( SCANNER_VERSION_CODE <= 2100000000 )) || exit 1
|
|
||||||
gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
|
|
||||||
--jq '.[] | select(.draft == false) | select(.tag_name | startswith("scanner-v")) | .tag_name' > "$RUNNER_TEMP/scanner-prior-tags.txt"
|
|
||||||
while IFS= read -r tag; do
|
|
||||||
[[ "$tag" =~ ^scanner-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]] || exit 1
|
|
||||||
prior_directory=$(mktemp -d "$RUNNER_TEMP/scanner-prior.XXXXXX")
|
|
||||||
gh release download "$tag" --pattern BUILD.txt --dir "$prior_directory"
|
|
||||||
previous=$(sed -n 's/^Version code: //p' "$prior_directory/BUILD.txt")
|
|
||||||
[[ "$previous" =~ ^[1-9][0-9]{0,9}$ ]] || exit 1
|
|
||||||
(( SCANNER_VERSION_CODE > previous )) || { echo 'Version code must exceed every published scanner release'; exit 1; }
|
|
||||||
done < "$RUNNER_TEMP/scanner-prior-tags.txt"
|
|
||||||
printf 'code=%s\n' "$SCANNER_VERSION_CODE" >> "$GITHUB_OUTPUT"
|
|
||||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: '21'
|
|
||||||
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
|
|
||||||
- name: Install Android SDK packages
|
|
||||||
run: |
|
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools"
|
|
||||||
- name: Build and verify signed release
|
|
||||||
env:
|
|
||||||
SCANNER_KEYSTORE_BASE64: ${{ secrets.SCANNER_KEYSTORE_BASE64 }}
|
|
||||||
SCANNER_STORE_PASSWORD: ${{ secrets.SCANNER_STORE_PASSWORD }}
|
|
||||||
SCANNER_KEY_ALIAS: ${{ secrets.SCANNER_KEY_ALIAS }}
|
|
||||||
SCANNER_KEY_PASSWORD: ${{ secrets.SCANNER_KEY_PASSWORD }}
|
|
||||||
SCANNER_CERTIFICATE_SHA256: ${{ vars.SCANNER_CERTIFICATE_SHA256 }}
|
|
||||||
SCANNER_VERSION_CODE: ${{ steps.release-version.outputs.code }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
umask 077
|
|
||||||
version="${GITHUB_REF_NAME#scanner-v}"
|
|
||||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]] || exit 1
|
|
||||||
[[ "$SCANNER_VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]] || exit 1
|
|
||||||
(( SCANNER_VERSION_CODE <= 2100000000 )) || exit 1
|
|
||||||
[[ "$SCANNER_CERTIFICATE_SHA256" =~ ^[A-Fa-f0-9]{64}$ ]] || exit 1
|
|
||||||
for name in SCANNER_KEYSTORE_BASE64 SCANNER_STORE_PASSWORD SCANNER_KEY_ALIAS SCANNER_KEY_PASSWORD; do
|
|
||||||
[[ -n "${!name}" ]] || { echo "Missing release signing configuration: $name"; exit 1; }
|
|
||||||
done
|
|
||||||
export SCANNER_KEYSTORE="$RUNNER_TEMP/scanner-release.jks"
|
|
||||||
trap 'rm -f -- "$SCANNER_KEYSTORE"' EXIT
|
|
||||||
printf '%s' "$SCANNER_KEYSTORE_BASE64" | base64 --decode > "$SCANNER_KEYSTORE"
|
|
||||||
unset SCANNER_KEYSTORE_BASE64
|
|
||||||
./gradlew --no-daemon --console=plain :scanner-host:lintRelease :scanner-host:assembleRelease \
|
|
||||||
"-PscannerVersionName=$version" "-PscannerVersionCode=$SCANNER_VERSION_CODE"
|
|
||||||
mkdir -p release
|
|
||||||
cp scanner-host/build/outputs/apk/release/scanner-host-release.apk "release/qqq-mobile-$version.apk"
|
|
||||||
cp scanner-host/build/reports/third-party/release/{aboutlibraries.json,LICENSE,NOTICE} release/
|
|
||||||
"$ANDROID_HOME/build-tools/35.0.0/apksigner" verify --verbose --print-certs "release/qqq-mobile-$version.apk" > release/signature.txt
|
|
||||||
certificate=$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' release/signature.txt)
|
|
||||||
[[ "${certificate,,}" == "${SCANNER_CERTIFICATE_SHA256,,}" ]] || { echo 'Unexpected signing certificate'; exit 1; }
|
|
||||||
"$ANDROID_HOME/build-tools/35.0.0/aapt" dump badging "release/qqq-mobile-$version.apk" > "$RUNNER_TEMP/scanner-manifest.txt"
|
|
||||||
grep -F "package: name='io.qrun.mobile.scanner' versionCode='$SCANNER_VERSION_CODE' versionName='$version'" "$RUNNER_TEMP/scanner-manifest.txt"
|
|
||||||
if grep -q 'application-debuggable' "$RUNNER_TEMP/scanner-manifest.txt"; then
|
|
||||||
echo 'Refusing a debuggable release'; exit 1
|
|
||||||
fi
|
|
||||||
printf 'Source: %s\nVersion: %s\nVersion code: %s\n' "$GITHUB_SHA" "$version" "$SCANNER_VERSION_CODE" > release/BUILD.txt
|
|
||||||
(cd release && sha256sum -- *.apk signature.txt BUILD.txt aboutlibraries.json LICENSE NOTICE > SHA256SUMS)
|
|
||||||
- name: Retain signed deliverable
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
||||||
with:
|
|
||||||
name: scanner-release-${{ github.sha }}
|
|
||||||
path: release/
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
publish:
|
|
||||||
needs: package
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
|
||||||
with:
|
|
||||||
name: scanner-release-${{ github.sha }}
|
|
||||||
path: release
|
|
||||||
- name: Publish immutable-tag APK assets
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
GH_REPO: ${{ github.repository }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
(cd release && sha256sum --check SHA256SUMS)
|
|
||||||
prerelease=()
|
|
||||||
version="${GITHUB_REF_NAME#scanner-v}"
|
|
||||||
tag_object=$(gh api "repos/$GH_REPO/git/ref/tags/$GITHUB_REF_NAME" --jq '.object.sha')
|
|
||||||
tag_type=$(gh api "repos/$GH_REPO/git/ref/tags/$GITHUB_REF_NAME" --jq '.object.type')
|
|
||||||
for ((attempt=0; attempt<5; attempt++)); do
|
|
||||||
if [[ "$tag_type" == commit ]]; then break; fi
|
|
||||||
[[ "$tag_type" == tag ]] || exit 1
|
|
||||||
tag_json=$(gh api "repos/$GH_REPO/git/tags/$tag_object")
|
|
||||||
tag_object=$(jq -r '.object.sha' <<< "$tag_json")
|
|
||||||
tag_type=$(jq -r '.object.type' <<< "$tag_json")
|
|
||||||
done
|
|
||||||
[[ "$tag_type" == commit && "$tag_object" == "$GITHUB_SHA" ]] || { echo 'Release tag no longer identifies the verified source'; exit 1; }
|
|
||||||
if [[ "$version" == *-* ]]; then prerelease=(--prerelease); fi
|
|
||||||
gh release create "$GITHUB_REF_NAME" --verify-tag --title "QQQ Mobile $version" \
|
|
||||||
--generate-notes "${prerelease[@]}" release/*
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
name: Scanner Android
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, 'feature/**', 'fix/**', 'release/**', 'hotfix/**']
|
|
||||||
pull_request:
|
|
||||||
workflow_call:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
emulator:
|
|
||||||
description: Run native UI and real-server scanner end-to-end tests
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: scanner-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
verify:
|
|
||||||
name: Tests, lint, and debug APK
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: '21'
|
|
||||||
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
|
|
||||||
with:
|
|
||||||
cache-read-only: ${{ github.event_name == 'pull_request' }}
|
|
||||||
- name: Verify source headers, whitespace, imports and JavaScript syntax
|
|
||||||
run: node scripts/ci/check-source-standards.mjs
|
|
||||||
- name: Run no-device Echo fixture guard tests
|
|
||||||
run: node --test scripts/ci/scanner-echo-guards.test.mjs
|
|
||||||
- name: Install required Android SDK packages
|
|
||||||
run: |
|
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools"
|
|
||||||
- name: Verify shared libraries and scanner host
|
|
||||||
run: >-
|
|
||||||
./gradlew --no-daemon --console=plain
|
|
||||||
:qqq:android:core:testDebugUnitTest
|
|
||||||
:qqq:android:mobileapp:testDebugUnitTest
|
|
||||||
:scanner-host:testDebugUnitTest
|
|
||||||
:scanner-host:lintDebug
|
|
||||||
:scanner-host:assembleDebug
|
|
||||||
- name: Compile opt-in physical QA acceptance
|
|
||||||
run: >-
|
|
||||||
./gradlew --no-daemon --console=plain -PscannerTestBuildType=qa
|
|
||||||
:scanner-host:assembleQa :scanner-host:assembleQaAndroidTest
|
|
||||||
- name: Hash debug APK
|
|
||||||
run: |
|
|
||||||
mkdir -p scanner-host/build/outputs/apk/debug/notices
|
|
||||||
cp scanner-host/build/reports/third-party/debug/{aboutlibraries.json,LICENSE,NOTICE} scanner-host/build/outputs/apk/debug/notices/
|
|
||||||
(cd scanner-host/build/outputs/apk/debug && sha256sum scanner-host-debug.apk notices/* > SHA256SUMS)
|
|
||||||
- name: Upload installable development APK
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
||||||
with:
|
|
||||||
name: scanner-debug-${{ github.sha }}
|
|
||||||
path: scanner-host/build/outputs/apk/debug/
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
- name: Upload test and lint evidence
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
||||||
with:
|
|
||||||
name: scanner-checks-${{ github.sha }}
|
|
||||||
path: |
|
|
||||||
qqq/android/core/build/test-results/
|
|
||||||
qqq/android/mobileapp/build/test-results/
|
|
||||||
scanner-host/build/test-results/
|
|
||||||
scanner-host/build/reports/
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
emulator:
|
|
||||||
name: Native UI and real-server scanner E2E
|
|
||||||
if: github.event_name != 'workflow_dispatch' || inputs.emulator
|
|
||||||
needs: verify
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 40
|
|
||||||
env:
|
|
||||||
QQQ_CORE_SHA: ecf0f550a0357cec6387c2b54efca128a405a629
|
|
||||||
SCANNER_SERVER_SHA: 10416938724c9663d66f7848ff2b1a63fe635794
|
|
||||||
MAVEN_OPTS: -XX:ActiveProcessorCount=2 -Xmx768m
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: '21'
|
|
||||||
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
|
|
||||||
- name: Check out the pinned QQQ core source
|
|
||||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
||||||
with:
|
|
||||||
repository: QRun-IO/qqq
|
|
||||||
ref: ${{ env.QQQ_CORE_SHA }}
|
|
||||||
path: .e2e-core
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Check out the pinned real QQQ demo
|
|
||||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
||||||
with:
|
|
||||||
repository: QRun-IO/qbit-mobile-scanners
|
|
||||||
ref: ${{ env.SCANNER_SERVER_SHA }}
|
|
||||||
path: .e2e-server
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Verify and build the isolated source-pinned server fixture
|
|
||||||
env:
|
|
||||||
JAVA_TOOL_OPTIONS: -XX:ActiveProcessorCount=2 -Xmx768m
|
|
||||||
run: |
|
|
||||||
test "$(git -C .e2e-core rev-parse HEAD)" = "$QQQ_CORE_SHA"
|
|
||||||
test "$(git -C .e2e-server rev-parse HEAD)" = "$SCANNER_SERVER_SHA"
|
|
||||||
fixture_m2=$(mktemp -d "$RUNNER_TEMP/scanner-fixture-m2.XXXXXX")
|
|
||||||
core_revision="4.1.0-ci.$QQQ_CORE_SHA"
|
|
||||||
(cd .e2e-core && ./mvnw --batch-mode --no-transfer-progress \
|
|
||||||
-Dmaven.repo.local="$fixture_m2" -Drevision="$core_revision" \
|
|
||||||
-pl qqq-bom,qqq-middleware-api -am clean install)
|
|
||||||
(cd .e2e-server && ./mvnw --batch-mode --no-transfer-progress \
|
|
||||||
-Dmaven.repo.local="$fixture_m2" -Dqqq.version="$core_revision" clean install)
|
|
||||||
(cd .e2e-server && ./mvnw --batch-mode --no-transfer-progress -f demo/pom.xml \
|
|
||||||
-Dmaven.repo.local="$fixture_m2" -Dqqq.version="$core_revision" clean verify \
|
|
||||||
dependency:build-classpath -DincludeScope=runtime -Dmdep.outputFile=target/e2e-classpath.txt \
|
|
||||||
dependency:tree -Dscope=runtime -DoutputFile=target/fixture-dependencies.txt \
|
|
||||||
help:effective-pom -Doutput=target/fixture-effective-pom.xml)
|
|
||||||
node scripts/ci/verify-fixture-provenance.mjs .e2e-server "$fixture_m2" "$QQQ_CORE_SHA" "$SCANNER_SERVER_SHA"
|
|
||||||
- name: Enable hosted-runner hardware acceleration
|
|
||||||
run: |
|
|
||||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
|
||||||
sudo udevadm control --reload-rules
|
|
||||||
sudo udevadm trigger --name-match=kvm
|
|
||||||
- name: Run emulator checks
|
|
||||||
uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
|
||||||
with:
|
|
||||||
api-level: 35
|
|
||||||
target: google_apis
|
|
||||||
arch: x86_64
|
|
||||||
disable-animations: true
|
|
||||||
script: |
|
|
||||||
./gradlew --no-daemon --console=plain :qqqsampleandroidmobileapp:connectedDebugAndroidTest :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest
|
|
||||||
node scripts/ci/scanner-e2e.mjs .e2e-server
|
|
||||||
- name: Upload emulator evidence
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
||||||
with:
|
|
||||||
name: scanner-emulator-${{ github.sha }}
|
|
||||||
include-hidden-files: true
|
|
||||||
path: |
|
|
||||||
qqqsampleandroidmobileapp/build/reports/androidTests/
|
|
||||||
qqqsampleandroidmobileapp/build/outputs/androidTest-results/
|
|
||||||
scanner-host/build/native-e2e/
|
|
||||||
.e2e-server/demo/target/fixture-provenance.json
|
|
||||||
.e2e-server/demo/target/fixture-dependencies.txt
|
|
||||||
.e2e-server/demo/target/fixture-effective-pom.xml
|
|
||||||
.e2e-core/**/target/surefire-reports/TEST-*.xml
|
|
||||||
.e2e-server/**/target/surefire-reports/TEST-*.xml
|
|
||||||
retention-days: 14
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
profile: android
|
|
||||||
orb_version: '0.8'
|
|
||||||
default_branch: main
|
|
||||||
@@ -24,4 +24,4 @@ Never log or place passwords, session cookies, or device tokens in process value
|
|||||||
|
|
||||||
## Repository automation
|
## Repository automation
|
||||||
|
|
||||||
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive, so make no ordinary GitHub writes. Protected `main` on Gitea has `.gitea/workflows/qrun-android-release.yaml` calling the shared `KofTwentyTwo/platform-ci` `android.yaml@qrun-release-v1`; pull-request checks come from central Gitea policy, not from that release workflow. It is not a scanner-APK signing pipeline, and no automatic signing CI is configured. CircleCI is managed by `kingsrook/qqq-orb` using `.qqq-ci.yml`; preserve that lane and its main-only library publication. The legacy `.github` `Scanner Android` workflow builds a debug APK artifact and never publishes libraries or production releases; it and the scanner release workflow are not a selected release path. The owner has authorized production signing with the QRun-owned key `qqq-mobile-release` (details and fingerprint in `docs/RELEASING.md`) for signed-APK/MDM distribution. Do not create replacement or additional signing keys, put key material, passwords or vault item details in the repository, or use the legacy GitHub release workflows; create no tags or releases without current authorization. Do not replace managed CI with a stale local template.
|
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive, so make no ordinary GitHub writes. `.gitea/workflows/build.yaml` calls the shared `KofTwentyTwo/ci-workflows` Android workflow (`android.yaml@v1`) on every push: source checks, JVM unit tests, scanner lint, debug and QA APK assembly, and connected tests on an emulator (android-build runners). A `vX.Y.Z` tag also publishes the SDK AARs (`qqq-android-core`, `qqq-android-mobileapp`) to the QRun-IO Gitea Maven registry. It is not a scanner-APK signing pipeline, and no automatic signing CI is configured. The owner has authorized production signing with the QRun-owned key `qqq-mobile-release` (details and fingerprint in `docs/RELEASING.md`) for signed-APK/MDM distribution. Do not create replacement or additional signing keys, or put key material, passwords or vault item details in the repository; create no tags or releases without current authorization.
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
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.
|
||||||
|
|||||||
+2
-2
@@ -23,9 +23,9 @@ The smoke script clears application data only after verifying the target is an e
|
|||||||
|
|
||||||
## CI and delivery
|
## 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.
|
Production signing and distribution require a separately configured release procedure. Do not treat the development APK as a production release.
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Limits: JVM, emulator and synthetic-scan tests do not prove physical trigger, li
|
|||||||
|
|
||||||
## CI and automation
|
## 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
|
## Extension points
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -41,7 +41,7 @@ Dated evidence below is preserved. As of November 4: a QRun-owned production sig
|
|||||||
|
|
||||||
## What exists today
|
## 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`.
|
- **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.
|
- 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.
|
- None set: `assembleRelease` produces an **unsigned** APK, which is what the isolated signing step consumes.
|
||||||
@@ -49,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.
|
- 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`).
|
- 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.
|
- **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)
|
## First release: 1.0.0 (versionCode 2)
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ plugins {
|
|||||||
publishing {
|
publishing {
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
maven {
|
||||||
name = "githubQqqMavenRegistry"
|
// Gitea Maven registry of the repository owner. CI sets giteaMavenUrl, giteaUsername
|
||||||
url = uri("https://maven.pkg.github.com/QRun-IO/qqq-maven-registry")
|
// and giteaPassword (ci-workflows android.yaml, publish-tasks on vX.Y.Z tags).
|
||||||
// username and password (a personal Github access token) should be specified as
|
name = "gitea"
|
||||||
// `githubPackagesUsername` and `githubPackagesPassword` Gradle properties
|
url = uri(providers.gradleProperty("giteaMavenUrl").getOrElse("https://git.kof22.com/api/packages/QRun-IO/maven"))
|
||||||
credentials(PasswordCredentials::class)
|
credentials(PasswordCredentials::class)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-9
@@ -44,18 +44,30 @@ import okhttp3.RequestBody
|
|||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InterruptedIOException
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
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 sessionUUIDs = ConcurrentHashMap<String, String>()
|
||||||
private val retroJson = Json { ignoreUnknownKeys = true }
|
private val retroJson = Json { ignoreUnknownKeys = true }
|
||||||
private val compatibilityByServer = ConcurrentHashMap<String, UiApiCompatibility>()
|
private val compatibilityByServer = ConcurrentHashMap<String, UiApiCompatibility>()
|
||||||
|
|
||||||
private val qqqApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
|
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
|
baseUrl = environment.baseUrl
|
||||||
|
|
||||||
if (qqqApiServiceMap[baseUrl] == null) {
|
if (qqqApiServiceMap[baseUrl] == null) {
|
||||||
val thisRetrofit = buildRetrofit(baseUrl)
|
val client = buildClient(baseUrl)
|
||||||
val thisQQQApiService = thisRetrofit.create(QQQApiService::class.java)
|
qqqApiServiceMap[baseUrl] = buildRetrofit(baseUrl, client).create(QQQApiService::class.java)
|
||||||
qqqApiServiceMap[baseUrl] = thisQQQApiService
|
val metaDataClient = client.newBuilder()
|
||||||
|
.callTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||||
|
.readTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
metaDataApiServiceMap[baseUrl] = buildRetrofit(baseUrl, metaDataClient).create(QQQApiService::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrofit = buildRetrofit()
|
// 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()
|
return Retrofit.Builder()
|
||||||
.addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType()))
|
.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()
|
OkHttpClient.Builder()
|
||||||
.followRedirects(false)
|
.followRedirects(false)
|
||||||
.followSslRedirects(false)
|
.followSslRedirects(false)
|
||||||
@@ -110,8 +135,6 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
|||||||
.addInterceptor(SameOriginInterceptor())
|
.addInterceptor(SameOriginInterceptor())
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.baseUrl(baseUrl)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
@@ -309,7 +332,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
|||||||
override suspend fun getMetaData(
|
override suspend fun getMetaData(
|
||||||
frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String
|
frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String
|
||||||
): QInstance {
|
): 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
|
|||||||
+3
-2
@@ -27,8 +27,9 @@ import kotlinx.serialization.Serializable
|
|||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class QHelpContent(
|
data class QHelpContent(
|
||||||
val content: String,
|
val content: String? = null, // QQQ core now sends summary-only help entries
|
||||||
val format: String,
|
val summary: String? = null,
|
||||||
|
val format: String? = null,
|
||||||
val roles: Set<String> = emptySet(),
|
val roles: Set<String> = emptySet(),
|
||||||
val contentAsText: String? = null
|
val contentAsText: String? = null
|
||||||
) {
|
) {
|
||||||
|
|||||||
+2
-1
@@ -23,6 +23,7 @@
|
|||||||
package com.kingsrook.qqq.frontend.android.core.model.metadata
|
package com.kingsrook.qqq.frontend.android.core.model.metadata
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
@@ -30,7 +31,7 @@ import kotlinx.serialization.Serializable
|
|||||||
data class QWidgetMetaData(
|
data class QWidgetMetaData(
|
||||||
val name: String,
|
val name: String,
|
||||||
val label: String? = null,
|
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,
|
val type: String,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
+56
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
* QQQ - Low-code Application Framework for Engineers.
|
||||||
|
* Copyright (C) 2004-2024. Kingsrook, LLC
|
||||||
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||||
|
* contact@kingsrook.com
|
||||||
|
* https://github.com/Kingsrook/
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.kingsrook.qqq.frontend.android.core.model.metadata
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Current QQQ core serializes widget icons as plain strings and sends
|
||||||
|
** summary-only help entries; the decoder must accept those shapes and the
|
||||||
|
** older object/content shapes.
|
||||||
|
*******************************************************************************/
|
||||||
|
class MetadataDecodingTest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun widgetIconAsStringDecodes() {
|
||||||
|
val widget = json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table","icon":"troubleshoot"}""")
|
||||||
|
assertEquals(JsonPrimitive("troubleshoot"), widget.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun widgetIconAsObjectStillDecodes() {
|
||||||
|
val widget = json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table","icon":{"name":"troubleshoot"}}""")
|
||||||
|
assertTrue(widget.icon is JsonObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun widgetWithoutIconDecodes() {
|
||||||
|
assertNull(json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table"}""").icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun summaryOnlyHelpDecodes() {
|
||||||
|
val help = json.decodeFromString<QHelpContent>("""{"summary":"short help","roles":["ALL_SCREENS"]}""")
|
||||||
|
assertEquals("short help", help.summary)
|
||||||
|
assertNull(help.content)
|
||||||
|
assertNull(help.format)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun contentHelpStillDecodes() {
|
||||||
|
val help = json.decodeFromString<QHelpContent>("""{"content":"body","format":"TEXT"}""")
|
||||||
|
assertEquals("body", help.content)
|
||||||
|
assertEquals("TEXT", help.format)
|
||||||
|
assertNull(help.summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,10 +30,10 @@ plugins {
|
|||||||
publishing {
|
publishing {
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
maven {
|
||||||
name = "githubQqqMavenRegistry"
|
// Gitea Maven registry of the repository owner. CI sets giteaMavenUrl, giteaUsername
|
||||||
url = uri("https://maven.pkg.github.com/QRun-IO/qqq-maven-registry")
|
// and giteaPassword (ci-workflows android.yaml, publish-tasks on vX.Y.Z tags).
|
||||||
// username and password (a personal Github access token) should be specified as
|
name = "gitea"
|
||||||
// `githubPackagesUsername` and `githubPackagesPassword` Gradle properties
|
url = uri(providers.gradleProperty("giteaMavenUrl").getOrElse("https://git.kof22.com/api/packages/QRun-IO/maven"))
|
||||||
credentials(PasswordCredentials::class)
|
credentials(PasswordCredentials::class)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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.
|
** 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.
|
** The same guarded leave/cancel policy applies to explicitly enabled provider sign-out.
|
||||||
|
|||||||
+9
-1
@@ -43,6 +43,13 @@ import kotlin.coroutines.resumeWithException
|
|||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
val LocalOAuth2Discovery = staticCompositionLocalOf<suspend (OAuth2LoginBinding) -> AuthorizationServiceConfiguration> { ::discoverOAuth2 }
|
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.
|
** 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(
|
val request = AuthorizationRequest.Builder(
|
||||||
configuration, binding.clientId, ResponseTypeValues.CODE,
|
configuration, binding.clientId, ResponseTypeValues.CODE,
|
||||||
Uri.parse(binding.redirectUri)
|
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")
|
check(request.codeVerifierChallengeMethod == "S256")
|
||||||
if (viewModel.beginOAuth2Browser(
|
if (viewModel.beginOAuth2Browser(
|
||||||
generation, binding, requireNotNull(request.state),
|
generation, binding, requireNotNull(request.state),
|
||||||
|
|||||||
+20
-8
@@ -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.mobileapp.viewmodel.QViewModel
|
||||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData
|
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.LocalCompanySignOutEnabled
|
||||||
|
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
|
||||||
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 }
|
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
|
@Composable
|
||||||
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
|
fun rememberProviderSignOutBinding(model: QViewModel): OAuth2LogoutBinding? {
|
||||||
val companyLogout = if (LocalCompanySignOutEnabled.current)
|
val companyLogout = if (LocalCompanySignOutEnabled.current)
|
||||||
((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let {
|
((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let {
|
||||||
oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName)
|
oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName)
|
||||||
@@ -53,6 +64,12 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
|
|||||||
supportsCompanyLogout = false
|
supportsCompanyLogout = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return providerSignOutBinding(companyLogout, supportsCompanyLogout)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
|
||||||
|
val providerSignOut = rememberProviderSignOutBinding(model)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) },
|
onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) },
|
||||||
text = {
|
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")
|
Text(if (model.isAuthenticated) "Current User: ${model.sessionUserFullName}" else "Not currently logged in")
|
||||||
TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = {
|
TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = {
|
||||||
dismiss()
|
dismiss()
|
||||||
model.qNavigator.requestSignOut()
|
model.qNavigator.requestSignOut(providerSignOut)
|
||||||
}) { Text("Sign out") }
|
}) { 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") } })
|
}, confirmButton = { TextButton(onClick = dismiss) { Text("Close") } })
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -128,7 +128,7 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro
|
|||||||
OutlinedButton(onClick = { processViewModel.loadRemoteOptions(field) }) { Text("Retry choices") }
|
OutlinedButton(onClick = { processViewModel.loadRemoteOptions(field) }) { Text("Retry choices") }
|
||||||
}
|
}
|
||||||
semanticFieldHint(field)?.let { Text(it) }
|
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()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-3
@@ -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.core.model.processes.ProcessStepResult
|
||||||
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
|
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.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.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.OAuth2LogoutBinding
|
||||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase
|
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -145,12 +147,60 @@ class QViewModelLogoutTest {
|
|||||||
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName)
|
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.
|
** 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))
|
QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER))
|
||||||
val model = QViewModel(repository, MockPreferencesDataStore())
|
val model = QViewModel(repository, store)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
|
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
|
||||||
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
|
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
|
||||||
@@ -182,6 +232,7 @@ class QViewModelLogoutTest {
|
|||||||
private class LogoutRepository : QQQRepository {
|
private class LogoutRepository : QQQRepository {
|
||||||
val calls = mutableListOf<String>()
|
val calls = mutableListOf<String>()
|
||||||
var activeSession = ""
|
var activeSession = ""
|
||||||
|
var metaDataCalls = 0
|
||||||
|
|
||||||
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData(
|
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData(
|
||||||
"oauth", values = OAuth2AuthenticationMetaData.Values(
|
"oauth", values = OAuth2AuthenticationMetaData.Values(
|
||||||
@@ -214,7 +265,7 @@ class QViewModelLogoutTest {
|
|||||||
|
|
||||||
override fun setEnvironment(environment: Environment) {}
|
override fun setEnvironment(environment: Environment) {}
|
||||||
override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Not used")
|
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 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 processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
|
||||||
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
|
override suspend fun 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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled
|
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.LaunchedEffect
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
@@ -405,6 +406,7 @@ private fun DeviceGate(
|
|||||||
viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, onApproval: (Boolean) -> Unit,
|
viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, onApproval: (Boolean) -> Unit,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val providerSignOut = rememberProviderSignOutBinding(viewModel)
|
||||||
var attempt by remember { mutableIntStateOf(0) }
|
var attempt by remember { mutableIntStateOf(0) }
|
||||||
var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") }
|
var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") }
|
||||||
var error by remember { mutableStateOf("") }
|
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.")
|
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") }
|
Button(enabled = status != "CHECKING", onClick = { attempt++ }) { Text("Check approval") }
|
||||||
if (enrollmentConflict) Button(onClick = { credentials.restartPendingEnrollment(); attempt++ }) { Text("Start new registration") }
|
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]) {
|
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');
|
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] });
|
||||||
Reference in new issue
Block a user