From b1912cf59c5f9854c812e1051b901fd2ba89f30e Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 9 Nov 2026 19:35:59 -0600 Subject: [PATCH] fix(network): raise the remaining short limits for slow links (1.0.5, versionCode 7) 1.0.4 gave the metadata load 60 seconds. Every other call still had a 5 second call limit or OkHttp's 10 second defaults, so a slow warehouse link could still fail start-up, enrollment or a process step. All limits now come from one set, NetworkTimeouts: - connect 10 s (unchanged value, now named) - small calls 30 s: version discovery, branding, logo, reachability and the session heartbeat (were a 5 s call limit); device enrollment and status, sign-in, table and process-status calls (were a 10 s read) - process init and step 60 s read and write, no call limit as before - metadata load 60 s (unchanged) - best-effort calls 5 s (unchanged): ending the device session and live screen frames, so sign-out, Change server and screen viewing never wait on a dead link Not changed: the 3 s bounds around sign-out and the 30 s photo transfer limit. Tests assert the configured limits of every client (NetworkTimeoutsTest, SlowLinkTimeoutsTest); the test that pinned the 10 s default now expects an ordinary call to survive a 12 s answer. Docs: CHANGELOG 1.0.5 candidate; RELEASING, INSTALLATION, SESSION-STATE and TODO record the published 1.0.4 and the 1.0.5 release steps. Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 13 ++++ docs/INSTALLATION.md | 27 +++---- docs/RELEASING.md | 26 ++++++- docs/SESSION-STATE.md | 6 ++ docs/TODO.md | 3 +- .../core/controllers/NetworkQQQRepository.kt | 27 +++++-- .../core/controllers/NetworkTimeouts.kt | 55 ++++++++++++++ .../core/controllers/UiApiCompatibility.kt | 5 +- .../controllers/NetworkQQQRepositoryTest.kt | 8 +- .../core/controllers/NetworkTimeoutsTest.kt | 76 +++++++++++++++++++ .../java/io/qrun/mobile/scanner/DeviceApi.kt | 10 ++- .../io/qrun/mobile/scanner/HostRuntime.kt | 6 +- .../qrun/mobile/scanner/MobileSessionApi.kt | 15 +++- .../mobile/scanner/SlowLinkTimeoutsTest.kt | 47 ++++++++++++ 14 files changed, 283 insertions(+), 41 deletions(-) create mode 100644 qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkTimeouts.kt create mode 100644 qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkTimeoutsTest.kt create mode 100644 scanner-host/src/test/java/io/qrun/mobile/scanner/SlowLinkTimeoutsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 75289d7..77780f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,20 @@ # Changelog +## 1.0.5 (versionCode 7), candidate + +Not signed, tagged or published (prepared 2026-11-09). Updates 1.0.4 in place with the same permanent signer. Build and release steps: [RELEASING](docs/RELEASING.md#next-candidate-105-versioncode-7). + +- Slow links: the short network limits that 1.0.4 left in place are raised, and all limits now come from one shared set (`NetworkTimeouts`). Nothing else changes. + - Version discovery (`/qqq/v1/version`), branding, logo, the reachability check and the session heartbeat allowed 5 seconds for the whole call and now allow 30. A version check that took longer than 5 seconds stopped start-up with "Unable to check QQQ UI API compatibility". + - Device enrollment and status, sign-in, table and process-status calls waited OkHttp's default 10 seconds for the server and now wait 30. + - Process init and step wait 60 seconds. A client that gives up after the server has run a step invites the operator to run it twice. + - Opening the connection stays at 10 seconds, so a server that cannot be reached is still reported quickly. + - Unchanged on purpose: ending the device session (sign-out, Change server) and live screen frames keep a 5 second call limit; the 3 second bounds around sign-out and the 30 second photo transfer limit are not touched. + ## 1.0.4 (versionCode 6) +Published 2026-11-09 as prerelease [scanner-v1.0.4](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.4). + - Loading the server's metadata no longer fails with "Error loading QInstance: timeout" on a handheld. The metadata response is about 1.7 MB and takes the server 4 to 8 seconds to build, and the default 10 second read timeout expired. That one call now allows 60 seconds; every other call keeps its short timeout. A timeout now reads "Timed out after 60.0s loading the server's metadata from /qqq/v1/metaData". - Sign-out now also ends the identity-provider session in the browser when the server supports it (the same flow as "Sign out of company", now behind the single **Sign out** action), and every browser sign-in sends `max_age=0` next to `prompt=login`. Before, ordinary Sign out cleared only the QQQ session, so the provider session left in the browser silently signed the same person straight back in on the next sign-in. Servers without provider logout still get local plus QQQ sign-out. Stored-session restore after sign-out was checked and is not a cause (test added). - Includes 1.0.3 (versionCode 5): string widget icons and summary-only help content from current QQQ core are accepted. (PR 24) diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index b9f7107..a673fc4 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -4,28 +4,22 @@ Use this for the **production-signed** APK on a small number of devices. Move to This guide is not the local demo. The demo uses the development-signed debug APK, plain HTTP and `adb reverse` port forwarding to a workstation server; see [README](../README.md#installing-on-a-device). The two builds have different signers and cannot update each other. -**Current release status (November 5, 2026):** the only published release is the signed preview 1.0.0 below. A signed **1.0.1 (versionCode 3) candidate** exists but is **not published and has no download link**; this guide does not point to one. Use the table below for what is actually hosted. It is superseded by the planned **1.0.2 (versionCode 4)** candidate (not yet built; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4)). When a newer version is published, use the file, checksum and link given by the release owner, and verify the same signer. - -| Candidate (not published) | Value | -| --- | --- | -| Version | 1.0.1, versionCode 3 | -| APK SHA-256 | `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F` (15015419 bytes) | -| Signing certificate SHA-256 | Same as below (`BDDB2D2F...5B17A9FF`) | +**Current release status (November 9, 2026):** the latest published build is **1.0.4 (versionCode 6)** below, a Gitea prerelease. It updates 1.0.3, 1.0.2, 1.0.1 and 1.0.0 in place. A **1.0.5 (versionCode 7)** candidate with longer network limits for slow links is prepared but not signed or published (see [RELEASING](RELEASING.md#next-candidate-105-versioncode-7)). When a newer version is published, use the file, checksum and link on its release page, and verify the same signer. Details and qualification status: [RELEASING](RELEASING.md). | Item | Value | | --- | --- | -| Source | [Signed preview release scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease; forge sign-in required) | -| File | `QQQ-Mobile-1.0.0.apk`, with `SHA256SUMS` on the same release page | +| Source | [Release scanner-v1.0.4](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.4) (prerelease; downloads without sign-in) | +| File | `QQQ-Mobile-1.0.4.apk` (15015419 bytes), with `SHA256SUMS`, `INSTALL.md` and `RELEASE-NOTES.md` on the same release page | | Application ID | `io.qrun.mobile.scanner` | -| Version | 1.0.0, versionCode 2 | -| APK SHA-256 | `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51` | +| Version | 1.0.4, versionCode 6 | +| APK SHA-256 | `F643872F4435B55F6A0B929D1762CAACB18010E5CC0B473C7B4BABBDC4299489` | | Signing certificate SHA-256 | `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF` | -The same APK is used for every customer; branding comes from the server at runtime. Download the APK and `SHA256SUMS` from the release page and compare the SHA-256 before installing, then verify the signing certificate matches the value above. The checksum shows the file is intact, not who signed it. This is a preview, not a stable release. Only the APK and `SHA256SUMS` are hosted; there is no hosted install kit. +The same APK is used for every customer; branding comes from the server at runtime. Download the APK and `SHA256SUMS` from the release page and compare the SHA-256 before installing, then verify the signing certificate matches the value above. The checksum shows the file is intact, not who signed it. This is a prerelease, not a stable release. Earlier releases: `scanner-v1.0.2` and the signed preview `scanner-v1.0.0` (`QQQ-Mobile-1.0.0.apk`, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`). -**Device evidence.** *Historical (November 4):* this APK was installed and launched once on a fresh Zebra MC9400 (Android 13) and showed the Connect screen; it was not yet enrolled. *Current (November 5):* with the user's explicit authorization, the MC9400 was migrated: 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready were observed, and the operator confirmed a physical Saved scan that the backend verified. These checks are done and need not be repeated for this device. The 1.0.1 candidate is still unpublished, native Authentik login/logout/account switching is not yet qualified with the production APK, and the TC22 keeps its original development-signed installation. +**Device evidence (1.0.0 and 1.0.1; 1.0.4 had not been installed on a device when it was published).** *Historical (November 4):* the 1.0.0 APK was installed and launched once on a fresh Zebra MC9400 (Android 13) and showed the Connect screen; it was not yet enrolled. *Current (November 5):* with the user's explicit authorization, the MC9400 was migrated: 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready were observed, and the operator confirmed a physical Saved scan that the backend verified. These checks are done and need not be repeated for this device. The 1.0.1 candidate is still unpublished, native Authentik login/logout/account switching is not yet qualified with the production APK, and the TC22 keeps its original development-signed installation. ## Before you start @@ -35,7 +29,7 @@ The same APK is used for every customer; branding comes from the server at runti ## Install: copy the file (recommended) -1. Copy the verified `QQQ-Mobile-1.0.0.apk` to the device's **Downloads** folder. +1. Copy the verified `QQQ-Mobile-1.0.4.apk` to the device's **Downloads** folder. 2. Open **Files**, go to Downloads, and tap the APK. 3. If Android asks, allow that Files app to install apps, then continue. You can turn the permission off again afterward. @@ -45,9 +39,12 @@ Requires USB debugging enabled on the device, a computer the device has been ask ```powershell adb devices -adb -s DEVICE_SERIAL install -r QQQ-Mobile-1.0.0.apk +adb -s DEVICE_SERIAL install -r QQQ-Mobile-1.0.4.apk +adb -s DEVICE_SERIAL shell dumpsys package io.qrun.mobile.scanner | findstr /i "versionName versionCode" ``` +The last command confirms the installed version (expect `versionCode=6` and `versionName=1.0.4`). + Replace `DEVICE_SERIAL` with the serial of the device you chose from `adb devices`. Always name the target with `-s`. Do not add `-d` (downgrade) or `-t`. ## First launch and enrollment diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9c64374..4f0b687 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,6 +1,28 @@ # Scanner APK release readiness -## Current status (November 5, 2026) +## Current status (November 9, 2026) + +- **Latest published:** [scanner-v1.0.4](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.4), QQQ Mobile **1.0.4 (versionCode 6)**, a Gitea prerelease. The tag is on `main` `9bae976138c4447f3fa451ee49b8585e80f01847` (1.0.3 source plus Android PRs 26 and 27; a lightweight tag created with the release, like `scanner-v1.0.2`). Assets: `QQQ-Mobile-1.0.4.apk` (15015419 bytes, SHA-256 `F643872F4435B55F6A0B929D1762CAACB18010E5CC0B473C7B4BABBDC4299489`), `SHA256SUMS`, `INSTALL.md`, `RELEASE-NOTES.md`. The APK was downloaded back without sign-in and matched; `aapt2` shows versionCode 6 and versionName 1.0.4; `apksigner` verifies v2 and v3 with one signer whose certificate SHA-256 is `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`. +- **Earlier releases:** `scanner-v1.0.2` (1.0.2, versionCode 4, signed candidate, prerelease) and `scanner-v1.0.0` (signed preview). 1.0.3 (versionCode 5) was signed and installed on the TC22 and MC9400 but never tagged or published; 1.0.4 includes it. +- **Qualification of 1.0.4:** hosted CI on the tagged commit passed source checks, JVM unit tests, debug lint and the debug build; the emulator tests did not run because the runner had no usable `/dev/kvm`. No `lintRelease` result was recorded for the 1.0.4 build, and 1.0.4 had not been installed on a device when it was published. +- **Next candidate:** **1.0.5 (versionCode 7)**, slow-link network limits; see [Next candidate](#next-candidate-105-versioncode-7). Not signed, tagged or published. +- **CI note:** the shared workflow's Gradle step currently runs only the first line of `tasks` (unit tests, debug lint, debug build); the QA assembly line does not run. Run the release-variant lint and build locally before signing. + +## Next candidate: 1.0.5 (versionCode 7) + +Prepared on 2026-11-09 on branch `fix/slow-link-timeouts`. **Nothing is signed, tagged, installed or published for 1.0.5.** It updates 1.0.4 in place with the same signer and a higher `versionCode`. + +Change since 1.0.4 (details in [CHANGELOG](../CHANGELOG.md#105-versioncode-7-candidate)): the remaining short network limits are raised for slow links, from one shared set (`NetworkTimeouts`). + +Build from the exact merged `main` commit, with no `SCANNER_*` signing variables set: + +``` +./gradlew --no-daemon --console=plain :scanner-host:lintRelease :scanner-host:assembleRelease -PscannerVersionName=1.0.5 -PscannerVersionCode=7 +``` + +Then sign `scanner-host/build/outputs/apk/release/scanner-host-release-unsigned.apk` in isolation, as for 1.0.4: `zipalign -P 16 -f 4`, then `apksigner sign` with the existing permanent key (certificate SHA-256 `BDDB2D2F...5B17A9FF`, recovered from the vault only for this step), then verify v2 and v3 with exactly one signer, `versionCode='7' versionName='1.0.5'`, and write `QQQ-Mobile-1.0.5.apk` and `SHA256SUMS`. Tag the built commit `scanner-v1.0.5`, create the Gitea release with the APK, `SHA256SUMS`, install notes and release notes, download the APK back and compare its SHA-256. Record the hashes here and in [INSTALLATION](INSTALLATION.md). + +## Status on November 5, 2026 (historical) - **Published:** the only published release is still [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), a **signed preview** (Gitea prerelease, not a stable release). It is unchanged. - **Candidate, not published:** QQQ Mobile **1.0.1 (versionCode 3)** is a signed candidate. Its release qualification is incomplete, and no tag or download link is available yet. @@ -13,6 +35,8 @@ ## Next candidate: 1.0.2 (versionCode 4) +*(Historical: 1.0.2 was built, signed and published as prerelease `scanner-v1.0.2` on 2026-11-06, and is superseded by 1.0.4.)* + Prepared on 2026-11-06 on branch `release/scanner-1.0.2` (Android PRs 17 and 18 merged locally plus these notes). **Nothing is built, signed, tagged, installed or published for 1.0.2.** The signed 1.0.1 candidate stays unpublished and is superseded; 1.0.2 updates it in place with the same signer and a higher `versionCode`. Changes since 1.0.1 (details in [CHANGELOG](../CHANGELOG.md#102-versioncode-4-candidate)): company sign-out now completes (the device-session end ran before the provider logout, so no `nativeLogoutData` came back and the browser never opened on the MC9400); the user name is shown after browser sign-in; app/OS version are reported on every heartbeat (effective with mobile-scanners server 1.6.0). diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index e8e7186..1f78596 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -2,6 +2,12 @@ As of **2026-11-06**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is not a final-release or full-qualification claim. See [Release facts](#release-facts-2026-11-05) below. +## Release facts (2026-11-09) + +- **Published:** `scanner-v1.0.4` (1.0.4, versionCode 6, prerelease) on `main` `9bae976`: the 60 second metadata load limit (PR 26), sign-out that ends the identity-provider session (PR 27) and 1.0.3's widget icon fix (PR 24). APK SHA-256 `F643872F4435B55F6A0B929D1762CAACB18010E5CC0B473C7B4BABBDC4299489`. Not yet installed on a device when published. Details: [RELEASING](RELEASING.md). +- **Candidate:** 1.0.5 (versionCode 7) raises the remaining short network limits for slow links from one shared set (`NetworkTimeouts`); branch `fix/slow-link-timeouts`. Not signed, tagged or published. +- **CI:** emulator tests do not run on the hosted runners (no usable `/dev/kvm`), and the shared workflow runs only the first Gradle task line. + ## Release candidate 1.0.2 (versionCode 4), 2026-11-06 - Branch `release/scanner-1.0.2` carries Android PR 17 (company sign-out ordering, OAuth user name) and PR 18 (heartbeat app/OS version) plus the [1.0.2 changelog](../CHANGELOG.md#102-versioncode-4-candidate) and build instructions in [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). It depends on both PRs merging first and must be rebased onto the resulting `main`. **Nothing is built, signed, tagged, installed or published for 1.0.2**; the signed 1.0.1 candidate stays unpublished. diff --git a/docs/TODO.md b/docs/TODO.md index 4ded376..a66aede 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,7 +6,8 @@ Open work only, as of **2026-11-05**. Current evidence: [SESSION-STATE](SESSION- - [ ] Qualify actual GS1 label media and physical printed-label rescan. Synthetic capture/render evidence does not close this check. - [ ] Verify actual production-APK native Authentik login, logout and account switching; awaiting the MC wireless-debug endpoint. Real-server discovery (UI API 1.10, strict nonce, scanner and printing channels) is already verified; no native-Authentik PASS is claimed. -- [ ] After Android PRs 17 and 18 merge, rebase `release/scanner-1.0.2`, then build, sign (same permanent signer) and publish 1.0.2 (versionCode 4) with checksum and installation instructions after remaining qualification; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). The signed 1.0.1 (versionCode 3) candidate is superseded and stays unpublished. The published release is still the 1.0.0 signed preview. +- [ ] Install published 1.0.4 (`scanner-v1.0.4`) on the handhelds and confirm the in-place update. Then merge the 1.0.5 slow-link change, build, sign (same permanent signer), tag `scanner-v1.0.5` and publish; see [RELEASING](RELEASING.md#next-candidate-105-versioncode-7). +- [ ] (Superseded by 1.0.4; kept for its notes.) After Android PRs 17 and 18 merge, rebase `release/scanner-1.0.2`, then build, sign (same permanent signer) and publish 1.0.2 (versionCode 4) with checksum and installation instructions after remaining qualification; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). The signed 1.0.1 (versionCode 3) candidate is superseded and stays unpublished. The published release is still the 1.0.0 signed preview. - [ ] Publish and distribute full release assets to remaining devices; validate MDM/company profile and wallpaper separately. A customer deployment needs a customer HTTPS URL with a trusted certificate. - [ ] Printing: the next installer remains with the active printing owner (print nonce fix PR 11 postdates published print 1.1.1). - [ ] Optional, non-blocking: automatic APK signing CI. Direct APK release does not depend on it. diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt index 8420f49..adc080b 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt @@ -51,12 +51,16 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /******************************************************************************* - ** Ceiling for loading the server's metadata (GET /qqq/v1/metaData). That response is large (about 1.7 MB for a - ** platform administrator on the staging server) and the server needs 4 to 8 seconds to build it, so the default - ** 10 second OkHttp read timeout expired on handhelds. Only this one call gets the long limit; every other call - ** keeps the short defaults. + ** Ceiling for loading the server's metadata (GET /qqq/v1/metaData). That response is large (about 1.7 MB before + ** compression for a platform administrator) and the server needs seconds to build it, so the default 10 second + ** OkHttp read timeout expired on handhelds. The value lives with the other limits in NetworkTimeouts. *******************************************************************************/ -const val METADATA_LOAD_TIMEOUT_SECONDS = 60L +const val METADATA_LOAD_TIMEOUT_SECONDS = NetworkTimeouts.METADATA_LOAD_SECONDS + +/******************************************************************************* + ** The HTTP clients of one server: ordinary calls, the metadata load, and process init and step. + *******************************************************************************/ +internal class RepositoryClients(val standard: OkHttpClient, val metaData: OkHttpClient, val process: OkHttpClient) /******************************************************************************* @@ -68,6 +72,8 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs private val qqqApiServiceMap: MutableMap = mutableMapOf() private val metaDataApiServiceMap: MutableMap = mutableMapOf() + private val processApiServiceMap: MutableMap = mutableMapOf() + internal val clientsByServer: MutableMap = mutableMapOf() /******************************************************************************* *******************************************************************************/ @@ -82,6 +88,10 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs .readTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS) .build() metaDataApiServiceMap[baseUrl] = buildRetrofit(baseUrl, metaDataClient).create(QQQApiService::class.java) + // No call timeout, as before: only the wait for the server's bytes is longer. + val processClient = client.newBuilder().slowLinkTimeouts(NetworkTimeouts.PROCESS_STEP_SECONDS).build() + processApiServiceMap[baseUrl] = buildRetrofit(baseUrl, processClient).create(QQQApiService::class.java) + clientsByServer[baseUrl] = RepositoryClients(client, metaDataClient, processClient) } // retrofit = buildRetrofit() @@ -99,11 +109,12 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs } /******************************************************************************* - ** Build the shared client (same-origin, session, compatibility and error interceptors) with default timeouts. + ** Build the shared client (same-origin, session, compatibility and error interceptors) with the small-call limits. *******************************************************************************/ private fun buildClient(baseUrl: String): OkHttpClient { return ( OkHttpClient.Builder() + .slowLinkTimeouts() .followRedirects(false) .followSslRedirects(false) .addInterceptor { chain -> @@ -350,13 +361,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs /******************************************************************************* *******************************************************************************/ override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult { - return qqqApiServiceMap[baseUrl]!!.processInit(processName, buildProcessInitOrStepRequestBodyV1(values, recordsParam, recordIds, filterJSON, stepTimeoutMillis)) + return processApiServiceMap[baseUrl]!!.processInit(processName, buildProcessInitOrStepRequestBodyV1(values, recordsParam, recordIds, filterJSON, stepTimeoutMillis)) } /******************************************************************************* *******************************************************************************/ override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult { - return qqqApiServiceMap[baseUrl]!!.processStep(processName, processUUID, stepName, buildProcessInitOrStepRequestBodyV1(values, stepTimeoutMillis = stepTimeoutMillis)) + return processApiServiceMap[baseUrl]!!.processStep(processName, processUUID, stepName, buildProcessInitOrStepRequestBodyV1(values, stepTimeoutMillis = stepTimeoutMillis)) } /******************************************************************************* diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkTimeouts.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkTimeouts.kt new file mode 100644 index 0000000..114ffcf --- /dev/null +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkTimeouts.kt @@ -0,0 +1,55 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.core.controllers + +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient + +/******************************************************************************* + ** The one set of network limits for every HTTP client in the app, sized for a slow warehouse link. + ** A limit that is too short turns a slow answer into a failure; a call that the client abandons also + ** closes its connection, so the retry pays for a new TLS handshake on the same slow link. + *******************************************************************************/ +object NetworkTimeouts { + /******************************************************************************* + ** Opening the TCP connection. Kept at OkHttp's default: one round trip, so a server that cannot be + ** reached at all is still reported quickly. + *******************************************************************************/ + const val CONNECT_SECONDS = 10L + + /******************************************************************************* + ** Small request and response calls: version discovery, branding, logo, reachability, device + ** enrollment and status, session heartbeat, sign-in, table and process-status calls. + *******************************************************************************/ + const val SMALL_CALL_SECONDS = 30L + + /******************************************************************************* + ** Process init and step. These carry the operator's work: if the client gives up after the server + ** has run the step, the operator retries and the work is done twice. + *******************************************************************************/ + const val PROCESS_STEP_SECONDS = 60L + + /******************************************************************************* + ** Loading the server's metadata (GET /qqq/v1/metaData): the largest response, and the server takes + ** seconds to build it. + *******************************************************************************/ + const val METADATA_LOAD_SECONDS = 60L + + /******************************************************************************* + ** Calls whose failure is tolerated and that must never hold the operator: ending the device session + ** before sign-out or a server change (the usual reason to change server is that it cannot be reached) + ** and live screen frames, which are stale after a few seconds. + *******************************************************************************/ + const val BEST_EFFORT_CALL_SECONDS = 5L +} + +/******************************************************************************* + ** Apply the shared connect limit and one read and write limit to a client. + *******************************************************************************/ +fun OkHttpClient.Builder.slowLinkTimeouts(seconds: Long = NetworkTimeouts.SMALL_CALL_SECONDS): OkHttpClient.Builder = + connectTimeout(NetworkTimeouts.CONNECT_SECONDS, TimeUnit.SECONDS).readTimeout(seconds, TimeUnit.SECONDS).writeTimeout(seconds, TimeUnit.SECONDS) + +/******************************************************************************* + ** The same, with the whole call (connect, request, response) bounded by the same limit. + *******************************************************************************/ +fun OkHttpClient.Builder.slowLinkCallTimeouts(seconds: Long = NetworkTimeouts.SMALL_CALL_SECONDS): OkHttpClient.Builder = + slowLinkTimeouts(seconds).callTimeout(seconds, TimeUnit.SECONDS) diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt index a40dddb..1a4e989 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/UiApiCompatibility.kt @@ -2,7 +2,6 @@ package com.kingsrook.qqq.frontend.android.core.controllers import java.io.IOException -import java.util.concurrent.TimeUnit import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.OkHttpClient @@ -38,8 +37,8 @@ class UiApiCompatibility( val server: String, private val requireMobileScanners: Boolean = false, private val observer: (RequestOutcome) -> Unit = {} ) { - private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) - .callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(observer)).build() + internal val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) + .slowLinkCallTimeouts().addInterceptor(RequestOutcomeInterceptor(observer)).build() @Volatile private var verified: UiApiVersion? = null diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt index b9a4a85..aa557a0 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt @@ -345,18 +345,18 @@ class NetworkQQQRepositoryTest { } /******************************************************************************* - ** Only metadata gets the long limit: other calls keep the short default and still fail fast. + ** Since 1.0.5 other calls also survive a server that answers after the old 10 second read timeout + ** (NetworkTimeoutsTest asserts the configured limits). *******************************************************************************/ @Test - fun otherCallsStillUseTheShortDefaultTimeout() = runBlocking() { + fun otherCallsSurviveAServerThatAnswersAfterTheOldDefaultTimeout() = runBlocking() { MockWebServer().use { server -> server.enqueue(MockResponse().setBody(VERSION_FIXTURE)) server.enqueue(MockResponse().setHeadersDelay(12, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"records":[]}""")) val origin = server.url("/").toString() val repository = NetworkQQQRepository(origin) repository.setEnvironment(Environment("test", origin)) - val failure = runCatching { repository.queryRecords("items", "id", 0, 10) }.exceptionOrNull() - assertTrue(failure is java.io.InterruptedIOException) + assertTrue(repository.queryRecords("items", "id", 0, 10).isEmpty()) } } } diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkTimeoutsTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkTimeoutsTest.kt new file mode 100644 index 0000000..2048602 --- /dev/null +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkTimeoutsTest.kt @@ -0,0 +1,76 @@ +/* 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 okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Test + +/******************************************************************************* + ** The limits a slow warehouse link depends on. A change to any of them must be deliberate. + *******************************************************************************/ +class NetworkTimeoutsTest { + /******************************************************************************* + ** Connect, read, write and call limits of a client, in milliseconds (0 means no limit). + *******************************************************************************/ + private fun limits(client: OkHttpClient) = listOf(client.connectTimeoutMillis, client.readTimeoutMillis, client.writeTimeoutMillis, client.callTimeoutMillis) + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun theSharedLimitsAreTheDocumentedValues() { + assertEquals(10L, NetworkTimeouts.CONNECT_SECONDS) + assertEquals(30L, NetworkTimeouts.SMALL_CALL_SECONDS) + assertEquals(60L, NetworkTimeouts.PROCESS_STEP_SECONDS) + assertEquals(60L, NetworkTimeouts.METADATA_LOAD_SECONDS) + assertEquals(5L, NetworkTimeouts.BEST_EFFORT_CALL_SECONDS) + assertEquals(NetworkTimeouts.METADATA_LOAD_SECONDS, METADATA_LOAD_TIMEOUT_SECONDS) + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + fun theHelpersSetConnectReadWriteAndOptionallyTheWholeCall() { + assertEquals(listOf(10_000, 30_000, 30_000, 0), limits(OkHttpClient.Builder().slowLinkTimeouts().build())) + assertEquals(listOf(10_000, 60_000, 60_000, 0), limits(OkHttpClient.Builder().slowLinkTimeouts(NetworkTimeouts.PROCESS_STEP_SECONDS).build())) + assertEquals(listOf(10_000, 30_000, 30_000, 30_000), limits(OkHttpClient.Builder().slowLinkCallTimeouts().build())) + } + + /******************************************************************************* + ** Version discovery runs before every first request; 5 seconds for the whole call failed start-up on a slow link. + *******************************************************************************/ + @Test + fun versionDiscoveryAllowsTheSmallCallLimit() { + assertEquals(listOf(10_000, 30_000, 30_000, 30_000), limits(UiApiCompatibility("https://server.example/").client)) + } + + /******************************************************************************* + ** Ordinary calls wait 30 seconds for the server, process init and step 60, the metadata load 60 for the whole call. + *******************************************************************************/ + @Test + fun repositoryClientsUseTheSharedLimits() { + val origin = "https://server.example/" + val repository = NetworkQQQRepository(origin) + repository.setEnvironment(Environment("test", origin)) + val clients = repository.clientsByServer.getValue(origin) + assertEquals(listOf(10_000, 30_000, 30_000, 0), limits(clients.standard)) + assertEquals(listOf(10_000, 60_000, 60_000, 0), limits(clients.process)) + assertEquals(listOf(10_000, 60_000, 30_000, 60_000), limits(clients.metaData)) + } + + /******************************************************************************* + ** The metadata limit stays a constructor parameter; the other clients do not follow it. + *******************************************************************************/ + @Test + fun aCustomMetadataLimitChangesOnlyTheMetadataClient() { + val origin = "https://server.example/" + val repository = NetworkQQQRepository(origin, metaDataTimeoutMillis = 500) + repository.setEnvironment(Environment("test", origin)) + val clients = repository.clientsByServer.getValue(origin) + assertEquals(listOf(10_000, 500, 30_000, 500), limits(clients.metaData)) + assertEquals(listOf(10_000, 30_000, 30_000, 0), limits(clients.standard)) + assertEquals(listOf(10_000, 60_000, 60_000, 0), limits(clients.process)) + } +} diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt index 6a2fc69..abd7472 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/DeviceApi.kt @@ -9,6 +9,7 @@ import android.os.Build import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor +import com.kingsrook.qqq.frontend.android.core.controllers.slowLinkTimeouts import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -22,6 +23,12 @@ import org.json.JSONObject *******************************************************************************/ class EnrollmentConflict : IllegalStateException("Registration already exists but its credentials were not received. Start a new registration and ask the administrator to approve it.") +/******************************************************************************* + ** Device enrollment and status client: small calls with the shared slow-link limits and, as before, no call timeout. + *******************************************************************************/ +internal fun deviceApiClient(onRequestOutcome: (RequestOutcome) -> Unit): OkHttpClient = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) + .slowLinkTimeouts().addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).addInterceptor(SameOriginInterceptor()).build() + /******************************************************************************* ** The native QQQ session also authenticates device management. *******************************************************************************/ @@ -33,8 +40,7 @@ class DeviceApi( private set var displayName: String = "" private set - private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) - .addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).addInterceptor(SameOriginInterceptor()).build() + private val client = deviceApiClient(onRequestOutcome) /******************************************************************************* ** Check enrollment on demand; approval remains a server-side decision. diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt index 197d3ad..4044b02 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/HostRuntime.kt @@ -4,10 +4,10 @@ package io.qrun.mobile.scanner import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility import com.kingsrook.qqq.frontend.android.core.controllers.MOBILE_SCANNERS_PATH import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT +import com.kingsrook.qqq.frontend.android.core.controllers.slowLinkCallTimeouts import java.net.URI import java.time.Instant import java.util.ArrayDeque -import java.util.concurrent.TimeUnit import kotlin.math.pow import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -137,8 +137,8 @@ internal class HostRuntime(private val server: String, val diagnostics: Diagnost *******************************************************************************/ suspend fun verifyCompatibility() = withContext(Dispatchers.IO) { compatibility.verify() } - private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) - .callTimeout(5, TimeUnit.SECONDS).build() + internal val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) + .slowLinkCallTimeouts().build() /******************************************************************************* ** Bound presentation responses and record safe outcomes while preserving coroutine cancellation. diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt index d36e1cc..92467fa 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/MobileSessionApi.kt @@ -3,11 +3,13 @@ package io.qrun.mobile.scanner import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT import com.kingsrook.qqq.frontend.android.core.controllers.MOBILE_SCANNERS_PATH +import com.kingsrook.qqq.frontend.android.core.controllers.NetworkTimeouts import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException +import com.kingsrook.qqq.frontend.android.core.controllers.slowLinkCallTimeouts import android.os.Build import java.io.IOException import java.util.concurrent.TimeUnit @@ -62,8 +64,13 @@ internal class MobileSessionApi( fun installedVersions(): Map = mapOf("osVersion" to Build.VERSION.RELEASE, "appVersion" to BuildConfig.VERSION_NAME) } - private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) - .callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build() + // Heartbeats report the device as online; a 5 second limit abandoned any heartbeat a slow link needed longer to deliver. + internal val heartbeatClient = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) + .slowLinkCallTimeouts().addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build() + + // Ending the session and live screen frames stay short (see NetworkTimeouts.BEST_EFFORT_CALL_SECONDS). + internal val bestEffortClient = heartbeatClient.newBuilder() + .callTimeout(NetworkTimeouts.BEST_EFFORT_CALL_SECONDS, TimeUnit.SECONDS).build() /******************************************************************************* ** Send the current eligible session metadata and accept only the server's bounded lease policy. @@ -87,7 +94,7 @@ internal class MobileSessionApi( "stepName" to state.stepName, "screenLabel" to state.label, "captureBlockedReason" to state.blockedReason ) .forEach { (name, value) -> json.put(name, value ?: JSONObject.NULL) } - return SessionHeartbeat.parse(post(session, "/sessions/heartbeat", json, 200)) + return SessionHeartbeat.parse(post(session, "/sessions/heartbeat", json, 200, heartbeatClient)) } /******************************************************************************* @@ -112,7 +119,7 @@ internal class MobileSessionApi( /******************************************************************************* ** Verify compatibility and close bounded responses while connecting coroutine cancellation to the HTTP call. *******************************************************************************/ - private suspend fun post(session: String, path: String, body: JSONObject, expected: Int): String = withContext(Dispatchers.IO) + private suspend fun post(session: String, path: String, body: JSONObject, expected: Int, client: OkHttpClient = bestEffortClient): String = withContext(Dispatchers.IO) { require(session.isNotBlank()) require(compatibility.server == server) diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/SlowLinkTimeoutsTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/SlowLinkTimeoutsTest.kt new file mode 100644 index 0000000..6b85132 --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/SlowLinkTimeoutsTest.kt @@ -0,0 +1,47 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package io.qrun.mobile.scanner + +import com.kingsrook.qqq.frontend.android.core.controllers.NetworkTimeouts +import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Test + +/******************************************************************************* + ** The scanner host's own HTTP clients use the shared slow-link limits instead of 5 second call limits. + *******************************************************************************/ +class SlowLinkTimeoutsTest { + private val server = "https://server.example/" + + /******************************************************************************* + ** Connect, read, write and call limits of a client, in milliseconds (0 means no limit). + *******************************************************************************/ + private fun limits(client: OkHttpClient) = listOf(client.connectTimeoutMillis, client.readTimeoutMillis, client.writeTimeoutMillis, client.callTimeoutMillis) + + /******************************************************************************* + ** Branding, logo and the reachability check. + *******************************************************************************/ + @Test + fun brandingLogoAndReachabilityAllowTheSmallCallLimit() { + assertEquals(listOf(10_000, 30_000, 30_000, 30_000), limits(HostRuntime(server).client)) + } + + /******************************************************************************* + ** Device enrollment and status had OkHttp's 10 second defaults and no call limit. + *******************************************************************************/ + @Test + fun deviceEnrollmentAndStatusAllowTheSmallCallLimitWithoutACallLimit() { + assertEquals(listOf(10_000, 30_000, 30_000, 0), limits(deviceApiClient {})) + } + + /******************************************************************************* + ** Only the heartbeat gets the longer limit; ending the session and screen frames must not hold the operator. + *******************************************************************************/ + @Test + fun heartbeatAllowsTheSmallCallLimitWhileSessionEndAndFramesStayShort() { + val api = MobileSessionApi(server, { emptyMap() }, UiApiCompatibility(server, true), deviceInfo = emptyMap()) + assertEquals(listOf(10_000, 30_000, 30_000, 30_000), limits(api.heartbeatClient)) + assertEquals(listOf(10_000, 30_000, 30_000, 5_000), limits(api.bestEffortClient)) + assertEquals(5L, NetworkTimeouts.BEST_EFFORT_CALL_SECONDS) + } +} -- 2.54.0