feat: add native scanner host and Android verification workflows
This commit is contained in:
59 files changed
+1725
-170
No files matched your search
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.jar binary
|
||||||
|
*.png binary
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* @KofTwentyTwo
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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: ['*']
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+31
-1
@@ -1,4 +1,34 @@
|
|||||||
# Contributing to QQQ Android
|
# 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.
|
||||||
@@ -1,122 +1,75 @@
|
|||||||
# qqq-android
|
# QQQ Android
|
||||||
|
|
||||||
[](https://opensource.org/licenses/Apache-2.0)
|
[](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
|
## Modules
|
||||||
**Status:** Experimental
|
|
||||||
|
|
||||||
## 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
|
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.
|
||||||
- **Metadata Loading** - Fetch table and field definitions
|
|
||||||
- **Record Operations** - Query, insert, update, delete records
|
|
||||||
- **Process Execution** - Run backend processes from Android
|
|
||||||
|
|
||||||
## 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
|
## Zebra DataWedge profile
|
||||||
- Android SDK 26+
|
|
||||||
- QQQ backend running
|
|
||||||
|
|
||||||
### 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
|
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/).
|
||||||
dependencies {
|
|
||||||
implementation 'io.qrun:qqq-android:0.1.0'
|
## 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
|
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.
|
||||||
val client = QqqClient.Builder()
|
|
||||||
.baseUrl("https://your-qqq-app.com")
|
|
||||||
.build()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Basic Usage
|
See [emulator testing](docs/EMULATOR.md) for a local interactive demo, synthetic scanner broadcasts, and the disposable-emulator smoke check.
|
||||||
|
|
||||||
```kotlin
|
## CI and delivery
|
||||||
// Fetch records
|
|
||||||
val orders = client.query("order")
|
|
||||||
.filter("status", "pending")
|
|
||||||
.limit(20)
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
// Create record
|
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.
|
||||||
val newOrder = client.insert("order", mapOf(
|
|
||||||
"customerName" to "Acme Corp",
|
|
||||||
"total" to 100.00
|
|
||||||
))
|
|
||||||
|
|
||||||
// Run process
|
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).
|
||||||
client.runProcess("orderSync")
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
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.
|
||||||
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
|
|
||||||
|
|
||||||
## License
|
## 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+1
@@ -2,5 +2,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
|
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
@@ -51,6 +51,7 @@ dependencies {
|
|||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.11.0")
|
||||||
testImplementation(libs.junit.jupiter)
|
testImplementation(libs.junit.jupiter)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
@@ -66,4 +67,4 @@ dependencies {
|
|||||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20")
|
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20")
|
||||||
}
|
}
|
||||||
+44
-30
@@ -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.metadata.authentication.ManageSessionResponse
|
||||||
import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult
|
import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.Cookie
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okhttp3.Response
|
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import java.io.IOException
|
|
||||||
import java.util.TimeZone
|
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<String, String> = { emptyMap() }) : QQQRepository
|
||||||
{
|
{
|
||||||
|
private val sessionUUIDs = ConcurrentHashMap<String, String>()
|
||||||
private val retroJson = Json { ignoreUnknownKeys = true }
|
private val retroJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
private val qqqApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
|
private val qqqApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
|
||||||
@@ -79,7 +80,24 @@ class NetworkQQQRepository(private var baseUrl: String) : QQQRepository
|
|||||||
.addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType()))
|
||||||
.client(
|
.client(
|
||||||
OkHttpClient.Builder()
|
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()
|
.build()
|
||||||
)
|
)
|
||||||
.baseUrl(baseUrl)
|
.baseUrl(baseUrl)
|
||||||
@@ -91,7 +109,7 @@ class NetworkQQQRepository(private var baseUrl: String) : QQQRepository
|
|||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
override fun setSessionUUID(sessionUUID: String)
|
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
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
+8
@@ -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.metadata.authentication.ManageSessionResponse
|
||||||
import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult
|
import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Header
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
@@ -52,6 +54,12 @@ interface QQQApiService
|
|||||||
@POST(BASE_PATH + "manageSession")
|
@POST(BASE_PATH + "manageSession")
|
||||||
suspend fun manageSession(@Body body: ManageSessionRequest): ManageSessionResponse
|
suspend fun manageSession(@Body body: ManageSessionRequest): ManageSessionResponse
|
||||||
|
|
||||||
|
@POST(BASE_PATH + "manageSession")
|
||||||
|
suspend fun loginWithPassword(@Header("Authorization") authorization: String, @Body body: Map<String, String>): ManageSessionResponse
|
||||||
|
|
||||||
|
@POST(BASE_PATH + "logout")
|
||||||
|
suspend fun logout(): ResponseBody
|
||||||
|
|
||||||
///////////////////
|
///////////////////
|
||||||
// qqq meta data //
|
// qqq meta data //
|
||||||
///////////////////
|
///////////////////
|
||||||
|
|||||||
+7
@@ -43,6 +43,13 @@ interface QQQRepository
|
|||||||
|
|
||||||
suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse
|
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 getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance
|
||||||
|
|
||||||
suspend fun getProcessMetaData(processName: String): QProcessMetaData
|
suspend fun getProcessMetaData(processName: String): QProcessMetaData
|
||||||
|
|||||||
+2
-1
@@ -40,6 +40,7 @@ object AuthenticationSerializer : JsonContentPolymorphicSerializer<BaseAuthentic
|
|||||||
{
|
{
|
||||||
Auth0AuthenticationMetaData.TYPE -> Auth0AuthenticationMetaData.serializer()
|
Auth0AuthenticationMetaData.TYPE -> Auth0AuthenticationMetaData.serializer()
|
||||||
FullyAnonymousAuthenticationMetaData.TYPE -> FullyAnonymousAuthenticationMetaData.serializer()
|
FullyAnonymousAuthenticationMetaData.TYPE -> FullyAnonymousAuthenticationMetaData.serializer()
|
||||||
|
TableBasedAuthenticationMetaData.TYPE, "tableBased" -> TableBasedAuthenticationMetaData.serializer()
|
||||||
else -> throw SerializationException("Unknown type: $type")
|
else -> throw SerializationException("Unknown type: $type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -29,8 +29,8 @@ import kotlinx.serialization.Serializable
|
|||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ManageSessionResponse(
|
data class ManageSessionResponse(
|
||||||
val uuid: String,
|
val uuid: String? = null,
|
||||||
val values: MapOfStringAny = emptyMap(),
|
val values: MapOfStringAny = emptyMap(),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -10,6 +10,6 @@ import kotlinx.serialization.Serializable
|
|||||||
data class QJobComplete(
|
data class QJobComplete(
|
||||||
override val processUUID: String,
|
override val processUUID: String,
|
||||||
val values: MapOfStringAny = emptyMap(),
|
val values: MapOfStringAny = emptyMap(),
|
||||||
val nextStep: String,
|
val nextStep: String = "",
|
||||||
val processMetaDataAdjustment: ProcessMetaDataAdjustment? = null,
|
val processMetaDataAdjustment: ProcessMetaDataAdjustment? = null,
|
||||||
) : ProcessStepResult()
|
) : ProcessStepResult()
|
||||||
+70
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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<BaseAuthenticationMetaData>("""{"name":"users","type":"TABLE_BASED","values":{}}""")
|
||||||
|
assertTrue(metadata is TableBasedAuthenticationMetaData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun terminalProcessCompletionMayOmitNextStep()
|
||||||
|
{
|
||||||
|
val result = json.decodeFromString<ProcessStepResult>("""{"type":"COMPLETE","processUUID":"test-process","values":{}}""")
|
||||||
|
assertEquals("", (result as QJobComplete).nextStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rendersContractCapturedFromTheLiveDemo()
|
||||||
|
{
|
||||||
|
val metadata = json.decodeFromString<QInstance>(fixture("metadata"))
|
||||||
|
assertTrue(metadata.apps!!.containsKey("mobileScanApp"))
|
||||||
|
val process = json.decodeFromString<QProcessMetaData>(fixture("process"))
|
||||||
|
assertEquals("scanner", process.frontendSteps!!.first().format)
|
||||||
|
val init = json.decodeFromString<ProcessStepResult>(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() }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}]
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -79,5 +79,6 @@ dependencies {
|
|||||||
implementation(libs.coil.svg)
|
implementation(libs.coil.svg)
|
||||||
|
|
||||||
implementation(project(":qqq:android:core"))
|
implementation(project(":qqq:android:core"))
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
implementation(libs.auth0)
|
implementation(libs.auth0)
|
||||||
}
|
}
|
||||||
+20
@@ -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 }
|
||||||
|
}
|
||||||
+1
-1
@@ -79,7 +79,7 @@ fun QAuth0Login(qViewModel: QViewModel, modifier: Modifier = Modifier)
|
|||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
override fun onSuccess(result: Credentials)
|
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
|
auth0Success.value = true
|
||||||
|
|
||||||
qViewModel.doSetSessionUserFullName(result.user.nickname ?: "Unknown")
|
qViewModel.doSetSessionUserFullName(result.user.nickname ?: "Unknown")
|
||||||
|
|||||||
+2
-1
@@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData
|
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.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.horseshoe.QApplicationWrapper
|
||||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.LoadStateView
|
import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.LoadStateView
|
||||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.Reloader
|
import com.kingsrook.qqq.frontend.android.mobileapp.ui.utils.Reloader
|
||||||
@@ -61,7 +62,7 @@ fun QAuthenticationWrapper(qViewModel: QViewModel, modifier: Modifier = Modifier
|
|||||||
}
|
}
|
||||||
else if(isAuthenticated)
|
else if(isAuthenticated)
|
||||||
{
|
{
|
||||||
QApplicationWrapper(qViewModel, Modifier, qNavigator)
|
LocalAuthenticatedContent.current(qViewModel) { QApplicationWrapper(qViewModel, Modifier, qNavigator) }
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
+2
@@ -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.Auth0AuthenticationMetaData
|
||||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData
|
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.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.ui.common.FullSizedAllCenteredColumn
|
||||||
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
|
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -45,6 +46,7 @@ fun QLoginForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticatio
|
|||||||
{
|
{
|
||||||
when (authenticationMetaData)
|
when (authenticationMetaData)
|
||||||
{
|
{
|
||||||
|
is TableBasedAuthenticationMetaData -> QPasswordLogin(qViewModel, modifier)
|
||||||
is Auth0AuthenticationMetaData ->
|
is Auth0AuthenticationMetaData ->
|
||||||
{
|
{
|
||||||
QAuth0Login(qViewModel, modifier)
|
QAuth0Login(qViewModel, modifier)
|
||||||
|
|||||||
+4
-3
@@ -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.Auth0AuthenticationMetaData
|
||||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData
|
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.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.ui.common.FullSizedAllCenteredColumn
|
||||||
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
|
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ fun QLogoutForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticati
|
|||||||
QAuth0Logout(qViewModel, modifier)
|
QAuth0Logout(qViewModel, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
is FullyAnonymousAuthenticationMetaData ->
|
is FullyAnonymousAuthenticationMetaData, is TableBasedAuthenticationMetaData ->
|
||||||
{
|
{
|
||||||
LaunchedEffect(true)
|
LaunchedEffect(true)
|
||||||
{
|
{
|
||||||
@@ -62,9 +63,9 @@ fun QLogoutForm(qViewModel: QViewModel, authenticationMetaData: BaseAuthenticati
|
|||||||
|
|
||||||
FullSizedAllCenteredColumn()
|
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")
|
Text("Log back in")
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -122,6 +122,8 @@ fun QProcessHome(qViewModel: QViewModel, qInstance: QInstance, modifier: Modifie
|
|||||||
LoadStateView(
|
LoadStateView(
|
||||||
loadStates,
|
loadStates,
|
||||||
modifier = Modifier,
|
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"
|
loadingText = "Loading Process"
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|||||||
+6
-2
@@ -25,6 +25,7 @@ package com.kingsrook.qqq.frontend.android.mobileapp.ui.widgets.blocks
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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
|
** 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)
|
"IMAGE" -> ImageWidgetBlock(params, modifier)
|
||||||
"INPUT_FIELD" -> InputFieldWidgetBlock(params, modifier, disableControls)
|
"INPUT_FIELD" -> InputFieldWidgetBlock(params, modifier, disableControls)
|
||||||
"TEXT" -> TextWidgetBlock(params, modifier)
|
"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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+45
-4
@@ -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 com.kingsrook.qqq.frontend.android.core.model.widgets.WidgetBlock
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.cancelChildren
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
|
||||||
@@ -208,6 +209,8 @@ open class ProcessViewModel : ViewModel()
|
|||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
fun reset()
|
fun reset()
|
||||||
{
|
{
|
||||||
|
viewModelScope.coroutineContext.cancelChildren()
|
||||||
|
pendingStepValues = null
|
||||||
processMetaDataLoadState = LoadState.Loading(Unit)
|
processMetaDataLoadState = LoadState.Loading(Unit)
|
||||||
processMetaData = null
|
processMetaData = null
|
||||||
initLoadState = LoadState.Loading(Unit)
|
initLoadState = LoadState.Loading(Unit)
|
||||||
@@ -249,6 +252,7 @@ open class ProcessViewModel : ViewModel()
|
|||||||
|
|
||||||
LoadState.Success(processMetaData)
|
LoadState.Success(processMetaData)
|
||||||
}
|
}
|
||||||
|
catch(e: kotlinx.coroutines.CancellationException) { throw e }
|
||||||
catch(e: Exception)
|
catch(e: Exception)
|
||||||
{
|
{
|
||||||
Log.w(TAG, "Error loading ProcessMetaData", e)
|
Log.w(TAG, "Error loading ProcessMetaData", e)
|
||||||
@@ -272,6 +276,7 @@ open class ProcessViewModel : ViewModel()
|
|||||||
val value = qqqRepository.processInit(processName, emptyMap())
|
val value = qqqRepository.processInit(processName, emptyMap())
|
||||||
evaluateProcessResult(value)
|
evaluateProcessResult(value)
|
||||||
}
|
}
|
||||||
|
catch(e: kotlinx.coroutines.CancellationException) { throw e }
|
||||||
catch(e: Exception)
|
catch(e: Exception)
|
||||||
{
|
{
|
||||||
Log.w(TAG, "Error initing process", e)
|
Log.w(TAG, "Error initing process", e)
|
||||||
@@ -293,6 +298,7 @@ open class ProcessViewModel : ViewModel()
|
|||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
fun runStep(eventValues: Map<String, Any?> = emptyMap())
|
fun runStep(eventValues: Map<String, Any?> = emptyMap())
|
||||||
{
|
{
|
||||||
|
if(waitingOnBackend) return
|
||||||
val localProcessUUID = processUUID
|
val localProcessUUID = processUUID
|
||||||
val localActiveStepName = activeStepName
|
val localActiveStepName = activeStepName
|
||||||
|
|
||||||
@@ -321,6 +327,9 @@ open class ProcessViewModel : ViewModel()
|
|||||||
valuesToSubmit[entry.key] = entry.value
|
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 //
|
// 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)
|
val value = qqqRepository.processStep(processName, localProcessUUID, localActiveStepName, valuesToSubmit, stepTimeoutMillis = 1000)
|
||||||
evaluateProcessResult(value)
|
evaluateProcessResult(value)
|
||||||
}
|
}
|
||||||
|
catch(e: kotlinx.coroutines.CancellationException) { throw e }
|
||||||
catch(e: Exception)
|
catch(e: Exception)
|
||||||
{
|
{
|
||||||
Log.w(TAG, "Error running process step", e)
|
waitingOnBackend = false
|
||||||
setTopLevelError("Error running process step: ${e.message}")
|
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<String, Any?>? = 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 ->
|
is QJobError ->
|
||||||
{
|
{
|
||||||
waitingOnBackend = false
|
waitingOnBackend = false
|
||||||
|
activeJobUUID = ""
|
||||||
setTopLevelError(value.userFacingError ?: value.error)
|
setTopLevelError(value.userFacingError ?: value.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +448,15 @@ open class ProcessViewModel : ViewModel()
|
|||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
private fun processJobCompleteResult(value: QJobComplete)
|
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)
|
updateActiveStepName(value.nextStep)
|
||||||
stepInstanceCounter++
|
stepInstanceCounter++
|
||||||
|
|
||||||
@@ -485,10 +524,11 @@ open class ProcessViewModel : ViewModel()
|
|||||||
val value = qqqRepository.processJobStatus(processName, localProcessUUID, localJobUUID)
|
val value = qqqRepository.processJobStatus(processName, localProcessUUID, localJobUUID)
|
||||||
evaluateProcessResult(value)
|
evaluateProcessResult(value)
|
||||||
}
|
}
|
||||||
|
catch(e: kotlinx.coroutines.CancellationException) { throw e }
|
||||||
catch(e: Exception)
|
catch(e: Exception)
|
||||||
{
|
{
|
||||||
Log.w(TAG, "Error checking running process step status", e)
|
waitingOnBackend = false
|
||||||
setTopLevelError("Error checking running process step status: ${e.message}")
|
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()
|
controlCallbacks[controlCallbackName]?.invoke()
|
||||||
}
|
}
|
||||||
|
catch(e: kotlinx.coroutines.CancellationException) { throw e }
|
||||||
catch(e: Exception)
|
catch(e: Exception)
|
||||||
{
|
{
|
||||||
Log.w(TAG, "Error calling control callback for [${controlCallbackName}]", e)
|
Log.w(TAG, "Error calling control callback for [${controlCallbackName}]", e)
|
||||||
|
|||||||
+58
-24
@@ -78,6 +78,12 @@ open class QViewModel(
|
|||||||
var isLogoutRequested: Boolean by mutableStateOf(false)
|
var isLogoutRequested: Boolean by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var logoutInProgress: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var logoutMessage: String by mutableStateOf("")
|
||||||
|
private set
|
||||||
|
|
||||||
var sessionUUID: String by mutableStateOf("")
|
var sessionUUID: String by mutableStateOf("")
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -183,6 +189,7 @@ open class QViewModel(
|
|||||||
manageSessionLoadState = LoadState.Loading(Unit)
|
manageSessionLoadState = LoadState.Loading(Unit)
|
||||||
qInstanceLoadState = LoadState.Loading(Unit)
|
qInstanceLoadState = LoadState.Loading(Unit)
|
||||||
topBarStatusText = ""
|
topBarStatusText = ""
|
||||||
|
activeProcessViewModel?.reset()
|
||||||
activeProcessViewModel = null
|
activeProcessViewModel = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,10 +222,8 @@ open class QViewModel(
|
|||||||
{
|
{
|
||||||
try
|
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)))
|
val manageSessionLoadState = LoadState.Success(qqqRepository.manageSession(ManageSessionRequest(accessToken = accessToken, uuid = null)))
|
||||||
sessionUUID = manageSessionLoadState.value.uuid
|
sessionUUID = requireNotNull(manageSessionLoadState.value.uuid)
|
||||||
qqqRepository.setSessionUUID(sessionUUID)
|
qqqRepository.setSessionUUID(sessionUUID)
|
||||||
loadQInstance()
|
loadQInstance()
|
||||||
logInSuccessful()
|
logInSuccessful()
|
||||||
@@ -333,14 +338,14 @@ open class QViewModel(
|
|||||||
else -> "Unknown"
|
else -> "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "qInstanceLoadState was success (${qInstanceLoadState}). marking login success!")
|
Log.d(TAG, "Stored session accepted")
|
||||||
logInSuccessful()
|
logInSuccessful()
|
||||||
}
|
}
|
||||||
|
|
||||||
else ->
|
else ->
|
||||||
{
|
{
|
||||||
qInstanceLoadState = LoadState.Loading(Unit)
|
qInstanceLoadState = LoadState.Loading(Unit)
|
||||||
Log.d(TAG, "qInstanceLoadState was not success (${qInstanceLoadState}). marking login failed.")
|
Log.d(TAG, "Stored session rejected")
|
||||||
logInFailed()
|
logInFailed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,7 +357,7 @@ open class QViewModel(
|
|||||||
///////////
|
///////////
|
||||||
// noop. //
|
// 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()
|
qNavigator.goHome()
|
||||||
viewModelScope.launch()
|
viewModelScope.launch()
|
||||||
{
|
{
|
||||||
activateEnvironment(environment).join()
|
|
||||||
logOut().join()
|
logOut().join()
|
||||||
|
activateEnvironment(environment).join()
|
||||||
loadAuthenticationMetaData().join()
|
loadAuthenticationMetaData().join()
|
||||||
postSwitchCallback.invoke()
|
postSwitchCallback.invoke()
|
||||||
}
|
}
|
||||||
@@ -465,7 +470,6 @@ open class QViewModel(
|
|||||||
{
|
{
|
||||||
viewModelScope.launch()
|
viewModelScope.launch()
|
||||||
{
|
{
|
||||||
Log.d(TAG, "Storing session UUID: [${value}]")
|
|
||||||
settingsRepository.setSessionUUID(value)
|
settingsRepository.setSessionUUID(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,29 +495,35 @@ open class QViewModel(
|
|||||||
{
|
{
|
||||||
val capturedIsLogoutRequested = isLogoutRequested
|
val capturedIsLogoutRequested = isLogoutRequested
|
||||||
val capturedAuthLoadState = authenticationMetaDataLoadState
|
val capturedAuthLoadState = authenticationMetaDataLoadState
|
||||||
|
activeProcessViewModel?.reset()
|
||||||
viewModelScope.launch()
|
logoutInProgress = true
|
||||||
|
logoutMessage = "Signing out…"
|
||||||
|
var serverConfirmed = false
|
||||||
|
try
|
||||||
{
|
{
|
||||||
isAuthenticated = false
|
qqqRepository.logout()
|
||||||
sessionUUID = ""
|
serverConfirmed = true
|
||||||
qqqRepository.setSessionUUID(sessionUUID)
|
}
|
||||||
settingsRepository.setSessionUUID(sessionUUID)
|
catch(exception: kotlinx.coroutines.CancellationException) { throw exception }
|
||||||
}.join()
|
catch(exception: Exception) { /* Local sign-out still completes when the network is unavailable. */ }
|
||||||
|
finally
|
||||||
clearAll()
|
{
|
||||||
|
clearAll()
|
||||||
////////////////////////////////////////
|
qqqRepository.setSessionUUID("")
|
||||||
// restore these 2 after the clear... //
|
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { settingsRepository.setSessionUUID("") }
|
||||||
////////////////////////////////////////
|
isLogoutRequested = capturedIsLogoutRequested
|
||||||
isLogoutRequested = capturedIsLogoutRequested
|
authenticationMetaDataLoadState = capturedAuthLoadState
|
||||||
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()
|
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/***************************************************************************
|
/***************************************************************************
|
||||||
**
|
**
|
||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
|
|||||||
+2
-2
@@ -186,7 +186,7 @@ class SessionTests : BaseTest()
|
|||||||
.performClick()
|
.performClick()
|
||||||
|
|
||||||
composeTestRule
|
composeTestRule
|
||||||
.onNodeWithText("Logout Successful.")
|
.onNodeWithText("Signed out.")
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
|
|
||||||
composeTestRule
|
composeTestRule
|
||||||
@@ -291,4 +291,4 @@ class SessionTests : BaseTest()
|
|||||||
TestUtils.localDebugSleep(composeTestRule, 0)
|
TestUtils.localDebugSleep(composeTestRule, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build/
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Local LAN demos only. Release builds reject cleartext. -->
|
||||||
|
<application android:usesCleartextTraffic="true" tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<application android:allowBackup="false" android:fullBackupContent="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules" android:usesCleartextTraffic="false"
|
||||||
|
android:icon="@drawable/ic_scanner"
|
||||||
|
android:label="QQQ Mobile" android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<activity android:name=".ScannerActivity" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> = 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Any> = mapOf(
|
||||||
|
"barcode" to barcode, "symbology" to symbology, "scanSource" to scanSource,
|
||||||
|
"eventId" to eventId, "capturedAt" to capturedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<String, DeviceCredentials>()
|
||||||
|
private val storesByServer = mutableMapOf<String, DataStore<Preferences>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <T : ViewModel> create(modelClass: Class<T>): 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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
|
||||||
|
<path android:fillColor="#174EA6" android:pathData="M0,0h48v48h-48z" />
|
||||||
|
<path android:fillColor="#FFFFFF" android:pathData="M8,8h10v3h-7v7h-3zM30,8h10v10h-3v-7h-7zM8,30h3v7h7v3h-10zM37,30h3v10h-10v-3h7zM15,16h3v16h-3zM21,16h2v16h-2zM26,16h4v16h-4zM33,16h1v16h-1z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -23,3 +23,4 @@ rootProject.name = "QQQ Android"
|
|||||||
include(":qqq:android:core")
|
include(":qqq:android:core")
|
||||||
include(":qqq:android:mobileapp")
|
include(":qqq:android:mobileapp")
|
||||||
include(":qqqsampleandroidmobileapp")
|
include(":qqqsampleandroidmobileapp")
|
||||||
|
include(":scanner-host")
|
||||||
Reference in new issue
Block a user