From dfdbb74d211f87f9c4b064d68da76731ea8266b6 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 29 Oct 2026 14:16:19 -0500 Subject: [PATCH 01/39] feat: add native scanner host and Android verification workflows --- .editorconfig | 18 ++ .gitattributes | 5 + .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 25 ++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.md | 17 ++ .github/PULL_REQUEST_TEMPLATE.md | 14 + .github/dependabot.yml | 14 + .github/workflows/scanner.yml | 108 +++++++ AGENTS.md | 23 ++ CHANGELOG.md | 9 + CODE_OF_CONDUCT.md | 3 + CONTRIBUTING.md | 32 +- README.md | 145 +++------ SECURITY.md | 9 + docs/EMULATOR.md | 31 ++ docs/SESSION-STATE.md | 11 + docs/TODO.md | 11 + gradle/wrapper/gradle-wrapper.properties | 1 + qqq/android/core/build.gradle.kts | 3 +- .../core/controllers/NetworkQQQRepository.kt | 74 +++-- .../android/core/controllers/QQQApiService.kt | 8 + .../android/core/controllers/QQQRepository.kt | 7 + .../AuthenticationSerializer.kt | 3 +- .../authentication/ManageSessionResponse.kt | 4 +- .../TableBasedAuthenticationMetaData.kt | 16 + .../core/model/processes/QJobComplete.kt | 2 +- .../controllers/NetworkQQQRepositoryTest.kt | 70 +++++ .../android/core/model/MobileProtocolTest.kt | 53 ++++ .../test/resources/mobile-contract/init.json | 27 ++ .../resources/mobile-contract/metadata.json | 287 ++++++++++++++++++ .../resources/mobile-contract/process.json | 1 + qqq/android/mobileapp/build.gradle.kts | 3 +- .../android/mobileapp/ui/HostExtensions.kt | 20 ++ .../ui/authentication/QAuth0Login.kt | 2 +- .../authentication/QAuthenticationWrapper.kt | 3 +- .../mobileapp/ui/authentication/QLoginForm.kt | 2 + .../ui/authentication/QLogoutForm.kt | 7 +- .../ui/authentication/QPasswordLogin.kt | 45 +++ .../mobileapp/ui/processes/QProcessHome.kt | 2 + .../widgets/blocks/WidgetBlockComposable.kt | 8 +- .../mobileapp/viewmodel/ProcessViewModel.kt | 49 ++- .../android/mobileapp/viewmodel/QViewModel.kt | 82 +++-- .../sampleandroidmobileapp/SessionTests.kt | 4 +- scanner-host/.gitignore | 1 + scanner-host/build.gradle.kts | 42 +++ scanner-host/src/debug/AndroidManifest.xml | 5 + scanner-host/src/main/AndroidManifest.xml | 15 + .../java/io/qrun/mobile/scanner/DeviceApi.kt | 43 +++ .../qrun/mobile/scanner/DeviceCredentials.kt | 83 +++++ .../java/io/qrun/mobile/scanner/ScanEvent.kt | 21 ++ .../io/qrun/mobile/scanner/ScannerActivity.kt | 209 +++++++++++++ .../io/qrun/mobile/scanner/ScannerWidget.kt | 115 +++++++ .../src/main/res/drawable/ic_scanner.xml | 5 + .../main/res/xml/data_extraction_rules.xml | 17 ++ .../io/qrun/mobile/scanner/ScanEventTest.kt | 18 ++ .../io/qrun/mobile/scanner/ServerUrlTest.kt | 24 ++ scripts/ci/emulator-smoke.sh | 29 ++ settings.gradle.kts | 1 + 59 files changed, 1725 insertions(+), 170 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/scanner.yml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SECURITY.md create mode 100644 docs/EMULATOR.md create mode 100644 docs/SESSION-STATE.md create mode 100644 docs/TODO.md create mode 100644 qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/TableBasedAuthenticationMetaData.kt create mode 100644 qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt create mode 100644 qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/MobileProtocolTest.kt create mode 100644 qqq/android/core/src/test/resources/mobile-contract/init.json create mode 100644 qqq/android/core/src/test/resources/mobile-contract/metadata.json create mode 100644 qqq/android/core/src/test/resources/mobile-contract/process.json create mode 100644 qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/HostExtensions.kt create mode 100644 qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt create mode 100644 scanner-host/.gitignore create mode 100644 scanner-host/build.gradle.kts create mode 100644 scanner-host/src/debug/AndroidManifest.xml create mode 100644 scanner-host/src/main/AndroidManifest.xml create mode 100644 scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt create mode 100644 scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceCredentials.kt create mode 100644 scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt create mode 100644 scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt create mode 100644 scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt create mode 100644 scanner-host/src/main/res/drawable/ic_scanner.xml create mode 100644 scanner-host/src/main/res/xml/data_extraction_rules.xml create mode 100644 scanner-host/src/test/java/io/qrun/mobile/scanner/ScanEventTest.kt create mode 100644 scanner-host/src/test/java/io/qrun/mobile/scanner/ServerUrlTest.kt create mode 100644 scripts/ci/emulator-smoke.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3aef2a4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 3 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[*.{cmd,bat}] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..700d31a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.jar binary +*.png binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..e7d84f9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @KofTwentyTwo diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..02914d8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug report +about: Report an Android client or scanner problem +labels: bug +--- + +## What happened? + +Describe expected and actual behavior. + +## Reproduction + +1. +2. + +## Environment + +- APK version/build: +- QQQ server/qbit version: +- Android version and device model: +- DataWedge version/profile settings, if applicable: + +## Evidence + +Use synthetic barcodes. Remove credentials, device tokens, session cookies, and personal/business data from screenshots and logs. Report security vulnerabilities privately using SECURITY.md. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..94d57c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Questions and discussion + url: https://github.com/orgs/QRun-IO/discussions + about: Ask the QQQ community about application development. + - name: Security reporting + url: https://github.com/QRun-IO/qqq-android/blob/main/SECURITY.md + about: Report vulnerabilities privately rather than opening a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..5dde7d2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Describe a mobile workflow or Android capability +labels: enhancement +--- + +## User workflow + +Who needs this, and what are they trying to do? + +## Proposed behavior + +Describe the screens, scanner interaction, or QQQ process behavior. + +## Scope and acceptance + +List target devices and concrete examples of success. Use synthetic data. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..02d12da --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Summary + +Describe the user-visible change and link related issues. + +## Validation + +- [ ] Shared-library and scanner JVM tests passed +- [ ] Scanner lint and debug APK build passed +- [ ] Native emulator tests or manual UI verification completed when relevant +- [ ] TC22/DataWedge behavior verified when relevant, or the remaining hardware check is stated + +## Compatibility and rollout + +Describe API/metadata changes, credential or enrollment changes, and any required server upgrade. Include screenshots for UI changes. Never include passwords, session cookies, device tokens, or real barcode data. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cccd359 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: gradle + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 3 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ['*'] diff --git a/.github/workflows/scanner.yml b/.github/workflows/scanner.yml new file mode 100644 index 0000000..1174ccd --- /dev/null +++ b/.github/workflows/scanner.yml @@ -0,0 +1,108 @@ +name: Scanner Android + +on: + push: + branches: [main, 'feature/**', 'fix/**', 'release/**', 'hotfix/**'] + pull_request: + workflow_dispatch: + inputs: + emulator: + description: Run native UI tests and scanner launch smoke on an Android emulator + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: scanner-${{ 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: 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: Hash debug APK + run: sha256sum scanner-host/build/outputs/apk/debug/scanner-host-debug.apk > scanner-host/build/outputs/apk/debug/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 scanner launch + if: github.event_name == 'workflow_dispatch' && inputs.emulator + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 40 + 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: 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:assembleDebug + bash scripts/ci/emulator-smoke.sh + - name: Upload emulator evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: scanner-emulator-${{ github.sha }} + path: | + qqqsampleandroidmobileapp/build/reports/androidTests/ + qqqsampleandroidmobileapp/build/outputs/androidTest-results/ + scanner-host/build/emulator-smoke/ + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e63c810 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Repository guidance + +Read `README.md`, `docs/SESSION-STATE.md`, and `docs/TODO.md` for current scope. Preserve unrelated changes and follow the user's current authorization for commits, publication, and deployment. + +## Build and tests + +Use the included Gradle wrapper, Java21, and Android SDK35. `local.properties`, build outputs, SDK paths, and credentials are local-only. + +```text +./gradlew :qqq:android:core:testDebugUnitTest :qqq:android:mobileapp:testDebugUnitTest :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleDebug +``` + +Run `:qqqsampleandroidmobileapp:connectedDebugAndroidTest` on an emulator for changes to native authentication/navigation/process rendering. `scripts/ci/emulator-smoke.sh` deliberately refuses physical devices and clears only the disposable emulator's scanner application data. Physical TC22 tests remain necessary for DataWedge integration. + +## Design and safety + +Reuse the existing `core` and `mobileapp` libraries and native QQQ metadata/process contracts. The scanner host adds enrollment and scanner widgets; do not introduce a competing screen engine. Preserve raw barcode strings, event identity across retries, server acknowledgments, and server-side device authorization. + +Never log or place passwords, session cookies, or device tokens in process values, test fixtures, or documentation. Keep enrollment credentials and sessions isolated by server. Keep HTTP support limited to debug builds. Test resources under `mobile-contract` are sanitized real demo responses. + +## Repository automation + +CircleCI is managed by `kingsrook/qqq-orb` using `.qqq-ci.yml`; preserve that lane and its main-only library publication. GitHub `Scanner Android` checks build and deliver a debug APK artifact and never publish libraries or production releases. Do not replace managed CI with a stale local template. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..80aa47a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## Unreleased + +- Add the QQQ Mobile scanner host for metadata-defined native apps and processes. +- Add administrator-approved device enrollment and Zebra DataWedge single-barcode capture. +- Add table-backed login, current QQQ HttpOnly session handling, and server-side logout. +- Preserve pending scan identity for explicit retry and require a matching server acknowledgment. +- Add scanner APK/test/lint CI and an optional emulator workflow alongside managed library CI. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c1b2a88 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Code of conduct + +This project follows the [QRun-IO Contributor Covenant Code of Conduct](https://github.com/QRun-IO/.github/blob/main/CODE_OF_CONDUCT.md), including its reporting and enforcement process. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92e232d..214d903 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,34 @@ # Contributing to QQQ Android -See the [Contributing Guidelines for QQQ](https://github.com/Kingsrook/qqq/blob/dev/CONTRIBUTING.md) +Follow the [QRun-IO contribution guidelines](https://github.com/QRun-IO/.github/blob/main/CONTRIBUTING.md) and this repository's existing Kotlin conventions. Use a `feature/`, `fix/`, `docs/`, or `chore/` branch from `main`, and open a pull request to `main`. Use Conventional Commits and include tests and documentation for changed behavior. +## Local verification + +Install Java21 and Android SDK35, then use the included Gradle wrapper: + +```text +./gradlew :qqq:android:core:testDebugUnitTest :qqq:android:mobileapp:testDebugUnitTest :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleDebug +``` + +On Windows use `gradlew.bat`. Keep SDK locations in ignored `local.properties` or environment variables. Do not commit APKs, signing keys, passwords, session cookies, device tokens, or real barcode data. + +For authentication/navigation/process UI changes, run the native emulator suite: + +```text +./gradlew :qqqsampleandroidmobileapp:connectedDebugAndroidTest +bash scripts/ci/emulator-smoke.sh +``` + +The smoke script clears application data only after verifying the target is an emulator. See [emulator testing](docs/EMULATOR.md). DataWedge trigger behavior still needs a physical Zebra handheld. + +## 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. + +The GitHub **Scanner Android** workflow runs shared-library/scanner JVM tests, scanner lint, and debug APK assembly for pull requests and supported branches. It uploads the development APK with SHA256 and verification reports. **Run workflow** optionally adds native emulator tests and a scanner launch screenshot. This workflow never publishes Maven artifacts, signs a production APK, or deploys to a device/store. + +Production signing and distribution require a separately configured release procedure. Do not treat the development APK as a production release. + +## Review and security + +Request review from the repository code owner. Explain compatibility changes between Android metadata/process contracts and the companion qbit. Use synthetic fixtures and preserve raw barcode values and retry identities. See [SECURITY.md](SECURITY.md) for private reporting and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community expectations. diff --git a/README.md b/README.md index c911f21..5fae27c 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,75 @@ -# qqq-android +# QQQ Android -[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Scanner Android](https://github.com/QRun-IO/qqq-android/actions/workflows/scanner.yml/badge.svg)](https://github.com/QRun-IO/qqq-android/actions/workflows/scanner.yml) -Android client library for QQQ applications. +Native Kotlin/Compose clients for QQQ. The new `scanner-host` application runs server-defined QQQ apps and processes on Android handhelds, initially Zebra TC22. -**For:** Developers building native Android apps that connect to QQQ backends -**Status:** Experimental +## Modules -## Why This Exists +- `qqq/android/core`: metadata and process HTTP client (`QQQRepository` / `NetworkQQQRepository`). +- `qqq/android/mobileapp`: native app picker, authentication, process runner, and widget renderer. +- `scanner-host`: server configuration, device registration/approval gate, and DataWedge barcode capture. +- `qqqsampleandroidmobileapp`: historical example application. -QQQ applications expose REST APIs that any client can consume. For Android apps that need to interact with QQQ backends, this library provides typed clients and UI components that understand QQQ's metadata model. +The client supports metadata and process execution. It does not implement the record CRUD/fluent API previously described by this README. -## Features +## First scanner application -- **API Client** - Typed client for QQQ REST endpoints -- **Metadata Loading** - Fetch table and field definitions -- **Record Operations** - Query, insert, update, delete records -- **Process Execution** - Run backend processes from Android +Use the companion `qbit-mobile-scanners` server/demo with QQQ `4.1.0-RC.20`. The demo uses QQQ TABLE_BASED username/password authentication. Credentials are exchanged through the native QQQ session endpoint; the password is not stored. -## Quick Start +1. Start the companion demo and install the debug APK. +2. Run `adb reverse tcp:8080 tcp:8080` for a USB-connected TC22. +3. Open QQQ Mobile, use `http://127.0.0.1:8080/`, and sign in. +4. An administrator approves the displayed device ID in QQQ. +5. Tap **Check approval**, select the server-provided mobile app, and open **Scan capture**. +6. Scan a barcode and wait for **Saved scan** before scanning again. Manual entry is available for development and non-Zebra devices. -### Prerequisites +The server defines the app, native process steps, and `SCANNER` widget; the APK does not hard-code a scan process name. Existing native widget types continue to work. -- Android Studio -- Android SDK 26+ -- QQQ backend running +## Zebra DataWedge profile -### Installation +Create a profile associated with `io.qrun.mobile.scanner`, activity `*`: -Add to `build.gradle`: +- Enable Barcode input and single-barcode decoding. +- Disable Keystroke output. +- Enable Intent output with action `io.qrun.mobile.scanner.SCAN`. +- Set category `android.intent.category.DEFAULT` and delivery to Broadcast. +- Target package `io.qrun.mobile.scanner` when package targeting is available. -```groovy -dependencies { - implementation 'io.qrun:qqq-android:0.1.0' -} +The receiver is active only while a scanner widget is visible and its activity is resumed. It reads `com.symbol.datawedge.data_string`, `label_type`, and `source`. Multi-decode payloads are rejected. Symbology/source are client-reported metadata, not proof of scanner hardware identity. See [Zebra Intent output](https://techdocs.zebra.com/datawedge/latest/guide/output/intent/). + +## Online-only behavior + +There is no offline queue. One submitted scan is retained in memory until its `acknowledgedEventId` is returned. If a response is uncertain, **Retry same request** preserves its event ID and original timestamp; the server deduplicates it. Further scans are blocked until the pending request is resolved. Sign-out/reset clears the pending request; app termination loses in-memory state. Repeated physical scans deliberately receive distinct event IDs. + +Enrollment credentials are encrypted with Android Keystore and stored under `noBackupFilesDir`, scoped to the configured server. The app disables backup. Device headers accompany native QQQ requests, and the server enforces approval on every scan. Release builds reject HTTP; only the development build permits it for local testing. The host disables HTTP redirects to avoid forwarding credentials to another origin. + +## Build + +Requirements: Java 17 or compatible newer runtime, Android SDK platform35, build tools34/35, and the included Gradle8.7 wrapper. Configure `ANDROID_HOME` or ignored `local.properties`. + +```powershell +.\gradlew.bat :scanner-host:testDebugUnitTest :scanner-host:assembleDebug :scanner-host:lintDebug ``` -### Configuration +APK: `scanner-host/build/outputs/apk/debug/scanner-host-debug.apk`. -```kotlin -val client = QqqClient.Builder() - .baseUrl("https://your-qqq-app.com") - .build() -``` +The existing libraries also have unit and emulator tests. `testDebugUnitTest` runs JVM tests; `connectedDebugAndroidTest` needs a connected device/emulator. Physical TC22 trigger/intent behavior must still be verified on hardware. -### Basic Usage +See [emulator testing](docs/EMULATOR.md) for a local interactive demo, synthetic scanner broadcasts, and the disposable-emulator smoke check. -```kotlin -// Fetch records -val orders = client.query("order") - .filter("status", "pending") - .limit(20) - .execute() +## CI and delivery -// Create record -val newOrder = client.insert("order", mapOf( - "customerName" to "Acme Corp", - "total" to 100.00 -)) +The managed CircleCI Android profile owns shared-library verification and its existing main-only Maven publication. The GitHub **Scanner Android** workflow adds shared/scanner JVM tests, scanner lint, debug APK assembly, SHA256, and downloadable artifacts for pull requests and supported branches. Its manual **Run workflow** option adds native UI tests and a scanner launch smoke on an API35 emulator. -// Run process -client.runProcess("orderSync") -``` +The GitHub workflow does not publish libraries or production releases. Signed APK distribution remains a separate release task. See [Contributing](CONTRIBUTING.md), [Security](SECURITY.md), and [Changelog](CHANGELOG.md). -## Usage +## Extension points -### Authentication +`LocalAuthenticatedContent` allows a host to gate authenticated QQQ content. `LocalCustomWidgetRenderer` lets the host render additional metadata-defined widgets without replacing the standard renderer. `NetworkQQQRepository` accepts a server-scoped header provider; credentials never enter process values. -```kotlin -val client = QqqClient.Builder() - .baseUrl("https://your-qqq-app.com") - .authToken("your-api-token") - .build() -``` - -### Querying Records - -```kotlin -// With filters -val results = client.query("customer") - .filter("status", Operator.EQUALS, "active") - .filter("createdDate", Operator.GREATER_THAN, "2024-01-01") - .orderBy("name", SortOrder.ASC) - .execute() - -// Get single record -val customer = client.get("customer", 123) -``` - -### Modifying Records - -```kotlin -// Update -client.update("order", 123, mapOf( - "status" to "shipped" -)) - -// Delete -client.delete("order", 123) -``` - -## Project Status - -Experimental. Core functionality is being developed. - -### Roadmap - -- Offline caching -- Background sync -- UI components for common patterns -- Kotlin coroutines support - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Submit a pull request +The first scanner host supports TABLE_BASED authentication only and shows an explicit unsupported-provider message for other authentication types. Existing shared-library Auth0 support remains intact; production identity-provider integration requires callback configuration and verification before enabling it in this host. ## License -Apache-2.0 - See [LICENSE](LICENSE) for details. +Root license: Apache-2.0. Historical source files still carry older license headers; the repository's pre-existing license-header inconsistency has not been resolved by this implementation. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9cf9485 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security policy + +Do not open public issues containing vulnerabilities, credentials, device tokens, session cookies, or real scan data. + +Report vulnerabilities privately through [GitHub security reporting](https://github.com/QRun-IO/qqq-android/security/advisories/new) when available, or email **security@qrun.io**. Include the affected commit/APK version, Android and QQQ versions, a synthetic reproduction, and the impact. + +The scanner host is experimental. Report issues against the current development version; there is not yet a stable scanner APK support schedule. The broader [QQQ security policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md) applies to the framework. + +Release installations must use HTTPS, authenticated users, and approved device credentials. The debug APK permits HTTP for local development only. Device enrollment secrets and sessions must not be included in backup, transfer, logs, or process values. Scanner-provided metadata is client input rather than proof of hardware identity. diff --git a/docs/EMULATOR.md b/docs/EMULATOR.md new file mode 100644 index 0000000..842c5de --- /dev/null +++ b/docs/EMULATOR.md @@ -0,0 +1,31 @@ +# Emulator testing + +An Android emulator verifies login, enrollment, native navigation, manual barcode submission, and intent handling. It does not reproduce Zebra's scan engine, physical trigger, or DataWedge profile management. + +## Local interactive test + +1. Create/start an API35 Android emulator in Android Studio, or use the SDK emulator installed on the development machine. +2. Build/install `scanner-host/build/outputs/apk/debug/scanner-host-debug.apk` with `adb install -r` targeting the emulator's serial if multiple devices are connected. +3. Start the companion [mobile scanner demo](https://github.com/QRun-IO/qbit-mobile-scanners/tree/main/demo) on port8080. +4. Run `adb reverse tcp:8080 tcp:8080` and use `http://127.0.0.1:8080/` in QQQ Mobile. +5. Sign in with the demo operator account. Approve the displayed device from the administrator session, then tap **Check approval**. +6. Select the mobile app, open its scan process, and submit a synthetic barcode manually. Verify **Saved scan**, and verify the server's stored record. +7. Revoke the device on the server and verify subsequent submissions are denied. Sign out and verify a new login is required. + +To exercise the same intent adapter used by DataWedge while the scanner screen is visible, send a synthetic development broadcast: + +```text +adb shell am broadcast -a io.qrun.mobile.scanner.SCAN -c android.intent.category.DEFAULT -p io.qrun.mobile.scanner --es com.symbol.datawedge.data_string DEMO-001 --es com.symbol.datawedge.label_type LABEL-TYPE-CODE128 --es com.symbol.datawedge.source scanner +``` + +This validates intent parsing only; the server treats source/symbology as client-reported metadata. + +## Automated checks + +The GitHub **Scanner Android** workflow always runs JVM tests, lint, and APK assembly. On **Run workflow**, leave **emulator** enabled to run the existing native Compose authentication/process tests and `scripts/ci/emulator-smoke.sh` on a disposable API35 emulator. Reports, the setup-screen screenshot, and UI hierarchy are uploaded as workflow artifacts. + +The smoke script refuses physical devices, installs the debug APK, clears the emulator's scanner application data, launches the real activity, and checks that **Connect QQQ Mobile** appears. It does not need a QQQ server. Full operator/device-approval/scan testing uses the companion demo as described above. + +## Physical acceptance still required + +Test the TC22 trigger and DataWedge profile, scan during rotation/backgrounding, retry after a lost response, revoked approval, leading-zero barcodes, and repeated physical scans. Confirm that an uncertain submission retains its event ID and a repeated physical scan receives a new event ID. Never use sensitive production barcodes in test screenshots or CI artifacts. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md new file mode 100644 index 0000000..c0c88f9 --- /dev/null +++ b/docs/SESSION-STATE.md @@ -0,0 +1,11 @@ +# Session state + +2026-10-29: Implementing QQQ Mobile scanner host and native host extensions. First target is Zebra TC22, online only. Backend companion is `qbit-mobile-scanners`; metadata-defined apps/processes remain the native execution model. Device approval is mandatory. Local demo uses QQQ TABLE_BASED auth on QQQ4.1.0-RC.20. + +Implemented server selection, per-server enrollment credentials, approval gate, SCANNER widget DataWedge input, explicit same-event retry, native password login, host extension points, and removal of session/token logging from touched authentication paths. No commit or push. + +Validation passed: 19 JVM tests (core12, mobileapp4, scanner3), debug APK assembly, APK signature verification, and lint with zero errors. Five lint warnings remain: three inherited Navigation custom-check compatibility warnings and two dependency/catalog notices. Real sanitized demo HTTP metadata/process/init fixtures deserialize through the native DTOs. Server logout, HttpOnly-cookie login, and per-origin header isolation have regression tests. Physical TC22 acceptance remains required. + +Final debug APK SHA256: B3E160CE3279C369CEDB425AC6E777A2729CE8A2E430E7D6F46D35C6376F8214. + +Repository readiness added: pinned GitHub scanner tests/lint/APK artifacts and optional emulator workflow; CODEOWNERS, contribution/security/conduct guidance, issue/PR templates, Dependabot, changelog, editor/line-ending policy, and scoped agent instructions. `actionlint`, `shellcheck`, `shfmt`, and `git diff --check` passed. Upstream main `63fca89` was fast-forwarded and all implementation files restored without conflicts; the managed CircleCI Android profile and QRun-IO Maven registry updates are preserved. The full Gradle verification command passed again after reconciliation (16 executed tasks, remaining unchanged tasks up to date). Remote CI and emulator acceptance have not yet been recorded here. diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..6176fc2 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,11 @@ +# TODO + +- [x] Complete Gradle unit tests, debug APK, signature, and lint verification (19 tests; zero lint errors). +- [ ] Run physical TC22 acceptance: DataWedge trigger, rotation/background lifecycle, duplicate retry, revoked device, bad credentials, sign-out and re-login. +- [ ] Verify release APK signing/distribution separately; current deliverable is a debug APK. +- [ ] Reconcile historical source license headers with the root license before publication. +- [ ] Configure/test production identity-provider integration if selected; version one deliberately supports TABLE_BASED sign-in only and rejects other providers with a clear message. + +Offline queue/background synchronization is deliberately outside version one. +- [ ] Verify remote Scanner Android workflow after publication and run the optional emulator lane. +- [ ] Run the native sample instrumentation suite and full interactive scanner demo on an emulator. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c437bfd..0790b38 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,5 +2,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/qqq/android/core/build.gradle.kts b/qqq/android/core/build.gradle.kts index cb17a58..3bd9a86 100644 --- a/qqq/android/core/build.gradle.kts +++ b/qqq/android/core/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(libs.material) testImplementation(libs.junit) + testImplementation("com.squareup.okhttp3:mockwebserver:4.11.0") testImplementation(libs.junit.jupiter) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -66,4 +67,4 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:4.11.0") implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20") -} \ No newline at end of file +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt index e991c46..c8c772e 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt @@ -31,23 +31,24 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.Man import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult import kotlinx.serialization.json.Json -import okhttp3.Interceptor +import okhttp3.Credentials +import okhttp3.Cookie import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.RequestBody -import okhttp3.Response import org.json.JSONObject import retrofit2.Retrofit -import java.io.IOException import java.util.TimeZone +import java.util.concurrent.ConcurrentHashMap /*************************************************************************** ** ***************************************************************************/ -class NetworkQQQRepository(private var baseUrl: String) : QQQRepository +class NetworkQQQRepository(private var baseUrl: String, private val additionalHeaders: (String) -> Map = { emptyMap() }) : QQQRepository { + private val sessionUUIDs = ConcurrentHashMap() private val retroJson = Json { ignoreUnknownKeys = true } private val qqqApiServiceMap: MutableMap = mutableMapOf() @@ -79,7 +80,24 @@ class NetworkQQQRepository(private var baseUrl: String) : QQQRepository .addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType())) .client( OkHttpClient.Builder() - .addInterceptor(QQQInterceptor) + .followRedirects(false) + .followSslRedirects(false) + .addInterceptor { chain -> + val request = chain.request().newBuilder() + sessionUUIDs[baseUrl]?.takeIf { it.isNotBlank() }?.let { request.header("Cookie", "sessionUUID=$it") } + additionalHeaders(baseUrl).forEach { (name, value) -> request.header(name, value) } + request.header("X-QQQ-UserTimezone", TimeZone.getDefault().toZoneId().toString()) + val response = chain.proceed(request.build()) + response.headers("Set-Cookie").forEach { header -> + Cookie.parse(response.request.url, header)?.let { cookie -> + if(cookie.name == "sessionUUID" && cookie.matches(response.request.url)) + { + sessionUUIDs[baseUrl] = cookie.value + } + } + } + response + } .build() ) .baseUrl(baseUrl) @@ -91,7 +109,7 @@ class NetworkQQQRepository(private var baseUrl: String) : QQQRepository ***************************************************************************/ override fun setSessionUUID(sessionUUID: String) { - QQQInterceptor.sessionUUID = sessionUUID + sessionUUIDs[baseUrl] = sessionUUID } /*************************************************************************** @@ -108,7 +126,26 @@ class NetworkQQQRepository(private var baseUrl: String) : QQQRepository ***************************************************************************/ override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse { - return qqqApiServiceMap[baseUrl]!!.manageSession(body) + return resolveSession(qqqApiServiceMap[baseUrl]!!.manageSession(body)) + } + + /** Send credentials only to the session endpoint, never to subsequent requests. */ + override suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse = + resolveSession(qqqApiServiceMap[baseUrl]!!.loginWithPassword(Credentials.basic(username, password, Charsets.UTF_8), emptyMap())) + + /** Modern QQQ deliberately omits the UUID body field and returns an HttpOnly session cookie. */ + private fun resolveSession(response: ManageSessionResponse): ManageSessionResponse + { + val session = response.uuid?.takeIf { it.isNotBlank() } ?: sessionUUIDs[baseUrl]?.takeIf { it.isNotBlank() } + ?: throw IllegalStateException("Sign-in response did not establish a session") + return response.copy(uuid = session) + } + + /** Preserve the cookie until the server has received the revocation request. */ + override suspend fun logout() + { + qqqApiServiceMap[baseUrl]!!.logout().close() + sessionUUIDs.remove(baseUrl) } /*************************************************************************** @@ -214,27 +251,4 @@ class NetworkQQQRepository(private var baseUrl: String) : QQQRepository /*************************************************************************** ** ***************************************************************************/ - companion object QQQInterceptor : Interceptor - { - private var sessionUUID: String? = null - - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response - { - val requestBuilder = chain.request().newBuilder() - - sessionUUID?.let() - { - requestBuilder.addHeader("Cookie", "sessionUUID=${it}") - } - - val timeZone = TimeZone.getDefault() - requestBuilder.addHeader("X-QQQ-UserTimezone", timeZone.toZoneId().toString()) - - val response: Response = chain.proceed(requestBuilder.build()) - - return response - } - } - } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt index 9015458..96a3107 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQApiService.kt @@ -29,8 +29,10 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.Man import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult import okhttp3.RequestBody +import okhttp3.ResponseBody import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query @@ -52,6 +54,12 @@ interface QQQApiService @POST(BASE_PATH + "manageSession") suspend fun manageSession(@Body body: ManageSessionRequest): ManageSessionResponse + @POST(BASE_PATH + "manageSession") + suspend fun loginWithPassword(@Header("Authorization") authorization: String, @Body body: Map): ManageSessionResponse + + @POST(BASE_PATH + "logout") + suspend fun logout(): ResponseBody + /////////////////// // qqq meta data // /////////////////// diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt index 6cf531b..e556921 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/QQQRepository.kt @@ -43,6 +43,13 @@ interface QQQRepository suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse + /** Authenticate table-backed users without retaining their password. */ + suspend fun loginWithPassword(username: String, password: String): ManageSessionResponse = + throw UnsupportedOperationException("Password authentication is unavailable") + + /** End the server session before discarding local credentials. */ + suspend fun logout() { } + suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance suspend fun getProcessMetaData(processName: String): QProcessMetaData diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializer.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializer.kt index 0281a28..1139f75 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializer.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/AuthenticationSerializer.kt @@ -40,6 +40,7 @@ object AuthenticationSerializer : JsonContentPolymorphicSerializer Auth0AuthenticationMetaData.serializer() FullyAnonymousAuthenticationMetaData.TYPE -> FullyAnonymousAuthenticationMetaData.serializer() + TableBasedAuthenticationMetaData.TYPE, "tableBased" -> TableBasedAuthenticationMetaData.serializer() else -> throw SerializationException("Unknown type: $type") } -} \ No newline at end of file +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/ManageSessionResponse.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/ManageSessionResponse.kt index eab354a..1b6826d 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/ManageSessionResponse.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/ManageSessionResponse.kt @@ -29,8 +29,8 @@ import kotlinx.serialization.Serializable ***************************************************************************/ @Serializable data class ManageSessionResponse( - val uuid: String, + val uuid: String? = null, val values: MapOfStringAny = emptyMap(), ) { -} \ No newline at end of file +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/TableBasedAuthenticationMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/TableBasedAuthenticationMetaData.kt new file mode 100644 index 0000000..70e2696 --- /dev/null +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/TableBasedAuthenticationMetaData.kt @@ -0,0 +1,16 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.core.model.metadata.authentication + +import kotlinx.serialization.Serializable + +/** Credentials are exchanged for a QQQ session; passwords are never persisted. */ +@Serializable +data class TableBasedAuthenticationMetaData(override val name: String = "tableBased") : BaseAuthenticationMetaData +{ + override val type: String get() = TYPE + + companion object + { + const val TYPE = "TABLE_BASED" + } +} diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobComplete.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobComplete.kt index 061cc4c..d090c58 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobComplete.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/processes/QJobComplete.kt @@ -10,6 +10,6 @@ import kotlinx.serialization.Serializable data class QJobComplete( override val processUUID: String, val values: MapOfStringAny = emptyMap(), - val nextStep: String, + val nextStep: String = "", val processMetaDataAdjustment: ProcessMetaDataAdjustment? = null, ) : ProcessStepResult() diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt new file mode 100644 index 0000000..3801e9e --- /dev/null +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt @@ -0,0 +1,70 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.core.controllers + +import com.kingsrook.qqq.frontend.android.core.model.Environment +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped. */ +class NetworkQQQRepositoryTest +{ + @Test + fun logoutRevokesServerSessionBeforeDroppingTheCookie() = runBlocking() + { + MockWebServer().use { server -> + server.start() + server.enqueue(MockResponse().setBody("{}")) + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}")) + val origin = server.url("/").toString() + val repository = NetworkQQQRepository(origin) + repository.setEnvironment(Environment("demo", origin)) + repository.setSessionUUID("synthetic-session") + repository.logout() + val logoutRequest = server.takeRequest() + assertEquals("/qqq/v1/logout", logoutRequest.path) + assertEquals("POST", logoutRequest.method) + assertEquals("sessionUUID=synthetic-session", logoutRequest.getHeader("Cookie")) + repository.getMetaData("test", "1", "test", "1") + assertNull(server.takeRequest().getHeader("Cookie")) + } + } + + @Test + fun capturesHttpOnlySessionAndDoesNotSendItToAnotherServer() = runBlocking() + { + MockWebServer().use { first -> + MockWebServer().use { second -> + first.start() + second.start() + first.enqueue(MockResponse().setHeader("Content-Type", "application/json") + .addHeader("Set-Cookie", "sessionUUID=synthetic-session; HttpOnly; Path=/").setBody("{\"values\":{}}")) + first.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}")) + second.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}")) + val origin = first.url("/").toString() + val repository = NetworkQQQRepository(origin) { server -> + if(server == origin) mapOf("X-QQQ-Device-Id" to "test-device") else emptyMap() + } + repository.setEnvironment(Environment("first", origin)) + val login = repository.loginWithPassword("demo", "synthetic-password") + assertEquals("synthetic-session", login.uuid) + repository.getMetaData("test", "1", "test", "1") + val loginRequest = first.takeRequest() + assertEquals("/qqq/v1/manageSession", loginRequest.path) + assertNull(loginRequest.getHeader("Cookie")) + val nextRequest = first.takeRequest() + assertEquals("sessionUUID=synthetic-session", nextRequest.getHeader("Cookie")) + assertNull(nextRequest.getHeader("Authorization")) + assertEquals("test-device", nextRequest.getHeader("X-QQQ-Device-Id")) + repository.setEnvironment(Environment("second", second.url("/").toString())) + repository.getMetaData("test", "1", "test", "1") + val otherRequest = second.takeRequest() + assertNull(otherRequest.getHeader("Cookie")) + assertNull(otherRequest.getHeader("X-QQQ-Device-Id")) + } + } + } +} diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/MobileProtocolTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/MobileProtocolTest.kt new file mode 100644 index 0000000..ad6ac52 --- /dev/null +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/model/MobileProtocolTest.kt @@ -0,0 +1,53 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.core.model + +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance +import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData +import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.TableBasedAuthenticationMetaData +import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult +import com.kingsrook.qqq.frontend.android.core.model.processes.QJobComplete +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Fixtures cover wire differences observed against the current QQQ demo server. */ +class MobileProtocolTest +{ + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun acceptsTableBasedAuthenticationMetadata() + { + val metadata = json.decodeFromString("""{"name":"users","type":"TABLE_BASED","values":{}}""") + assertTrue(metadata is TableBasedAuthenticationMetaData) + } + + @Test + fun terminalProcessCompletionMayOmitNextStep() + { + val result = json.decodeFromString("""{"type":"COMPLETE","processUUID":"test-process","values":{}}""") + assertEquals("", (result as QJobComplete).nextStep) + } + + @Test + fun rendersContractCapturedFromTheLiveDemo() + { + val metadata = json.decodeFromString(fixture("metadata")) + assertTrue(metadata.apps!!.containsKey("mobileScanApp")) + val process = json.decodeFromString(fixture("process")) + assertEquals("scanner", process.frontendSteps!!.first().format) + val init = json.decodeFromString(fixture("init")) as QJobComplete + assertEquals("scan", init.nextStep) + val composite = WidgetBlock.fromMap(init.values["mobileScanner"])!! + assertEquals("SCANNER", composite.subBlocks.single().blockType) + assertEquals("Scan a barcode", composite.subBlocks.single().values["title"]) + } + + /** These sanitized fixtures contain demo metadata only, never sessions or device tokens. */ + private fun fixture(name: String): String = + requireNotNull(javaClass.getResourceAsStream("/mobile-contract/$name.json")).bufferedReader().use { it.readText() } +} diff --git a/qqq/android/core/src/test/resources/mobile-contract/init.json b/qqq/android/core/src/test/resources/mobile-contract/init.json new file mode 100644 index 0000000..b3c6511 --- /dev/null +++ b/qqq/android/core/src/test/resources/mobile-contract/init.json @@ -0,0 +1,27 @@ +{ + "values": {"mobileScanner": { + "footerHTML": null, + "dropdownLabelList": null, + "dropdownNeedsSelectedText": null, + "blockType": "COMPOSITE", + "dropdownNameList": null, + "subBlocks": [{ + "blockId": "barcodeScanner", + "blockType": "SCANNER", + "values": { + "instructions": "Scan with the trigger or enter a barcode. Saved scans appear in Mobile Scanner Administration.", + "title": "Scan a barcode" + } + }], + "label": null, + "type": "composite", + "sublabel": null, + "dropdownDefaultValueList": null, + "csvData": null, + "hasPermission": null, + "dropdownDataList": null + }}, + "processUUID": "00000000-0000-4000-8000-000000000001", + "nextStep": "scan", + "type": "COMPLETE" +} \ No newline at end of file diff --git a/qqq/android/core/src/test/resources/mobile-contract/metadata.json b/qqq/android/core/src/test/resources/mobile-contract/metadata.json new file mode 100644 index 0000000..8e25e81 --- /dev/null +++ b/qqq/android/core/src/test/resources/mobile-contract/metadata.json @@ -0,0 +1,287 @@ +{ + "appTree": [{ + "children": [{ + "name": "mobileScanCapture", + "label": "Scan Capture", + "type": "PROCESS" + }], + "name": "mobileScanApp", + "icon": {"name": "qr_code_scanner"}, + "label": "Scan Capture", + "type": "APP" + }], + "processes": { + "mobileDeviceCredential.bulkDelete": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "mobileDeviceCredential.bulkDelete", + "label": "Device Credentials Bulk Delete", + "tableName": "mobileDeviceCredential", + "isHidden": true + }, + "mobileDeviceCredential.bulkEdit": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "mobileDeviceCredential.bulkEdit", + "label": "Device Credentials Bulk Edit", + "tableName": "mobileDeviceCredential", + "isHidden": true + }, + "mobileScan.bulkEditWithFile": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "mobileScan.bulkEditWithFile", + "label": "Barcode Scans Bulk Edit With File", + "tableName": "mobileScan", + "isHidden": true + }, + "demoUser.bulkDelete": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "demoUser.bulkDelete", + "label": "Demo User Bulk Delete", + "tableName": "demoUser", + "isHidden": true + }, + "demoSession.bulkEditWithFile": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "demoSession.bulkEditWithFile", + "label": "Demo Session Bulk Edit With File", + "tableName": "demoSession", + "isHidden": true + }, + "demoUser.bulkInsert": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "iconName": "library_add", + "hasPermission": false, + "name": "demoUser.bulkInsert", + "icon": { + "semanticName": "library-add", + "name": "library_add" + }, + "label": "Demo User Bulk Insert", + "iconSemanticName": "library-add", + "tableName": "demoUser", + "isHidden": true + }, + "demoSession.bulkInsert": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "iconName": "library_add", + "hasPermission": false, + "name": "demoSession.bulkInsert", + "icon": { + "semanticName": "library-add", + "name": "library_add" + }, + "label": "Demo Session Bulk Insert", + "iconSemanticName": "library-add", + "tableName": "demoSession", + "isHidden": true + }, + "demoUser.bulkEditWithFile": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "demoUser.bulkEditWithFile", + "label": "Demo User Bulk Edit With File", + "tableName": "demoUser", + "isHidden": true + }, + "mobileScanCapture": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": true, + "name": "mobileScanCapture", + "label": "Scan Capture", + "isHidden": false + }, + "mobileDevice.bulkInsert": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "iconName": "library_add", + "hasPermission": false, + "name": "mobileDevice.bulkInsert", + "icon": { + "semanticName": "library-add", + "name": "library_add" + }, + "label": "Mobile Devices Bulk Insert", + "iconSemanticName": "library-add", + "tableName": "mobileDevice", + "isHidden": true + }, + "mobileDeviceCredential.bulkInsert": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "iconName": "library_add", + "hasPermission": false, + "name": "mobileDeviceCredential.bulkInsert", + "icon": { + "semanticName": "library-add", + "name": "library_add" + }, + "label": "Device Credentials Bulk Insert", + "iconSemanticName": "library-add", + "tableName": "mobileDeviceCredential", + "isHidden": true + }, + "mobileScan.bulkEdit": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "mobileScan.bulkEdit", + "label": "Barcode Scans Bulk Edit", + "tableName": "mobileScan", + "isHidden": true + }, + "mobileDevice.bulkDelete": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "mobileDevice.bulkDelete", + "label": "Mobile Devices Bulk Delete", + "tableName": "mobileDevice", + "isHidden": true + }, + "mobileDevice.bulkEditWithFile": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "mobileDevice.bulkEditWithFile", + "label": "Mobile Devices Bulk Edit With File", + "tableName": "mobileDevice", + "isHidden": true + }, + "mobileScan.bulkInsert": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "iconName": "library_add", + "hasPermission": false, + "name": "mobileScan.bulkInsert", + "icon": { + "semanticName": "library-add", + "name": "library_add" + }, + "label": "Barcode Scans Bulk Insert", + "iconSemanticName": "library-add", + "tableName": "mobileScan", + "isHidden": true + }, + "mobileScan.bulkDelete": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "mobileScan.bulkDelete", + "label": "Barcode Scans Bulk Delete", + "tableName": "mobileScan", + "isHidden": true + }, + "demoSession.bulkEdit": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "demoSession.bulkEdit", + "label": "Demo Session Bulk Edit", + "tableName": "demoSession", + "isHidden": true + }, + "demoSession.bulkDelete": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "demoSession.bulkDelete", + "label": "Demo Session Bulk Delete", + "tableName": "demoSession", + "isHidden": true + }, + "mobileDevice.bulkEdit": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "mobileDevice.bulkEdit", + "label": "Mobile Devices Bulk Edit", + "tableName": "mobileDevice", + "isHidden": true + }, + "mobileDeviceCredential.bulkEditWithFile": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "mobileDeviceCredential.bulkEditWithFile", + "label": "Device Credentials Bulk Edit With File", + "tableName": "mobileDeviceCredential", + "isHidden": true + }, + "demoUser.bulkEdit": { + "durableJobAlways": false, + "stepFlow": "LINEAR", + "durableJobEligible": false, + "hasPermission": false, + "name": "demoUser.bulkEdit", + "label": "Demo User Bulk Edit", + "tableName": "demoUser", + "isHidden": true + } + }, + "metaDataGeneration": 0, + "widgets": {"mobileScanner": { + "showReloadButton": true, + "hasPermission": true, + "name": "mobileScanner", + "storeDropdownSelections": false, + "isCard": false, + "label": "Scan a barcode", + "showExportButton": false, + "type": "composite" + }}, + "apps": {"mobileScanApp": { + "children": [{ + "name": "mobileScanCapture", + "label": "Scan Capture", + "type": "PROCESS" + }], + "name": "mobileScanApp", + "icon": {"name": "qr_code_scanner"}, + "childMap": {"mobileScanCapture": { + "name": "mobileScanCapture", + "label": "Scan Capture", + "type": "PROCESS" + }}, + "label": "Scan Capture", + "sections": [{ + "processes": ["mobileScanCapture"], + "role": "PRIMARY", + "name": "scan", + "label": "Scanning", + "collapsedByDefault": false + }] + }} +} \ No newline at end of file diff --git a/qqq/android/core/src/test/resources/mobile-contract/process.json b/qqq/android/core/src/test/resources/mobile-contract/process.json new file mode 100644 index 0000000..b8ac4f9 --- /dev/null +++ b/qqq/android/core/src/test/resources/mobile-contract/process.json @@ -0,0 +1 @@ +{"frontendSteps":[{"name":"scan","format":"scanner","label":"Scan a barcode","components":[{"type":"WIDGET","values":{"widgetName":"mobileScanner"}}]}],"name":"mobileScanCapture","label":"Scan Capture","stepFlow":"LINEAR","durableJobEligible":false,"durableJobAlways":false,"isHidden":false,"hasPermission":true} \ No newline at end of file diff --git a/qqq/android/mobileapp/build.gradle.kts b/qqq/android/mobileapp/build.gradle.kts index 2bd7d61..b6f512d 100644 --- a/qqq/android/mobileapp/build.gradle.kts +++ b/qqq/android/mobileapp/build.gradle.kts @@ -79,5 +79,6 @@ dependencies { implementation(libs.coil.svg) implementation(project(":qqq:android:core")) + implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation(libs.auth0) -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/HostExtensions.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/HostExtensions.kt new file mode 100644 index 0000000..e654341 --- /dev/null +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/HostExtensions.kt @@ -0,0 +1,20 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.mobileapp.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters +import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel + +/** Applications can enforce device enrollment before exposing native QQQ content. */ +val LocalAuthenticatedContent = staticCompositionLocalOf<@Composable (QViewModel, @Composable () -> Unit) -> Unit>() +{ + { _, content -> content() } +} + +/** Unknown widgets can be implemented by a host without forking the standard renderer. */ +val LocalCustomWidgetRenderer = staticCompositionLocalOf<@Composable (WidgetBlockParameters, Modifier, Boolean) -> Boolean>() +{ + { _, _, _ -> false } +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Login.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Login.kt index 39c813e..2377f98 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Login.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuth0Login.kt @@ -79,7 +79,7 @@ fun QAuth0Login(qViewModel: QViewModel, modifier: Modifier = Modifier) ***************************************************************************/ override fun onSuccess(result: Credentials) { - Log.d(TAG, "Auth0 Called back success, accessToken: ${result.accessToken.firstN(8)}") + Log.d(TAG, "Auth0 sign-in succeeded") auth0Success.value = true qViewModel.doSetSessionUserFullName(result.user.nickname ?: "Unknown") diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuthenticationWrapper.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuthenticationWrapper.kt index 40e3715..be85518 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuthenticationWrapper.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QAuthenticationWrapper.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData import com.kingsrook.qqq.frontend.android.mobileapp.ui.QNavigator +import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalAuthenticatedContent import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.QApplicationWrapper import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.LoadStateView import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.Reloader @@ -61,7 +62,7 @@ fun QAuthenticationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier } else if(isAuthenticated) { - QApplicationWrapper(qViewModel, Modifier, qNavigator) + LocalAuthenticatedContent.current(qViewModel) { QApplicationWrapper(qViewModel, Modifier, qNavigator) } } else { diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLoginForm.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLoginForm.kt index 5317c03..dfc2e01 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLoginForm.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLoginForm.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.Auth0AuthenticationMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.FullyAnonymousAuthenticationMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.TableBasedAuthenticationMetaData import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullSizedAllCenteredColumn import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel import java.util.UUID @@ -45,6 +46,7 @@ fun QLoginForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticatio { when (authenticationMetaData) { + is TableBasedAuthenticationMetaData -> QPasswordLogin(qViewModel, modifier) is Auth0AuthenticationMetaData -> { QAuth0Login(qViewModel, modifier) diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLogoutForm.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLogoutForm.kt index 180a432..01e085e 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLogoutForm.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QLogoutForm.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.Auth0AuthenticationMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.FullyAnonymousAuthenticationMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.TableBasedAuthenticationMetaData import com.kingsrook.qqq.frontend.android.mobileapp.ui.common.FullSizedAllCenteredColumn import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel @@ -50,7 +51,7 @@ fun QLogoutForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticati QAuth0Logout(qViewModel, modifier) } - is FullyAnonymousAuthenticationMetaData -> + is FullyAnonymousAuthenticationMetaData, is TableBasedAuthenticationMetaData -> { LaunchedEffect(true) { @@ -62,9 +63,9 @@ fun QLogoutForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticati FullSizedAllCenteredColumn() { - Text("Logout Successful.", modifier = Modifier.padding(bottom = 16.dp)) + Text(qViewModel.logoutMessage.ifEmpty { "Signing out…" }, modifier = Modifier.padding(bottom = 16.dp)) - Button(onClick = { qViewModel.requestLogin() }) + Button(enabled = !qViewModel.logoutInProgress, onClick = { qViewModel.requestLogin() }) { Text("Log back in") } diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt new file mode 100644 index 0000000..89099ab --- /dev/null +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt @@ -0,0 +1,45 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState +import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel + +/** Native credential form for QQQ TABLE_BASED authentication. */ +@Composable +fun QPasswordLogin(viewModel: QViewModel, modifier: Modifier = Modifier) +{ + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var submitted by remember { mutableStateOf(false) } + val busy = submitted && viewModel.manageSessionLoadState is LoadState.Loading + Column(modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) + { + Text("Sign in to QQQ") + OutlinedTextField(username, { username = it }, label = { Text("Username") }, singleLine = true, enabled = !busy) + OutlinedTextField(password, { password = it }, label = { Text("Password") }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), enabled = !busy) + Button(enabled = !busy && username.isNotBlank() && password.isNotEmpty(), onClick = { + submitted = true + viewModel.loginWithPassword(username.trim(), password) + password = "" + }) { Text(if(busy) "Signing in…" else "Sign in") } + if(submitted && viewModel.manageSessionLoadState is LoadState.Error) + { + Text("Sign-in failed. Check your credentials and connection.") + } + } +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/QProcessHome.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/QProcessHome.kt index 8ba1a9a..56c01a1 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/QProcessHome.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/processes/QProcessHome.kt @@ -122,6 +122,8 @@ fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifie LoadStateView( loadStates, modifier = Modifier, + reloader = if(processViewModel.pendingStepValues != null) + com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.Reloader("Retry same request", { processViewModel.retryPendingStep() }) else null, loadingText = "Loading Process" ) { diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockComposable.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockComposable.kt index 053d662..2b73acc 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockComposable.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/widgets/blocks/WidgetBlockComposable.kt @@ -25,6 +25,7 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalCustomWidgetRenderer /*************************************************************************** ** Composable that takes a widget-block, switches based on the type, and @@ -42,6 +43,9 @@ fun WidgetBlockComposable(params: WidgetBlockParameters, modifier: Modifier = Mo "IMAGE" -> ImageWidgetBlock(params, modifier) "INPUT_FIELD" -> InputFieldWidgetBlock(params, modifier, disableControls) "TEXT" -> TextWidgetBlock(params, modifier) - else -> Text("Unsupported block type: ${params.widgetBlock.blockType}") + else -> if(!LocalCustomWidgetRenderer.current(params, modifier, disableControls)) + { + Text("Unsupported block type: ${params.widgetBlock.blockType}") + } } -} \ No newline at end of file +} diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt index 15974a5..1d0e234 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/ProcessViewModel.kt @@ -44,6 +44,7 @@ import com.kingsrook.qqq.frontend.android.core.model.processes.QJobStarted import com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.cancelChildren import java.time.LocalDateTime @@ -208,6 +209,8 @@ open class ProcessViewModel : ViewModel() ***************************************************************************/ fun reset() { + viewModelScope.coroutineContext.cancelChildren() + pendingStepValues = null processMetaDataLoadState = LoadState.Loading(Unit) processMetaData = null initLoadState = LoadState.Loading(Unit) @@ -249,6 +252,7 @@ open class ProcessViewModel : ViewModel() LoadState.Success(processMetaData) } + catch(e: kotlinx.coroutines.CancellationException) { throw e } catch(e: Exception) { Log.w(TAG, "Error loading ProcessMetaData", e) @@ -272,6 +276,7 @@ open class ProcessViewModel : ViewModel() val value = qqqRepository.processInit(processName, emptyMap()) evaluateProcessResult(value) } + catch(e: kotlinx.coroutines.CancellationException) { throw e } catch(e: Exception) { Log.w(TAG, "Error initing process", e) @@ -293,6 +298,7 @@ open class ProcessViewModel : ViewModel() ***************************************************************************/ fun runStep(eventValues: Map = emptyMap()) { + if(waitingOnBackend) return val localProcessUUID = processUUID val localActiveStepName = activeStepName @@ -321,6 +327,9 @@ open class ProcessViewModel : ViewModel() valuesToSubmit[entry.key] = entry.value } + pendingStepValues = if(valuesToSubmit["eventId"] is String) valuesToSubmit.toMap() else null + topLevelErrorState = LoadState.Success(Unit) + ///////////////////////////////////////// // set state to show we're loading now // ///////////////////////////////////////// @@ -337,14 +346,34 @@ open class ProcessViewModel : ViewModel() val value = qqqRepository.processStep(processName, localProcessUUID, localActiveStepName, valuesToSubmit, stepTimeoutMillis = 1000) evaluateProcessResult(value) } + catch(e: kotlinx.coroutines.CancellationException) { throw e } catch(e: Exception) { - Log.w(TAG, "Error running process step", e) - setTopLevelError("Error running process step: ${e.message}") + waitingOnBackend = false + setTopLevelError(if(e is retrofit2.HttpException && e.code() in listOf(401, 403)) + "Access denied. Your session may have expired or device approval may have been revoked. Sign in again or contact your administrator." + else "The server did not confirm this request. Retry the same request when connected.") } } } + /** Retain only the current request in memory so an uncertain response can be retried safely. */ + var pendingStepValues: Map? = null + private set + + /** Explicit retries preserve the scan event ID; no background queue is created. */ + fun retryPendingStep() + { + if(pendingStepValues == null) return + if(activeJobUUID.isNotEmpty()) + { + topLevelErrorState = LoadState.Success(Unit) + waitingOnBackend = true + checkIn() + } + else pendingStepValues?.let { runStep(it) } + } + /*************************************************************************** ** ***************************************************************************/ @@ -373,6 +402,7 @@ open class ProcessViewModel : ViewModel() is QJobError -> { waitingOnBackend = false + activeJobUUID = "" setTopLevelError(value.userFacingError ?: value.error) } @@ -418,6 +448,15 @@ open class ProcessViewModel : ViewModel() ***************************************************************************/ private fun processJobCompleteResult(value: QJobComplete) { + val pendingEventId = pendingStepValues?.get("eventId") + if(pendingEventId == null || pendingEventId == value.values["acknowledgedEventId"]) + { + pendingStepValues = null + } + else + { + setTopLevelError("The server did not acknowledge this scan. Retry the same request when connected.") + } updateActiveStepName(value.nextStep) stepInstanceCounter++ @@ -485,10 +524,11 @@ open class ProcessViewModel : ViewModel() val value = qqqRepository.processJobStatus(processName, localProcessUUID, localJobUUID) evaluateProcessResult(value) } + catch(e: kotlinx.coroutines.CancellationException) { throw e } catch(e: Exception) { - Log.w(TAG, "Error checking running process step status", e) - setTopLevelError("Error checking running process step status: ${e.message}") + waitingOnBackend = false + setTopLevelError("The server did not confirm the running request. Retry to check the same request again.") } } } @@ -672,6 +712,7 @@ open class ProcessViewModel : ViewModel() { controlCallbacks[controlCallbackName]?.invoke() } + catch(e: kotlinx.coroutines.CancellationException) { throw e } catch(e: Exception) { Log.w(TAG, "Error calling control callback for [${controlCallbackName}]", e) diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt index db8d74f..8f34ff0 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt @@ -78,6 +78,12 @@ open class QViewModel( var isLogoutRequested: Boolean by mutableStateOf(false) private set + var logoutInProgress: Boolean by mutableStateOf(false) + private set + + var logoutMessage: String by mutableStateOf("") + private set + var sessionUUID: String by mutableStateOf("") private set @@ -183,6 +189,7 @@ open class QViewModel( manageSessionLoadState = LoadState.Loading(Unit) qInstanceLoadState = LoadState.Loading(Unit) topBarStatusText = "" + activeProcessViewModel?.reset() activeProcessViewModel = null } @@ -215,10 +222,8 @@ open class QViewModel( { try { - Log.d(TAG, "managing session with an access token from login flow: ${accessToken.firstN(8)}") - val manageSessionLoadState = LoadState.Success(qqqRepository.manageSession(ManageSessionRequest(accessToken = accessToken, uuid = null))) - sessionUUID = manageSessionLoadState.value.uuid + sessionUUID = requireNotNull(manageSessionLoadState.value.uuid) qqqRepository.setSessionUUID(sessionUUID) loadQInstance() logInSuccessful() @@ -333,14 +338,14 @@ open class QViewModel( else -> "Unknown" } - Log.d(TAG, "qInstanceLoadState was success (${qInstanceLoadState}). marking login success!") + Log.d(TAG, "Stored session accepted") logInSuccessful() } else -> { qInstanceLoadState = LoadState.Loading(Unit) - Log.d(TAG, "qInstanceLoadState was not success (${qInstanceLoadState}). marking login failed.") + Log.d(TAG, "Stored session rejected") logInFailed() } } @@ -352,7 +357,7 @@ open class QViewModel( /////////// // noop. // /////////// - Log.d(TAG, "loadStoredSessionUUID was not success (${storedSessionUUIDLoadState}). ending dance") + Log.d(TAG, "No stored session available") } } @@ -432,8 +437,8 @@ open class QViewModel( qNavigator.goHome() viewModelScope.launch() { - activateEnvironment(environment).join() logOut().join() + activateEnvironment(environment).join() loadAuthenticationMetaData().join() postSwitchCallback.invoke() } @@ -465,7 +470,6 @@ open class QViewModel( { viewModelScope.launch() { - Log.d(TAG, "Storing session UUID: [${value}]") settingsRepository.setSessionUUID(value) } } @@ -491,29 +495,35 @@ open class QViewModel( { val capturedIsLogoutRequested = isLogoutRequested val capturedAuthLoadState = authenticationMetaDataLoadState - - viewModelScope.launch() + activeProcessViewModel?.reset() + logoutInProgress = true + logoutMessage = "Signing out…" + var serverConfirmed = false + try { - isAuthenticated = false - sessionUUID = "" - qqqRepository.setSessionUUID(sessionUUID) - settingsRepository.setSessionUUID(sessionUUID) - }.join() - - clearAll() - - //////////////////////////////////////// - // restore these 2 after the clear... // - //////////////////////////////////////// - isLogoutRequested = capturedIsLogoutRequested - authenticationMetaDataLoadState = capturedAuthLoadState + qqqRepository.logout() + serverConfirmed = true + } + catch(exception: kotlinx.coroutines.CancellationException) { throw exception } + catch(exception: Exception) { /* Local sign-out still completes when the network is unavailable. */ } + finally + { + clearAll() + qqqRepository.setSessionUUID("") + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { settingsRepository.setSessionUUID("") } + isLogoutRequested = capturedIsLogoutRequested + authenticationMetaDataLoadState = capturedAuthLoadState + logoutInProgress = false + logoutMessage = if(serverConfirmed) "Signed out." + else "Signed out on this device. Server sign-out could not be confirmed." + } } } /*************************************************************************** ** ***************************************************************************/ - private fun loadQInstance(): Job + fun loadQInstance(): Job { return viewModelScope.launch() { @@ -531,6 +541,30 @@ open class QViewModel( } } + /** Password remains in this request only; the returned session drives the existing host. */ + fun loginWithPassword(username: String, password: String) + { + manageSessionLoadState = LoadState.Loading(Unit) + viewModelScope.launch() + { + try + { + val response = qqqRepository.loginWithPassword(username, password) + sessionUUID = requireNotNull(response.uuid) + qqqRepository.setSessionUUID(sessionUUID) + doSetSessionUserFullName(username) + manageSessionLoadState = LoadState.Success(response) + logInSuccessful() + loadQInstance() + } + catch(e: kotlinx.coroutines.CancellationException) { throw e } + catch(e: Exception) + { + manageSessionLoadState = LoadState.Error("Sign-in failed. Check your credentials and connection.") + } + } + } + /*************************************************************************** ** ***************************************************************************/ diff --git a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SessionTests.kt b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SessionTests.kt index 0776152..f07cbd3 100644 --- a/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SessionTests.kt +++ b/qqqsampleandroidmobileapp/src/androidTest/java/com/kingsrook/qqq/sampleandroidmobileapp/SessionTests.kt @@ -186,7 +186,7 @@ class SessionTests : BaseTest() .performClick() composeTestRule - .onNodeWithText("Logout Successful.") + .onNodeWithText("Signed out.") .assertIsDisplayed() composeTestRule @@ -291,4 +291,4 @@ class SessionTests : BaseTest() TestUtils.localDebugSleep(composeTestRule, 0) } -} \ No newline at end of file +} diff --git a/scanner-host/.gitignore b/scanner-host/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/scanner-host/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/scanner-host/build.gradle.kts b/scanner-host/build.gradle.kts new file mode 100644 index 0000000..54b149d --- /dev/null +++ b/scanner-host/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "io.qrun.mobile.scanner" + compileSdk = 35 + defaultConfig { + applicationId = "io.qrun.mobile.scanner" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + manifestPlaceholders["auth0Domain"] = "unused.invalid" + manifestPlaceholders["auth0Scheme"] = "io.qrun.mobile.scanner" + } + buildFeatures { compose = true; buildConfig = true } + composeOptions { kotlinCompilerExtensionVersion = "1.5.1" } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = "1.8" } + packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" } +} + +dependencies { + implementation(project(":qqq:android:core")) + implementation(project(":qqq:android:mobileapp")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.datastore.preferences) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.material3) + implementation(libs.androidx.ui) + implementation("com.squareup.okhttp3:okhttp:4.11.0") + testImplementation(libs.junit) +} diff --git a/scanner-host/src/debug/AndroidManifest.xml b/scanner-host/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..28c3cc1 --- /dev/null +++ b/scanner-host/src/debug/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/scanner-host/src/main/AndroidManifest.xml b/scanner-host/src/main/AndroidManifest.xml new file mode 100644 index 0000000..966a04f --- /dev/null +++ b/scanner-host/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt new file mode 100644 index 0000000..8e470fb --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt @@ -0,0 +1,43 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import android.os.Build +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +/** The server already knows this installation, but this app never received its secret. */ +class EnrollmentConflict : IllegalStateException("Registration already exists but its credentials were not received. Start a new registration and ask the administrator to approve it.") + +/** The native QQQ session also authenticates device management. */ +class DeviceApi(private val server: String, private val credentials: DeviceCredentials) +{ + private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).build() + + /** Check enrollment on demand; approval remains a server-side decision. */ + suspend fun status(session: String, displayName: String): String = withContext(Dispatchers.IO) + { + val enrolling = credentials.deviceId.isBlank() + val request = Request.Builder().url(server + if(enrolling) "mobile/v1/devices/enroll" else "mobile/v1/devices/current") + .header("Cookie", "sessionUUID=$session") + credentials.headers().forEach { (name, value) -> request.header(name, value) } + if(enrolling) + { + val body = JSONObject().put("installationId", credentials.installationId).put("displayName", displayName) + .put("manufacturer", Build.MANUFACTURER).put("model", Build.MODEL) + .put("osVersion", Build.VERSION.RELEASE).put("appVersion", BuildConfig.VERSION_NAME) + request.post(body.toString().toRequestBody("application/json".toMediaType())) + } + client.newCall(request.build()).execute().use { response -> + if(enrolling && response.code == 409) throw EnrollmentConflict() + if(!response.isSuccessful) throw IllegalStateException("Device check failed (${response.code}). Sign in again or contact your administrator.") + val body = JSONObject(response.body?.string() ?: error("Empty device response")) + if(enrolling) credentials.enroll(body) + body.getString("status") + } + } +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceCredentials.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceCredentials.kt new file mode 100644 index 0000000..ed89227 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceCredentials.kt @@ -0,0 +1,83 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import org.json.JSONObject +import java.io.File +import java.security.KeyStore +import java.security.MessageDigest +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** Enrollment belongs to one server; its secret is encrypted by a non-exportable device key. */ +class DeviceCredentials(context: Context, server: String) +{ + private val scope = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) } + private val file = File(context.noBackupFilesDir, "device-$scope.json") + private val alias = "qqq-mobile-device" + private var values: JSONObject = if(file.exists()) decrypt(file.readText()) else JSONObject().put("installationId", UUID.randomUUID().toString()) + + init { if(!file.exists()) save() } + + val installationId: String get() = values.getString("installationId") + val deviceId: String get() = values.optString("deviceId") + + /** Credentials are headers only and never become QQQ process inputs. */ + fun headers(): Map = if(deviceId.isBlank()) emptyMap() else mapOf( + "X-QQQ-Device-Id" to deviceId, "X-QQQ-Device-Token" to values.getString("deviceToken") + ) + + /** Save before exposing the enrollment result to UI. */ + fun enroll(response: JSONObject) + { + values.put("deviceId", response.get("deviceId").toString()) + values.put("deviceToken", response.getString("deviceToken")) + save() + } + + /** A lost enrollment response requires a fresh identity and a new administrator approval. */ + fun restartPendingEnrollment() + { + check(deviceId.isBlank()) { "An enrolled identity cannot be silently replaced" } + values = JSONObject().put("installationId", UUID.randomUUID().toString()) + save() + } + + /** Android Keystore owns the AES key across application launches. */ + private fun key(): SecretKey + { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(alias, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build()) + }.generateKey() + } + + /** Atomic replacement prevents a partially written token after interruption. */ + private fun save() + { + val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, key()) } + val encrypted = cipher.doFinal(values.toString().toByteArray(Charsets.UTF_8)) + val encoded = Base64.encodeToString(cipher.iv + encrypted, Base64.NO_WRAP) + val atomicFile = android.util.AtomicFile(file) + val stream = atomicFile.startWrite() + try { stream.write(encoded.toByteArray()); atomicFile.finishWrite(stream) } + catch(exception: Exception) { atomicFile.failWrite(stream); throw exception } + } + + /** Corrupt storage fails closed instead of silently enrolling a replacement identity. */ + private fun decrypt(encoded: String): JSONObject + { + val bytes = Base64.decode(encoded, Base64.NO_WRAP) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12))) + return JSONObject(String(cipher.doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8)) + } +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt new file mode 100644 index 0000000..41c489b --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScanEvent.kt @@ -0,0 +1,21 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import java.time.Instant +import java.util.UUID + +/** One physical scan remains one event even when its HTTP request is retried. */ +data class ScanEvent( + val barcode: String, + val symbology: String, + val scanSource: String, + val eventId: String = UUID.randomUUID().toString(), + val capturedAt: String = Instant.now().toString() +) +{ + /** A fixed schema prevents arbitrary intent extras becoming process inputs. */ + fun values(): Map = mapOf( + "barcode" to barcode, "symbology" to symbology, "scanSource" to scanSource, + "eventId" to eventId, "capturedAt" to capturedAt + ) +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt new file mode 100644 index 0000000..a2886bb --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt @@ -0,0 +1,209 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.rememberNavController +import com.kingsrook.qqq.frontend.android.core.controllers.NetworkQQQRepository +import com.kingsrook.qqq.frontend.android.core.model.Environment +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.TableBasedAuthenticationMetaData +import com.kingsrook.qqq.frontend.android.mobileapp.QMobileAppMain +import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer +import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalAuthenticatedContent +import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalCustomWidgetRenderer +import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel +import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState +import java.io.File +import java.net.URI +import java.security.MessageDigest +import kotlinx.coroutines.CancellationException + +/** Deployable generic host: select a server, authenticate, enroll, then run its QQQ mobile apps. */ +class ScannerActivity : ComponentActivity() +{ + companion object + { + private val credentialsByServer = mutableMapOf() + private val storesByServer = mutableMapOf>() + } + + override fun onCreate(savedInstanceState: Bundle?) + { + super.onCreate(savedInstanceState) + val preferences = getSharedPreferences("host", MODE_PRIVATE) + setContent() + { + MaterialTheme() + { + Surface() + { + var server by remember { mutableStateOf(preferences.getString("server", null)) } + val currentServer = server + if(currentServer == null) + { + ServerForm { value -> preferences.edit().putString("server", value).apply(); server = value } + } + else + { + Column() + { + TextButton(onClick = { preferences.edit().remove("server").apply(); server = null }) { Text("Change server") } + Box(Modifier.weight(1f)) { ScannerHost(currentServer) } + } + } + } + } + } + } + + /** Server-scoped stores ensure switching installations cannot reuse a credential accidentally. */ + @Composable + private fun ScannerHost(server: String) + { + val credentialsResult = remember(server) { runCatching { credentialsByServer.getOrPut(server) { DeviceCredentials(applicationContext, server) } } } + val credentials = credentialsResult.getOrNull() + if(credentials == null) + { + Text("Device credentials could not be opened. Ask your administrator to reset this installation.", Modifier.padding(24.dp)) + return + } + val factory = remember(server) + { + QAppContainer.availableEnvironments = listOf(Environment("QQQ server", server)) + QViewModel.applicationName = "qqq-mobile-scanners" + QViewModel.applicationVersion = BuildConfig.VERSION_NAME + object : ViewModelProvider.Factory + { + override fun create(modelClass: Class): T + { + val hash = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) } + val dataStore = storesByServer.getOrPut(server) { PreferenceDataStoreFactory.create { File(noBackupFilesDir, "session-$hash.preferences_pb") } } + val repository = NetworkQQQRepository(server) { requestedServer -> + if(requestedServer == server) credentials.headers() else emptyMap() + } + @Suppress("UNCHECKED_CAST") + return QViewModel(repository, dataStore) as T + } + } + } + val qViewModel: QViewModel = viewModel(key = server, factory = factory) + qViewModel.setNavController(rememberNavController()) + val authentication = qViewModel.authenticationMetaDataLoadState + if(authentication is LoadState.Success && authentication.value !is TableBasedAuthenticationMetaData) + { + Text("This build supports username/password sign-in. This server uses another sign-in provider. Configure that provider for this app or choose another server.", Modifier.padding(24.dp)) + return + } + val api = remember(server) { DeviceApi(server, credentials) } + CompositionLocalProvider( + LocalAuthenticatedContent provides { model, content -> DeviceGate(model, api, credentials, content) }, + LocalCustomWidgetRenderer provides { parameters, modifier, disabled -> + if(parameters.widgetBlock.blockType == "SCANNER") + { + ScannerWidget(parameters, modifier, disabled) + true + } + else false + } + ) + { + QMobileAppMain(qViewModel, title = "QQQ Mobile", splashImage = null) + } + } +} + +/** URLs are origins only; release builds require HTTPS. */ +internal fun normalizeServerUrl(input: String, allowHttp: Boolean): String +{ + val uri = URI(input.trim()) + require(uri.scheme == "https" || (allowHttp && uri.scheme == "http")) { "Use an HTTPS server URL." } + require(uri.host != null && uri.userInfo == null && uri.query == null && uri.fragment == null) { "Use a server URL without credentials, query, or fragment." } + require(uri.path.isNullOrEmpty() || uri.path == "/") { "Use the server origin without a path." } + return URI(uri.scheme.lowercase(), null, uri.host.lowercase(), uri.port, "/", null, null).toASCIIString() +} + +/** Initial server configuration is editable until the host connects. */ +@Composable +private fun ServerForm(onConnect: (String) -> Unit) +{ + var url by remember { mutableStateOf(if(BuildConfig.DEBUG) "http://127.0.0.1:8080/" else "https://") } + var error by remember { mutableStateOf("") } + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) + { + Text("Connect QQQ Mobile", style = MaterialTheme.typography.headlineMedium) + OutlinedTextField(url, { url = it }, label = { Text("QQQ server URL") }, singleLine = true) + Button(onClick = { + try { onConnect(normalizeServerUrl(url, BuildConfig.DEBUG)) } + catch(exception: Exception) { error = exception.message ?: "Invalid server URL" } + }) { Text("Connect") } + if(error.isNotBlank()) Text(error) + if(BuildConfig.DEBUG) Text("Development build: HTTP is allowed for the local demo. The default URL uses USB port forwarding; see the TC22 setup guide.") + } +} + +/** Pending, revoked, or unavailable devices cannot expose application screens. */ +@Composable +private fun DeviceGate(viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, content: @Composable () -> Unit) +{ + var attempt by remember { mutableIntStateOf(0) } + var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") } + var error by remember { mutableStateOf("") } + var enrollmentConflict by remember { mutableStateOf(false) } + LaunchedEffect(viewModel.sessionUUID, attempt) + { + status = "CHECKING" + error = "" + enrollmentConflict = false + try + { + status = api.status(viewModel.sessionUUID, "${Build.MANUFACTURER} ${Build.MODEL}") + if(status == "APPROVED") viewModel.loadQInstance() + } + catch(exception: CancellationException) { throw exception } + catch(exception: EnrollmentConflict) { status = "ERROR"; error = exception.message ?: "Registration conflict"; enrollmentConflict = true } + catch(exception: Exception) { status = "ERROR"; error = exception.message ?: "Device check failed." } + } + if(status == "APPROVED") content() + else Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) + { + Text("Device approval", style = MaterialTheme.typography.headlineMedium) + Text(when(status) { + "CHECKING" -> "Checking device registration…" + "PENDING" -> "An administrator must approve this device before you can run mobile apps." + "REVOKED" -> "This device has been revoked. Contact your administrator." + else -> error.ifBlank { "Device status: $status" } + }) + if(credentials.deviceId.isNotBlank()) Text("Device ID: ${credentials.deviceId}") + Button(enabled = status != "CHECKING", onClick = { attempt++ }) { Text("Check approval") } + if(enrollmentConflict) Button(onClick = { credentials.restartPendingEnrollment(); attempt++ }) { Text("Start new registration") } + Button(onClick = { viewModel.requestLogout() }) { Text("Sign out") } + } +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt new file mode 100644 index 0000000..8829729 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt @@ -0,0 +1,115 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks.WidgetBlockParameters + +const val SCAN_ACTION = "io.qrun.mobile.scanner.SCAN" + +/** A server-defined SCANNER widget consumes typed DataWedge extras only while visible. */ +@Composable +fun ScannerWidget(parameters: WidgetBlockParameters, modifier: Modifier, disabled: Boolean) +{ + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + var manual by remember { mutableStateOf("") } + var notice by remember { mutableStateOf("") } + val currentDisabled by rememberUpdatedState(disabled) + val currentCallback by rememberUpdatedState(parameters.actionCallback) + val process = parameters.qViewModel?.activeProcessViewModel + LaunchedEffect(parameters.values["acknowledgedEventId"]) { notice = "" } + + /** Each new physical scan has an identity; retries are handled by ProcessViewModel. */ + fun submit(barcode: String, symbology: String, source: String) + { + if(currentDisabled || process?.pendingStepValues != null) + { + notice = "Wait for the previous scan to be confirmed." + return + } + if(barcode.isEmpty() || barcode.length > 4096) + { + notice = "Scan must contain between 1 and 4096 characters." + return + } + notice = "Sending scan…" + currentCallback?.invoke(ScanEvent(barcode, symbology, source).values()) + } + + val currentSubmit by rememberUpdatedState<(String, String, String) -> Unit>(::submit) + DisposableEffect(context, lifecycle) + { + var registered = false + val receiver = object : BroadcastReceiver() + { + override fun onReceive(receiverContext: Context?, intent: Intent?) + { + if(intent?.action != SCAN_ACTION) return + if(intent.getStringExtra("com.symbol.datawedge.decode_mode") == "multiple" || intent.hasExtra("com.symbol.datawedge.barcodes")) + { + notice = "Multiple barcode decoding is not supported. Configure DataWedge for a single barcode." + return + } + val barcode = intent.getStringExtra("com.symbol.datawedge.data_string") ?: return + val symbology = intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN" + val source = intent.getStringExtra("com.symbol.datawedge.source") ?: "scanner" + currentSubmit(barcode, symbology, source) + } + } + fun syncRegistration() + { + val resumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + if(resumed && !registered) + { + ContextCompat.registerReceiver(context, receiver, IntentFilter(SCAN_ACTION).apply { addCategory(Intent.CATEGORY_DEFAULT) }, ContextCompat.RECEIVER_EXPORTED) + registered = true + } + else if(!resumed && registered) + { + context.unregisterReceiver(receiver) + registered = false + } + } + val observer = LifecycleEventObserver { _, _ -> syncRegistration() } + lifecycle.addObserver(observer) + syncRegistration() + onDispose { lifecycle.removeObserver(observer); if(registered) context.unregisterReceiver(receiver) } + } + + Column(modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) + { + Text(parameters.widgetBlock.values["title"]?.toString() ?: "Scan a barcode") + Text(parameters.widgetBlock.values["instructions"]?.toString() ?: "Press the handheld scan trigger. Wait for server confirmation before scanning again.") + if(parameters.values["scanSaved"] == true && notice.isEmpty()) + { + Text("Saved scan ${parameters.values["scanId"] ?: ""}") + } + if(notice.isNotEmpty()) Text(notice) + OutlinedTextField(manual, { manual = it }, label = { Text("Manual barcode") }, enabled = !disabled, singleLine = true) + Button(enabled = !disabled && manual.isNotEmpty(), onClick = { submit(manual, "MANUAL", "manual") }) { Text("Submit barcode") } + } +} diff --git a/scanner-host/src/main/res/drawable/ic_scanner.xml b/scanner-host/src/main/res/drawable/ic_scanner.xml new file mode 100644 index 0000000..960df1b --- /dev/null +++ b/scanner-host/src/main/res/drawable/ic_scanner.xml @@ -0,0 +1,5 @@ + + + + diff --git a/scanner-host/src/main/res/xml/data_extraction_rules.xml b/scanner-host/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..2c6bad3 --- /dev/null +++ b/scanner-host/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/ScanEventTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScanEventTest.kt new file mode 100644 index 0000000..8e5b2f3 --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/ScanEventTest.kt @@ -0,0 +1,18 @@ +package io.qrun.mobile.scanner + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** Retrying a submission must not turn it into a second captured scan. */ +class ScanEventTest +{ + @Test + fun retryKeepsIdentityAndOriginalCaptureTime() + { + val event = ScanEvent("001234", "LABEL-TYPE-CODE128", "scanner") + assertEquals(event.values(), event.values()) + assertEquals("001234", event.values()["barcode"]) + assertNotEquals(event.eventId, ScanEvent("001234", "LABEL-TYPE-CODE128", "scanner").eventId) + } +} diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/ServerUrlTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/ServerUrlTest.kt new file mode 100644 index 0000000..4b00ab3 --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/ServerUrlTest.kt @@ -0,0 +1,24 @@ +package io.qrun.mobile.scanner + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** Credentials must never be sent to user-info URLs, unexpected paths, or release HTTP origins. */ +class ServerUrlTest +{ + @Test + fun normalizesOneCanonicalOrigin() + { + assertEquals("https://example.com/", normalizeServerUrl(" https://EXAMPLE.com ", false)) + assertEquals("http://192.168.1.20:8000/", normalizeServerUrl("http://192.168.1.20:8000/", true)) + } + + @Test + fun rejectsUnsafeOrigins() + { + listOf("http://example.com", "https://user:password@example.com", "https://example.com/path", "https://example.com/?token=x", "https://example.com/#fragment").forEach { url -> + assertThrows(IllegalArgumentException::class.java) { normalizeServerUrl(url, false) } + } + } +} diff --git a/scripts/ci/emulator-smoke.sh b/scripts/ci/emulator-smoke.sh new file mode 100644 index 0000000..d441e41 --- /dev/null +++ b/scripts/ci/emulator-smoke.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 +# Run only on a disposable emulator: verify the real scanner APK reaches setup. +set -euo pipefail + +if [[ "$(adb shell getprop ro.kernel.qemu | tr -d '\r')" != "1" ]]; then + echo "Refusing scanner smoke on a physical device." >&2 + exit 1 +fi + +evidence="scanner-host/build/emulator-smoke" +mkdir -p "$evidence" +adb install -r scanner-host/build/outputs/apk/debug/scanner-host-debug.apk +adb shell pm clear io.qrun.mobile.scanner +adb shell am start -W -n io.qrun.mobile.scanner/.ScannerActivity +for _attempt in {1..15}; do + adb shell uiautomator dump --compressed /sdcard/qqq-scanner-window.xml >/dev/null + adb pull /sdcard/qqq-scanner-window.xml "$evidence/window.xml" >/dev/null + if grep -Fq 'Connect QQQ Mobile' "$evidence/window.xml"; then + adb exec-out screencap -p >"$evidence/setup.png" + echo "Scanner setup screen verified on emulator." + exit 0 + fi + sleep 1 +done + +adb exec-out screencap -p >"$evidence/setup-failed.png" +echo "Scanner setup screen did not appear." >&2 +exit 1 diff --git a/settings.gradle.kts b/settings.gradle.kts index 92c5298..59c4b2c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,3 +23,4 @@ rootProject.name = "QQQ Android" include(":qqq:android:core") include(":qqq:android:mobileapp") include(":qqqsampleandroidmobileapp") +include(":scanner-host") -- 2.54.0 From a5621c586aec0b32e03dca0fce34bddba01f68c0 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 29 Oct 2026 15:18:36 -0500 Subject: [PATCH 02/39] feat: add customer branding and live connection diagnostics --- README.md | 6 + docs/SESSION-STATE.md | 12 +- docs/TODO.md | 10 +- .../core/controllers/NetworkQQQRepository.kt | 3 +- .../controllers/RequestOutcomeInterceptor.kt | 31 +++++ .../controllers/NetworkQQQRepositoryTest.kt | 38 ++++++ .../mobileapp/src/main/AndroidManifest.xml | 4 +- .../ui/authentication/QPasswordLogin.kt | 2 +- .../mobileapp/ui/horseshoe/QTopAppBar.kt | 9 +- scanner-host/build.gradle.kts | 4 + scanner-host/src/main/AndroidManifest.xml | 1 + .../java/io/qrun/mobile/scanner/DeviceApi.kt | 7 +- .../java/io/qrun/mobile/scanner/HostChrome.kt | 123 ++++++++++++++++++ .../io/qrun/mobile/scanner/HostRuntime.kt | 111 ++++++++++++++++ .../io/qrun/mobile/scanner/ScannerActivity.kt | 23 ++-- .../io/qrun/mobile/scanner/HostRuntimeTest.kt | 91 +++++++++++++ 16 files changed, 455 insertions(+), 20 deletions(-) create mode 100644 qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/RequestOutcomeInterceptor.kt create mode 100644 scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt create mode 100644 scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt create mode 100644 scanner-host/src/test/java/io/qrun/mobile/scanner/HostRuntimeTest.kt diff --git a/README.md b/README.md index 5fae27c..a19d5e8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ Create a profile associated with `io.qrun.mobile.scanner`, activity `*`: The receiver is active only while a scanner widget is visible and its activity is resumed. It reads `com.symbol.datawedge.data_string`, `label_type`, and `source`. Multi-decode payloads are rejected. Symbology/source are client-reported metadata, not proof of scanner hardware identity. See [Zebra Intent output](https://techdocs.zebra.com/datawedge/latest/guide/output/intent/). +## Customer branding and diagnostics + +The shared launcher name/icon remain QQQ Mobile. On connecting to a server, the host reads public `GET /mobile/v1/branding` (`displayName`, `primaryColor`, optional `logoUrl`). The name and Material theme apply to login, device approval, and native app screens. Names are limited to 80 printable characters and colors to `#RRGGBB`; malformed values fall back to QQQ defaults. Button foregrounds choose readable black/white contrast. Optional logos must use HTTPS, are fetched without session/device headers or cookies, and cannot prevent login if unavailable. Responses are size-limited, redirects are disabled, and branding is reset when changing servers. Reconnect to pick up server branding changes; no APK rebuild is needed. + +The persistent footer shows **Checking**, **Connected**, or **Disconnected** with the last check time. It checks `GET /mobile/v1/status` every five seconds while foregrounded and on network changes; `Connected` means a valid `{ "status": "ok" }` response, not sign-in or device approval. Tap the footer for **Copy**, **Clear**, and **Close** diagnostics. The in-memory log holds the latest 100 fixed event codes with timestamps, HTTP status, and latency for branding, connection checks, native QQQ calls (including login/process requests), and device enrollment/status requests. A transport failure has no HTTP status; an HTTP response retains its status, including 401/403/500 errors. The log contains no credentials, scan values, request paths, queries, raw response text, or exception messages. Changing servers clears the displayed diagnostics. Monitoring never queues or retries a scan. + ## Online-only behavior There is no offline queue. One submitted scan is retained in memory until its `acknowledgedEventId` is returned. If a response is uncertain, **Retry same request** preserves its event ID and original timestamp; the server deduplicates it. Further scans are blocked until the pending request is resolved. Sign-out/reset clears the pending request; app termination loses in-memory state. Repeated physical scans deliberately receive distinct event IDs. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index c0c88f9..907ac01 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,11 +1,21 @@ # Session state +2026-10-29 verification: all 26 JVM tests passed, lint reported zero errors/six warnings, and the debug APK assembled successfully. Runtime diagnostics now include sanitized native API and device-request outcomes, in addition to branding and heartbeat events. Android 15 system-bar insets were corrected after emulator inspection. The shared mobile library now declares its connectivity permission for all consumers. Login copy uses a neutral title beneath the customer branding. CI APK artifacts include their own `SHA256SUMS`; local builds can be hashed with `Get-FileHash`. + +Repository implementation is published for review in [PR #8](https://github.com/QRun-IO/qqq-android/pull/8); it has not been merged or released. The initial scanner commit passed hosted CircleCI and scanner GitHub Actions verification. The companion qbit is published at https://github.com/QRun-IO/qbit-mobile-scanners, with hosted server/CodeQL/demo checks passing. An API35 emulator is available locally, with server `http://127.0.0.1:8080/` forwarded to host demo port57832. Branding, native login screen and diagnostics were visually verified without entering user credentials. Final-APK native instrumentation and connection-loss recovery are being recorded separately. + +Earlier implementation records follow; the publication and emulator statements above supersede their original pending status. + +Runtime branding and diagnostics added: public server metadata supplies display name, primary color, and an optional HTTPS logo; malformed/missing metadata uses QQQ defaults and server changes reset presentation. A persistent foreground connection footer checks the public status endpoint, with bounded credential-free diagnostics Copy/Clear. Shared top-bar colors now use the matching Material foreground role and its connectivity callback is disposed correctly. Branding/status protocol, isolation, malformed input, response redaction, and log bounds have JVM regression tests. Validation passed: full 23 JVM tests (core12, mobileapp4, scanner7), debug APK assembly, and lint with zero errors / six warnings. The new APK SHA256 is F388AF488688DE87811FE32C94D07601DC3DD61ADAC139069ADCFA627E21D01C. Updated emulator UI acceptance is pending. + +Follow-up: diagnostics now also collect fixed native QQQ/device request outcomes through an optional, exception-isolated observer (status and latency only). Added regression cases for HTTP errors, transport failure, observer exceptions, and category formatting. Emulator confirmed F388 branding/login/Connected footer/diagnostics; observed Android15 system-bar overlap corrected with safe-drawing insets. Final verification for these follow-ups is pending. + 2026-10-29: Implementing QQQ Mobile scanner host and native host extensions. First target is Zebra TC22, online only. Backend companion is `qbit-mobile-scanners`; metadata-defined apps/processes remain the native execution model. Device approval is mandatory. Local demo uses QQQ TABLE_BASED auth on QQQ4.1.0-RC.20. Implemented server selection, per-server enrollment credentials, approval gate, SCANNER widget DataWedge input, explicit same-event retry, native password login, host extension points, and removal of session/token logging from touched authentication paths. No commit or push. Validation passed: 19 JVM tests (core12, mobileapp4, scanner3), debug APK assembly, APK signature verification, and lint with zero errors. Five lint warnings remain: three inherited Navigation custom-check compatibility warnings and two dependency/catalog notices. Real sanitized demo HTTP metadata/process/init fixtures deserialize through the native DTOs. Server logout, HttpOnly-cookie login, and per-origin header isolation have regression tests. Physical TC22 acceptance remains required. -Final debug APK SHA256: B3E160CE3279C369CEDB425AC6E777A2729CE8A2E430E7D6F46D35C6376F8214. +Previous pre-branding debug APK SHA256: B3E160CE3279C369CEDB425AC6E777A2729CE8A2E430E7D6F46D35C6376F8214. Repository readiness added: pinned GitHub scanner tests/lint/APK artifacts and optional emulator workflow; CODEOWNERS, contribution/security/conduct guidance, issue/PR templates, Dependabot, changelog, editor/line-ending policy, and scoped agent instructions. `actionlint`, `shellcheck`, `shfmt`, and `git diff --check` passed. Upstream main `63fca89` was fast-forwarded and all implementation files restored without conflicts; the managed CircleCI Android profile and QRun-IO Maven registry updates are preserved. The full Gradle verification command passed again after reconciliation (16 executed tasks, remaining unchanged tasks up to date). Remote CI and emulator acceptance have not yet been recorded here. diff --git a/docs/TODO.md b/docs/TODO.md index 6176fc2..12a696f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,11 +1,15 @@ # TODO -- [x] Complete Gradle unit tests, debug APK, signature, and lint verification (19 tests; zero lint errors). +- [x] Add runtime server branding and a foreground connection footer with bounded sanitized diagnostics. +- [ ] Verify customer logo/theme and connection transitions on the emulator and physical TC22. + +- [x] Complete Gradle unit tests, debug APK, signature, and lint verification (26 tests; zero lint errors). - [ ] Run physical TC22 acceptance: DataWedge trigger, rotation/background lifecycle, duplicate retry, revoked device, bad credentials, sign-out and re-login. - [ ] Verify release APK signing/distribution separately; current deliverable is a debug APK. -- [ ] Reconcile historical source license headers with the root license before publication. +- [ ] Reconcile historical source license headers with the root license before production distribution. - [ ] Configure/test production identity-provider integration if selected; version one deliberately supports TABLE_BASED sign-in only and rejects other providers with a clear message. Offline queue/background synchronization is deliberately outside version one. -- [ ] Verify remote Scanner Android workflow after publication and run the optional emulator lane. +- [x] Verify remote Scanner Android workflow after publication. +- [ ] Run the optional hosted emulator lane after its workflow is merged to the default branch. - [ ] Run the native sample instrumentation suite and full interactive scanner demo on an emulator. diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt index c8c772e..d5c9182 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt @@ -46,7 +46,7 @@ import java.util.concurrent.ConcurrentHashMap /*************************************************************************** ** ***************************************************************************/ -class NetworkQQQRepository(private var baseUrl: String, private val additionalHeaders: (String) -> Map = { emptyMap() }) : QQQRepository +class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val additionalHeaders: (String) -> Map = { emptyMap() }) : QQQRepository { private val sessionUUIDs = ConcurrentHashMap() private val retroJson = Json { ignoreUnknownKeys = true } @@ -82,6 +82,7 @@ class NetworkQQQRepository(private var baseUrl: String, private val additionalHe OkHttpClient.Builder() .followRedirects(false) .followSslRedirects(false) + .addInterceptor(RequestOutcomeInterceptor { requestObserver(it) }) .addInterceptor { chain -> val request = chain.request().newBuilder() sessionUUIDs[baseUrl]?.takeIf { it.isNotBlank() }?.let { request.header("Cookie", "sessionUUID=$it") } diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/RequestOutcomeInterceptor.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/RequestOutcomeInterceptor.kt new file mode 100644 index 0000000..8b27c37 --- /dev/null +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/RequestOutcomeInterceptor.kt @@ -0,0 +1,31 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.core.controllers + +import java.io.IOException +import okhttp3.Interceptor +import okhttp3.Response + +/** Deliberately excludes URLs, headers, bodies, and exception messages. Null status means transport failure. */ +data class RequestOutcome(val httpStatus: Int?, val elapsedMillis: Long) + +/** Optional diagnostics must never change request behavior, even if an observer throws. */ +class RequestOutcomeInterceptor(private val observer: (RequestOutcome) -> Unit) : Interceptor +{ + override fun intercept(chain: Interceptor.Chain): Response + { + val started = System.nanoTime() + fun report(status: Int?) + { + runCatching { observer(RequestOutcome(status, (System.nanoTime() - started) / 1_000_000)) } + } + try + { + return chain.proceed(chain.request()).also { report(it.code) } + } + catch(exception: IOException) + { + report(null) + throw exception + } + } +} diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt index 3801e9e..f4c8888 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt @@ -7,11 +7,49 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse import org.junit.Test /** Modern servers return HttpOnly session cookies; both credentials and redirects remain origin-scoped. */ class NetworkQQQRepositoryTest { + @Test + fun reportsOnlyStatusAndTimingAndObserverFailuresCannotBreakRequests() = runBlocking() + { + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody("{}")) + server.enqueue(MockResponse().setResponseCode(503).setBody("private-response-barcode")) + val outcomes = mutableListOf() + val origin = server.url("/").toString() + val repository = NetworkQQQRepository(origin, requestObserver = { outcomes.add(it); error("observer failed") }) + repository.setEnvironment(Environment("test", origin)) + repository.setSessionUUID("private-cookie") + repository.getMetaData("private-query", "1", "test", "1") + assertTrue(runCatching { repository.getMetaData("test", "1", "test", "1") }.isFailure) + assertEquals(listOf(200, 503), outcomes.map { it.httpStatus }) + assertTrue(outcomes.all { it.elapsedMillis >= 0 }) + assertFalse(outcomes.toString().contains("private")) + assertFalse(outcomes.toString().contains("http:")) + } + } + + @Test + fun transportFailureReportsNullStatusWithoutExceptionMessage() = runBlocking() + { + val server = MockWebServer() + server.start() + val origin = server.url("/").toString() + server.shutdown() + val outcomes = mutableListOf() + val repository = NetworkQQQRepository(origin, requestObserver = { outcomes.add(it) }) + repository.setEnvironment(Environment("test", origin)) + assertTrue(runCatching { repository.getMetaData("test", "1", "test", "1") }.isFailure) + assertEquals(1, outcomes.size) + assertNull(outcomes.single().httpStatus) + assertFalse(outcomes.toString().contains("localhost")) + } + @Test fun logoutRevokesServerSessionBeforeDroppingTheCookie() = runBlocking() { diff --git a/qqq/android/mobileapp/src/main/AndroidManifest.xml b/qqq/android/mobileapp/src/main/AndroidManifest.xml index a5918e6..9a5ae52 100644 --- a/qqq/android/mobileapp/src/main/AndroidManifest.xml +++ b/qqq/android/mobileapp/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt index 89099ab..ea17a9e 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/authentication/QPasswordLogin.kt @@ -28,7 +28,7 @@ fun QPasswordLogin(viewModel: QViewModel, modifier: Modifier = Modifier) val busy = submitted && viewModel.manageSessionLoadState is LoadState.Loading Column(modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Sign in to QQQ") + Text("Sign in") OutlinedTextField(username, { username = it }, label = { Text("Username") }, singleLine = true, enabled = !busy) OutlinedTextField(password, { password = it }, label = { Text("Password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), enabled = !busy) diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QTopAppBar.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QTopAppBar.kt index c767a01..cccafb7 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QTopAppBar.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/ui/horseshoe/QTopAppBar.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -84,12 +85,16 @@ fun QTopAppBar(qViewModel: QViewModel, defaultTitle: String = "A QQQ Android App } val connectivityManager = getSystemService(LocalContext.current, ConnectivityManager::class.java) as ConnectivityManager - connectivityManager.requestNetwork(networkRequest, networkCallback) + DisposableEffect(connectivityManager) + { + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + onDispose { connectivityManager.unregisterNetworkCallback(networkCallback) } + } TopAppBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.testTag("topAppBar.title")) }, navigationIcon = diff --git a/scanner-host/build.gradle.kts b/scanner-host/build.gradle.kts index 54b149d..0706ca1 100644 --- a/scanner-host/build.gradle.kts +++ b/scanner-host/build.gradle.kts @@ -37,6 +37,10 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.material3) implementation(libs.androidx.ui) + implementation(libs.kotlinx.serialization.json) + implementation(libs.coil.compose) + implementation(libs.coil.svg) implementation("com.squareup.okhttp3:okhttp:4.11.0") testImplementation(libs.junit) + testImplementation("com.squareup.okhttp3:mockwebserver:4.11.0") } diff --git a/scanner-host/src/main/AndroidManifest.xml b/scanner-host/src/main/AndroidManifest.xml index 966a04f..b69a2e5 100644 --- a/scanner-host/src/main/AndroidManifest.xml +++ b/scanner-host/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + Unit = {}) { - private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).build() + private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) + .addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).build() /** Check enrollment on demand; approval remains a server-side decision. */ suspend fun status(session: String, displayName: String): String = withContext(Dispatchers.IO) diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt new file mode 100644 index 0000000..4089727 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostChrome.kt @@ -0,0 +1,123 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import android.net.ConnectivityManager +import android.net.Network +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import coil3.ImageLoader +import coil3.compose.AsyncImage +import coil3.svg.SvgDecoder +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withTimeoutOrNull + +/** Lifetime is keyed by selected server: presentation and diagnostics cannot bleed across customers. */ +@Composable +internal fun HostChrome(server: String, onChangeServer: () -> Unit, content: @Composable (HostBrand, DiagnosticLog) -> Unit) +{ + val runtime = remember(server) { HostRuntime(server) } + var brand by remember(server) { mutableStateOf(HostBrand()) } + var logo by remember(server) { mutableStateOf(null) } + var connection by remember(server) { mutableStateOf("Checking") } + var checkedAt by remember(server) { mutableStateOf("not checked") } + var showDiagnostics by remember(server) { mutableStateOf(false) } + var diagnosticText by remember(server) { mutableStateOf("") } + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + val imageLoader = remember(context) { ImageLoader.Builder(context).components { add(SvgDecoder.Factory()) }.build() } + DisposableEffect(imageLoader) { onDispose { imageLoader.shutdown() } } + LaunchedEffect(runtime) + { + brand = runtime.branding() + logo = brand.logoUrl?.let { runtime.logo(it) } + diagnosticText = runtime.diagnostics.text() + } + LaunchedEffect(runtime, lifecycle) + { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) + { + val changed = Channel(Channel.CONFLATED) + val connectivity = context.getSystemService(ConnectivityManager::class.java) + val callback = object : ConnectivityManager.NetworkCallback() + { + override fun onAvailable(network: Network) { changed.trySend(Unit) } + override fun onLost(network: Network) { changed.trySend(Unit) } + } + connectivity.registerDefaultNetworkCallback(callback) + try + { + connection = "Checking" + while(isActive) + { + connection = if(runtime.connected()) "Connected" else "Disconnected" + checkedAt = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + diagnosticText = runtime.diagnostics.text() + withTimeoutOrNull(5000) { changed.receive() } + } + } + finally { connectivity.unregisterNetworkCallback(callback); changed.close() } + } + } + val primary = Color(0xFF000000 or brand.primaryColor.drop(1).toLong(16)) + val onPrimary = if(useBlackOnPrimary(brand.primaryColor)) Color.Black else Color.White + MaterialTheme(colorScheme = lightColorScheme(primary = primary, onPrimary = onPrimary, + primaryContainer = lerp(primary, Color.White, 0.85f), onPrimaryContainer = Color.Black)) + { + Surface() + { + Column(Modifier.fillMaxSize()) + { + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) + { + logo?.let { AsyncImage(model = it, imageLoader = imageLoader, contentDescription = "${brand.displayName} logo", modifier = Modifier.size(40.dp)) } + Text(brand.displayName, Modifier.weight(1f), style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + TextButton(onClick = onChangeServer) { Text("Change server") } + } + Box(Modifier.weight(1f)) { content(brand, runtime.diagnostics) } + HorizontalDivider() + TextButton(onClick = { diagnosticText = runtime.diagnostics.text(); showDiagnostics = true }, modifier = Modifier.fillMaxWidth()) + { + Text("$connection · $checkedAt · Diagnostics", color = MaterialTheme.colorScheme.onSurface) + } + } + } + if(showDiagnostics) + { + val clipboard = LocalClipboardManager.current + AlertDialog(onDismissRequest = { showDiagnostics = false }, title = { Text("Connection diagnostics") }, + text = { + Column() + { + Text("Connection checks server reachability only. Sign-in and device approval are checked separately.") + Text(diagnosticText.ifBlank { "No events." }, Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState())) + } + }, + confirmButton = { TextButton(onClick = { clipboard.setText(AnnotatedString(diagnosticText)) }) { Text("Copy") } }, + dismissButton = { + Row() + { + TextButton(onClick = { runtime.diagnostics.clear(); diagnosticText = "" }) { Text("Clear") } + TextButton(onClick = { showDiagnostics = false }) { Text("Close") } + } + }) + } + } +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt new file mode 100644 index 0000000..d731f40 --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt @@ -0,0 +1,111 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import java.net.URI +import java.time.Instant +import java.util.ArrayDeque +import java.util.concurrent.TimeUnit +import kotlin.math.pow +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome + +/** Public presentation data; never mixed with a session or device credential. */ +internal data class HostBrand(val displayName: String = "QQQ Mobile Scanners", val primaryColor: String = "#1565C0", val logoUrl: String? = null) +{ + companion object + { + fun parse(payload: String): HostBrand + { + val fields = runCatching { Json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: return HostBrand() + fun string(name: String): String? = (fields[name] as? JsonPrimitive)?.takeIf { it.isString }?.content + val name = string("displayName")?.trim()?.takeIf { it.length in 1..80 && it.none { c -> c.isISOControl() || c == '<' || c == '>' } } + val color = string("primaryColor")?.takeIf { Regex("#[0-9a-fA-F]{6}").matches(it) }?.uppercase() + val logo = string("logoUrl")?.takeIf { isSafeLogoUrl(it) } + return HostBrand(name ?: HostBrand().displayName, color ?: HostBrand().primaryColor, logo) + } + } +} + +internal fun isSafeLogoUrl(value: String): Boolean = runCatching { + val uri = URI(value) + value.length <= 2048 && uri.scheme == "https" && uri.host != null && uri.userInfo == null && uri.fragment == null +}.getOrDefault(false) + +/** Choose the higher WCAG contrast of black/white against the supplied opaque color. */ +internal fun useBlackOnPrimary(hex: String): Boolean +{ + val channels = listOf(1, 3, 5).map { hex.substring(it, it + 2).toInt(16) / 255.0 } + .map { if(it <= 0.04045) it / 12.92 else ((it + 0.055) / 1.055).pow(2.4) } + val luminance = channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722 + return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) +} + +internal enum class DiagnosticCode { BRAND_LOADED, BRAND_DEFAULT, LOGO_LOADED, LOGO_UNAVAILABLE, CONNECTION_OK, CONNECTION_FAILED, NATIVE_RESPONSE, NATIVE_TRANSPORT_FAILED, DEVICE_RESPONSE, DEVICE_TRANSPORT_FAILED } +internal data class DiagnosticEvent(val time: Instant, val code: DiagnosticCode, val httpStatus: Int?, val elapsedMillis: Long) + +/** No free-form messages: raw responses, URLs, credentials, and barcode data cannot enter this log. */ +internal class DiagnosticLog(private val capacity: Int = 100) +{ + private val events = ArrayDeque() + @Synchronized fun record(code: DiagnosticCode, status: Int?, elapsedMillis: Long) + { + events.addLast(DiagnosticEvent(Instant.now(), code, status, elapsedMillis.coerceAtLeast(0))) + while(events.size > capacity) events.removeFirst() + } + @Synchronized fun text(): String = events.joinToString("\n") { "${it.time} ${it.code} HTTP=${it.httpStatus ?: "none"} ${it.elapsedMillis}ms" } + @Synchronized fun clear() { events.clear() } + + fun nativeRequest(outcome: RequestOutcome) = record(if(outcome.httpStatus == null) DiagnosticCode.NATIVE_TRANSPORT_FAILED else DiagnosticCode.NATIVE_RESPONSE, outcome.httpStatus, outcome.elapsedMillis) + fun deviceRequest(outcome: RequestOutcome) = record(if(outcome.httpStatus == null) DiagnosticCode.DEVICE_TRANSPORT_FAILED else DiagnosticCode.DEVICE_RESPONSE, outcome.httpStatus, outcome.elapsedMillis) +} + +/** Dedicated unauthenticated client with no cookie jar, redirects, or disk cache. */ +internal class HostRuntime(private val server: String, val diagnostics: DiagnosticLog = DiagnosticLog()) +{ + private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) + .callTimeout(5, TimeUnit.SECONDS).build() + + private suspend fun fetch(url: String, limit: Long, success: DiagnosticCode, failure: DiagnosticCode, validate: (ByteArray) -> Boolean = { true }): ByteArray? = withContext(Dispatchers.IO) + { + val start = System.nanoTime() + var status: Int? = null + try + { + client.newCall(Request.Builder().url(url).header("Cache-Control", "no-store").build()).execute().use { response -> + status = response.code + val body = response.body + if(!response.isSuccessful || body == null || body.contentLength() > limit) error("Unavailable") + val source = body.source() + source.request(limit + 1) + if(source.buffer.size > limit) error("Oversized") + val bytes = source.readByteArray() + if(!validate(bytes)) error("Unexpected response") + diagnostics.record(success, status, (System.nanoTime() - start) / 1_000_000) + bytes + } + } + catch(exception: CancellationException) { throw exception } + catch(exception: Exception) + { + diagnostics.record(failure, status, (System.nanoTime() - start) / 1_000_000) + null + } + } + + suspend fun branding(): HostBrand = fetch(server + "mobile/v1/branding", 8192, DiagnosticCode.BRAND_LOADED, DiagnosticCode.BRAND_DEFAULT) + ?.toString(Charsets.UTF_8)?.let { HostBrand.parse(it) } ?: HostBrand() + + suspend fun logo(url: String): ByteArray? = if(isSafeLogoUrl(url)) fetch(url, 1_048_576, DiagnosticCode.LOGO_LOADED, DiagnosticCode.LOGO_UNAVAILABLE) else null + + suspend fun connected(): Boolean = fetch(server + "mobile/v1/status", 1024, DiagnosticCode.CONNECTION_OK, DiagnosticCode.CONNECTION_FAILED) { + val fields = Json.parseToJsonElement(it.toString(Charsets.UTF_8)) as? JsonObject + (fields?.get("status") as? JsonPrimitive)?.let { status -> status.isString && status.content == "ok" } == true + } != null +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt index a2886bb..da88179 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt @@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -18,6 +21,8 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf @@ -63,7 +68,7 @@ class ScannerActivity : ComponentActivity() { MaterialTheme() { - Surface() + Surface(Modifier.windowInsetsPadding(WindowInsets.safeDrawing)) { var server by remember { mutableStateOf(preferences.getString("server", null)) } val currentServer = server @@ -73,10 +78,10 @@ class ScannerActivity : ComponentActivity() } else { - Column() + key(currentServer) { - TextButton(onClick = { preferences.edit().remove("server").apply(); server = null }) { Text("Change server") } - Box(Modifier.weight(1f)) { ScannerHost(currentServer) } + HostChrome(currentServer, onChangeServer = { preferences.edit().remove("server").apply(); server = null }) + { brand, diagnostics -> ScannerHost(currentServer, brand, diagnostics) } } } } @@ -86,7 +91,7 @@ class ScannerActivity : ComponentActivity() /** Server-scoped stores ensure switching installations cannot reuse a credential accidentally. */ @Composable - private fun ScannerHost(server: String) + private fun ScannerHost(server: String, brand: HostBrand, diagnostics: DiagnosticLog) { val credentialsResult = remember(server) { runCatching { credentialsByServer.getOrPut(server) { DeviceCredentials(applicationContext, server) } } } val credentials = credentialsResult.getOrNull() @@ -106,7 +111,7 @@ class ScannerActivity : ComponentActivity() { val hash = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) } val dataStore = storesByServer.getOrPut(server) { PreferenceDataStoreFactory.create { File(noBackupFilesDir, "session-$hash.preferences_pb") } } - val repository = NetworkQQQRepository(server) { requestedServer -> + val repository = NetworkQQQRepository(server, requestObserver = diagnostics::nativeRequest) { requestedServer -> if(requestedServer == server) credentials.headers() else emptyMap() } @Suppress("UNCHECKED_CAST") @@ -115,6 +120,8 @@ class ScannerActivity : ComponentActivity() } } val qViewModel: QViewModel = viewModel(key = server, factory = factory) + // A retained per-server ViewModel must write into this visit's fresh diagnostics log. + SideEffect { (qViewModel.qqqRepository as NetworkQQQRepository).requestObserver = diagnostics::nativeRequest } qViewModel.setNavController(rememberNavController()) val authentication = qViewModel.authenticationMetaDataLoadState if(authentication is LoadState.Success && authentication.value !is TableBasedAuthenticationMetaData) @@ -122,7 +129,7 @@ class ScannerActivity : ComponentActivity() Text("This build supports username/password sign-in. This server uses another sign-in provider. Configure that provider for this app or choose another server.", Modifier.padding(24.dp)) return } - val api = remember(server) { DeviceApi(server, credentials) } + val api = remember(server, diagnostics) { DeviceApi(server, credentials, diagnostics::deviceRequest) } CompositionLocalProvider( LocalAuthenticatedContent provides { model, content -> DeviceGate(model, api, credentials, content) }, LocalCustomWidgetRenderer provides { parameters, modifier, disabled -> @@ -135,7 +142,7 @@ class ScannerActivity : ComponentActivity() } ) { - QMobileAppMain(qViewModel, title = "QQQ Mobile", splashImage = null) + QMobileAppMain(qViewModel, title = brand.displayName, splashImage = null) } } } diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/HostRuntimeTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/HostRuntimeTest.kt new file mode 100644 index 0000000..d231f6d --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/HostRuntimeTest.kt @@ -0,0 +1,91 @@ +package io.qrun.mobile.scanner + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.* +import org.junit.Test +import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome + +class HostRuntimeTest +{ + @Test + fun requestDiagnosticsDistinguishNativeAndDeviceOutcomes() + { + val log = DiagnosticLog() + log.nativeRequest(RequestOutcome(401, 10)) + log.nativeRequest(RequestOutcome(null, 20)) + log.deviceRequest(RequestOutcome(403, 30)) + log.deviceRequest(RequestOutcome(null, 40)) + assertTrue(log.text().contains("NATIVE_RESPONSE HTTP=401 10ms")) + assertTrue(log.text().contains("NATIVE_TRANSPORT_FAILED HTTP=none 20ms")) + assertTrue(log.text().contains("DEVICE_RESPONSE HTTP=403 30ms")) + assertTrue(log.text().contains("DEVICE_TRANSPORT_FAILED HTTP=none 40ms")) + } + + @Test + fun malformedBrandFallsBackAndUnsafeLogoIsDiscarded() + { + for(payload in listOf("not json", "[]", "null", "{\"displayName\":12,\"primaryColor\":\"red\"}")) + assertEquals(HostBrand(), HostBrand.parse(payload)) + assertEquals(HostBrand("Acme", "#AB12FF"), HostBrand.parse("{\"displayName\":\" Acme \",\"primaryColor\":\"#ab12ff\",\"logoUrl\":\"http://example.com/logo.png\"}")) + assertEquals(HostBrand().displayName, HostBrand.parse("{\"displayName\":\"