fix(network): raise the remaining short limits for slow links (1.0.5, versionCode 7)
android / android (push) Failing after 4m1s
build / android (push) Failing after 4m1s

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 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.2 committed 2026-11-09 19:35:59 -06:00
1 parent 9bae976138
commit b1912cf59c
14 files changed
+283 -41

No files matched your search

+13
View File
@@ -1,7 +1,20 @@
# Changelog # 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) ## 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 <server>/qqq/v1/metaData". - Loading the server's metadata no longer fails with "Error loading QInstance: timeout" on a handheld. The metadata response is about 1.7 MB and takes the server 4 to 8 seconds to build, and the default 10 second read timeout expired. That one call now allows 60 seconds; every other call keeps its short timeout. A timeout now reads "Timed out after 60.0s loading the server's metadata from <server>/qqq/v1/metaData".
- Sign-out now also ends the identity-provider session in the browser when the server supports it (the same flow as "Sign out of company", now behind the single **Sign out** action), and every browser sign-in sends `max_age=0` next to `prompt=login`. Before, ordinary Sign out cleared only the QQQ session, so the provider session left in the browser silently signed the same person straight back in on the next sign-in. Servers without provider logout still get local plus QQQ sign-out. Stored-session restore after sign-out was checked and is not a cause (test added). - 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) - Includes 1.0.3 (versionCode 5): string widget icons and summary-only help content from current QQQ core are accepted. (PR 24)
+12 -15
View File
@@ -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. 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. **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.
| 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`) |
Details and qualification status: [RELEASING](RELEASING.md). Details and qualification status: [RELEASING](RELEASING.md).
| Item | Value | | 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) | | 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.0.apk`, with `SHA256SUMS` on the same release page | | 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` | | Application ID | `io.qrun.mobile.scanner` |
| Version | 1.0.0, versionCode 2 | | Version | 1.0.4, versionCode 6 |
| APK SHA-256 | `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51` | | APK SHA-256 | `F643872F4435B55F6A0B929D1762CAACB18010E5CC0B473C7B4BABBDC4299489` |
| Signing certificate SHA-256 | `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF` | | 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 ## 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) ## 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. 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. 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 ```powershell
adb devices 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`. 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 ## First launch and enrollment
+25 -1
View File
@@ -1,6 +1,28 @@
# Scanner APK release readiness # 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. - **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. - **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) ## 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`. 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). 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).
+6
View File
@@ -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. 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 ## 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. - 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.
+2 -1
View File
@@ -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. - [ ] 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. - [ ] 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. - [ ] 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). - [ ] 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. - [ ] Optional, non-blocking: automatic APK signing CI. Direct APK release does not depend on it.
@@ -51,12 +51,16 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit 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 ** Ceiling for loading the server's metadata (GET /qqq/v1/metaData). That response is large (about 1.7 MB before
** platform administrator on the staging server) and the server needs 4 to 8 seconds to build it, so the default ** compression for a platform administrator) and the server needs seconds to build it, so the default 10 second
** 10 second OkHttp read timeout expired on handhelds. Only this one call gets the long limit; every other call ** OkHttp read timeout expired on handhelds. The value lives with the other limits in NetworkTimeouts.
** keeps the short defaults.
*******************************************************************************/ *******************************************************************************/
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<String, QQQApiService> = mutableMapOf() private val qqqApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
private val metaDataApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf() private val metaDataApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
private val processApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
internal val clientsByServer: MutableMap<String, RepositoryClients> = mutableMapOf()
/******************************************************************************* /*******************************************************************************
*******************************************************************************/ *******************************************************************************/
@@ -82,6 +88,10 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
.readTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS) .readTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
.build() .build()
metaDataApiServiceMap[baseUrl] = buildRetrofit(baseUrl, metaDataClient).create(QQQApiService::class.java) 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() // 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 { private fun buildClient(baseUrl: String): OkHttpClient {
return ( return (
OkHttpClient.Builder() OkHttpClient.Builder()
.slowLinkTimeouts()
.followRedirects(false) .followRedirects(false)
.followSslRedirects(false) .followSslRedirects(false)
.addInterceptor { chain -> .addInterceptor { chain ->
@@ -350,13 +361,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
/******************************************************************************* /*******************************************************************************
*******************************************************************************/ *******************************************************************************/
override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult { override suspend fun processInit(processName: String, values: Map<String, Any?>, 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<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult { override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, 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))
} }
/******************************************************************************* /*******************************************************************************
@@ -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)
@@ -2,7 +2,6 @@
package com.kingsrook.qqq.frontend.android.core.controllers package com.kingsrook.qqq.frontend.android.core.controllers
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -38,8 +37,8 @@ class UiApiCompatibility(
val server: String, private val requireMobileScanners: Boolean = false, val server: String, private val requireMobileScanners: Boolean = false,
private val observer: (RequestOutcome) -> Unit = {} private val observer: (RequestOutcome) -> Unit = {}
) { ) {
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) internal val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(observer)).build() .slowLinkCallTimeouts().addInterceptor(RequestOutcomeInterceptor(observer)).build()
@Volatile @Volatile
private var verified: UiApiVersion? = null private var verified: UiApiVersion? = null
@@ -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 @Test
fun otherCallsStillUseTheShortDefaultTimeout() = runBlocking() { fun otherCallsSurviveAServerThatAnswersAfterTheOldDefaultTimeout() = runBlocking() {
MockWebServer().use { server -> MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE)) server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setHeadersDelay(12, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"records":[]}""")) server.enqueue(MockResponse().setHeadersDelay(12, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"records":[]}"""))
val origin = server.url("/").toString() val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin) val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin)) repository.setEnvironment(Environment("test", origin))
val failure = runCatching { repository.queryRecords("items", "id", 0, 10) }.exceptionOrNull() assertTrue(repository.queryRecords("items", "id", 0, 10).isEmpty())
assertTrue(failure is java.io.InterruptedIOException)
} }
} }
} }
@@ -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))
}
}
@@ -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.RequestOutcome
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor 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.SameOriginInterceptor
import com.kingsrook.qqq.frontend.android.core.controllers.slowLinkTimeouts
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType 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.") 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. ** The native QQQ session also authenticates device management.
*******************************************************************************/ *******************************************************************************/
@@ -33,8 +40,7 @@ class DeviceApi(
private set private set
var displayName: String = "" var displayName: String = ""
private set private set
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) private val client = deviceApiClient(onRequestOutcome)
.addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).addInterceptor(SameOriginInterceptor()).build()
/******************************************************************************* /*******************************************************************************
** Check enrollment on demand; approval remains a server-side decision. ** Check enrollment on demand; approval remains a server-side decision.
@@ -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.UiApiCompatibility
import com.kingsrook.qqq.frontend.android.core.controllers.MOBILE_SCANNERS_PATH 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.ANDROID_CLIENT
import com.kingsrook.qqq.frontend.android.core.controllers.slowLinkCallTimeouts
import java.net.URI import java.net.URI
import java.time.Instant import java.time.Instant
import java.util.ArrayDeque import java.util.ArrayDeque
import java.util.concurrent.TimeUnit
import kotlin.math.pow import kotlin.math.pow
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers 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() } suspend fun verifyCompatibility() = withContext(Dispatchers.IO) { compatibility.verify() }
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) internal val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.callTimeout(5, TimeUnit.SECONDS).build() .slowLinkCallTimeouts().build()
/******************************************************************************* /*******************************************************************************
** Bound presentation responses and record safe outcomes while preserving coroutine cancellation. ** Bound presentation responses and record safe outcomes while preserving coroutine cancellation.
@@ -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.ANDROID_CLIENT
import com.kingsrook.qqq.frontend.android.core.controllers.MOBILE_SCANNERS_PATH 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.RequestOutcome
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor 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.SameOriginInterceptor
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility 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.UiApiException
import com.kingsrook.qqq.frontend.android.core.controllers.slowLinkCallTimeouts
import android.os.Build import android.os.Build
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -62,8 +64,13 @@ internal class MobileSessionApi(
fun installedVersions(): Map<String, String?> = mapOf("osVersion" to Build.VERSION.RELEASE, "appVersion" to BuildConfig.VERSION_NAME) fun installedVersions(): Map<String, String?> = mapOf("osVersion" to Build.VERSION.RELEASE, "appVersion" to BuildConfig.VERSION_NAME)
} }
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) // Heartbeats report the device as online; a 5 second limit abandoned any heartbeat a slow link needed longer to deliver.
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build() 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. ** 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 "stepName" to state.stepName, "screenLabel" to state.label, "captureBlockedReason" to state.blockedReason
) )
.forEach { (name, value) -> json.put(name, value ?: JSONObject.NULL) } .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. ** 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(session.isNotBlank())
require(compatibility.server == server) require(compatibility.server == server)
@@ -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)
}
}