Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.2 d3258bf52a feat(auth): automatic logoff after user inactivity (UI API 1.12.0)
android / android (push) Successful in 7m26s
build / android (push) Successful in 7m26s
Every request of a signed-in session states the age of the operator's last
touch, key or scan in X-QQQ-User-Activity-Age, so the session heartbeat alone
no longer keeps a session alive on a server with a user inactivity timeout.
When the server publishes userInactivityTimeoutSeconds the scanner warns a
minute before and signs out when it runs out; the server ends the session at
the next request regardless. Servers without the value are unaffected.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 12:38:26 -06:00
20 changed files with 270 additions and 288 deletions

No files matched your search

-13
View File
@@ -1,20 +1,7 @@
# 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 <server>/qqq/v1/metaData".
- Sign-out now also ends the identity-provider session in the browser when the server supports it (the same flow as "Sign out of company", now behind the single **Sign out** action), and every browser sign-in sends `max_age=0` next to `prompt=login`. Before, ordinary Sign out cleared only the QQQ session, so the provider session left in the browser silently signed the same person straight back in on the next sign-in. Servers without provider logout still get local plus QQQ sign-out. Stored-session restore after sign-out was checked and is not a cause (test added).
- Includes 1.0.3 (versionCode 5): string widget icons and summary-only help content from current QQQ core are accepted. (PR 24)
+15 -12
View File
@@ -4,22 +4,28 @@ 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 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.
**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`) |
Details and qualification status: [RELEASING](RELEASING.md).
| Item | Value |
| --- | --- |
| 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 |
| 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 |
| Application ID | `io.qrun.mobile.scanner` |
| Version | 1.0.4, versionCode 6 |
| APK SHA-256 | `F643872F4435B55F6A0B929D1762CAACB18010E5CC0B473C7B4BABBDC4299489` |
| Version | 1.0.0, versionCode 2 |
| APK SHA-256 | `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51` |
| 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 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`).
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.
**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.
**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.
## Before you start
@@ -29,7 +35,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.4.apk` to the device's **Downloads** folder.
1. Copy the verified `QQQ-Mobile-1.0.0.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.
@@ -39,12 +45,9 @@ 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.4.apk
adb -s DEVICE_SERIAL shell dumpsys package io.qrun.mobile.scanner | findstr /i "versionName versionCode"
adb -s DEVICE_SERIAL install -r QQQ-Mobile-1.0.0.apk
```
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
+1 -25
View File
@@ -1,28 +1,6 @@
# Scanner APK release readiness
## 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)
## Current status (November 5, 2026)
- **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.
@@ -35,8 +13,6 @@ Then sign `scanner-host/build/outputs/apk/release/scanner-host-release-unsigned.
## 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).
-6
View File
@@ -2,12 +2,6 @@
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.
+1 -2
View File
@@ -6,8 +6,7 @@ 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.
- [ ] 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.
- [ ] 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.
@@ -51,16 +51,12 @@ 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 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.
** Ceiling for loading the server's metadata (GET /qqq/v1/metaData). That response is large (about 1.7 MB for a
** platform administrator on the staging server) and the server needs 4 to 8 seconds to build it, so the default
** 10 second OkHttp read timeout expired on handhelds. Only this one call gets the long limit; every other call
** keeps the short defaults.
*******************************************************************************/
const val METADATA_LOAD_TIMEOUT_SECONDS = 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)
const val METADATA_LOAD_TIMEOUT_SECONDS = 60L
/*******************************************************************************
@@ -72,8 +68,6 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
private val qqqApiServiceMap: 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()
/*******************************************************************************
*******************************************************************************/
@@ -88,10 +82,6 @@ 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()
@@ -109,12 +99,11 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
}
/*******************************************************************************
** Build the shared client (same-origin, session, compatibility and error interceptors) with the small-call limits.
** Build the shared client (same-origin, session, compatibility and error interceptors) with default timeouts.
*******************************************************************************/
private fun buildClient(baseUrl: String): OkHttpClient {
return (
OkHttpClient.Builder()
.slowLinkTimeouts()
.followRedirects(false)
.followSslRedirects(false)
.addInterceptor { chain ->
@@ -361,13 +350,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 {
return processApiServiceMap[baseUrl]!!.processInit(processName, buildProcessInitOrStepRequestBodyV1(values, recordsParam, recordIds, filterJSON, stepTimeoutMillis))
return qqqApiServiceMap[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 {
return processApiServiceMap[baseUrl]!!.processStep(processName, processUUID, stepName, buildProcessInitOrStepRequestBodyV1(values, stepTimeoutMillis = stepTimeoutMillis))
return qqqApiServiceMap[baseUrl]!!.processStep(processName, processUUID, stepName, buildProcessInitOrStepRequestBodyV1(values, stepTimeoutMillis = stepTimeoutMillis))
}
/*******************************************************************************
@@ -1,55 +0,0 @@
/* 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,6 +2,7 @@
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
@@ -37,8 +38,8 @@ class UiApiCompatibility(
val server: String, private val requireMobileScanners: Boolean = false,
private val observer: (RequestOutcome) -> Unit = {}
) {
internal val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.slowLinkCallTimeouts().addInterceptor(RequestOutcomeInterceptor(observer)).build()
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(observer)).build()
@Volatile
private var verified: UiApiVersion? = null
@@ -20,7 +20,9 @@ data class OAuth2AuthenticationMetaData(
val clientId: String = "", val baseUrl: String = "", val scopes: String = "",
val nativeClientId: String? = null, val nativeClientBaseUrl: String? = null,
val bearerTokensAccepted: Boolean = false, val nativeSessionLogin: NativeSessionLogin? = null,
val nativeSessionLogout: NativeSessionLogout? = null
val nativeSessionLogout: NativeSessionLogout? = null,
// Automatic logoff (UI API 1.12.0): seconds without a user action after which the server ends the session.
val userInactivityTimeoutSeconds: Int? = null
)
/*******************************************************************************
@@ -39,6 +39,8 @@ class NativeSessionLoginTest {
assertEquals("login", metadata.values.nativeSessionLogin!!.prompt)
assertEquals("REQUIRED", metadata.values.nativeSessionLogin!!.nonceValidation)
assertNull((Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.nativeSessionLogin)
assertNull((Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.userInactivityTimeoutSeconds)
assertEquals(900, (Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"logoff","values":{"clientId":"server-client","userInactivityTimeoutSeconds":900}}""") as OAuth2AuthenticationMetaData).values.userInactivityTimeoutSeconds)
val wire = Json.encodeToString(request)
assertTrue(wire.contains("\"authenticationFlow\":\"AUTHORIZATION_CODE_PKCE_SESSION\""))
assertTrue(wire.contains("\"expectedNonce\":\"original-nonce-canary-1234\""))
@@ -345,18 +345,18 @@ class NetworkQQQRepositoryTest {
}
/*******************************************************************************
** Since 1.0.5 other calls also survive a server that answers after the old 10 second read timeout
** (NetworkTimeoutsTest asserts the configured limits).
** Only metadata gets the long limit: other calls keep the short default and still fail fast.
*******************************************************************************/
@Test
fun otherCallsSurviveAServerThatAnswersAfterTheOldDefaultTimeout() = runBlocking() {
fun otherCallsStillUseTheShortDefaultTimeout() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setHeadersDelay(12, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"records":[]}"""))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin))
assertTrue(repository.queryRecords("items", "id", 0, 10).isEmpty())
val failure = runCatching { repository.queryRecords("items", "id", 0, 10) }.exceptionOrNull()
assertTrue(failure is java.io.InterruptedIOException)
}
}
}
@@ -1,76 +0,0 @@
/* 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,7 +9,6 @@ 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
@@ -23,12 +22,6 @@ 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.
*******************************************************************************/
@@ -40,7 +33,8 @@ class DeviceApi(
private set
var displayName: String = ""
private set
private val client = deviceApiClient(onRequestOutcome)
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).addInterceptor(SameOriginInterceptor()).build()
/*******************************************************************************
** 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.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() }
internal val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.slowLinkCallTimeouts().build()
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.callTimeout(5, TimeUnit.SECONDS).build()
/*******************************************************************************
** Bound presentation responses and record safe outcomes while preserving coroutine cancellation.
@@ -3,13 +3,11 @@ 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
@@ -64,13 +62,8 @@ internal class MobileSessionApi(
fun installedVersions(): Map<String, String?> = mapOf("osVersion" to Build.VERSION.RELEASE, "appVersion" to BuildConfig.VERSION_NAME)
}
// 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()
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
/*******************************************************************************
** Send the current eligible session metadata and accept only the server's bounded lease policy.
@@ -94,7 +87,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, heartbeatClient))
return SessionHeartbeat.parse(post(session, "/sessions/heartbeat", json, 200))
}
/*******************************************************************************
@@ -119,7 +112,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, client: OkHttpClient = bestEffortClient): String = withContext(Dispatchers.IO)
private suspend fun post(session: String, path: String, body: JSONObject, expected: Int): String = withContext(Dispatchers.IO)
{
require(session.isNotBlank())
require(compatibility.server == server)
@@ -136,6 +136,13 @@ class ScannerActivity : ComponentActivity() {
watchingManagedConfiguration = false
}
/*******************************************************************************
** Every touch and key dispatched to this activity is a user action for the server's inactivity timeout.
*******************************************************************************/
override fun onUserInteraction() {
super.onUserInteraction(); UserActivity.record()
}
/*******************************************************************************
** Reflect focus changes so obscured windows cannot keep accepting scanner input.
*******************************************************************************/
@@ -231,7 +238,7 @@ class ScannerActivity : ComponentActivity() {
val hash = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) }
val dataStore = storesByServer.getOrPut(server) { PreferenceDataStoreFactory.create { File(noBackupFilesDir, "session-$hash.preferences_pb") } }
val repository = NetworkQQQRepository(server, requestObserver = diagnostics::nativeRequest, compatibility = compatibility) { requestedServer ->
if (requestedServer == server) credentials.headers() else emptyMap()
if (requestedServer == server) credentials.headers() + UserActivity.header() else emptyMap()
}
@Suppress("UNCHECKED_CAST")
return QViewModel(repository, dataStore).apply { supportedCustomWidgetTypes = setOf("SCANNER", "RECORD_FILES_CAPTURE") } as T
@@ -251,7 +258,9 @@ class ScannerActivity : ComponentActivity() {
return
}
val api = remember(server, diagnostics) { DeviceApi(server, credentials, diagnostics::deviceRequest, compatibility) }
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, credentials::headers, compatibility, diagnostics::nativeRequest) }
// Every request of the session states the age of the operator's last action; heartbeats alone keep nothing alive.
val sessionHeaders = remember(server) { { credentials.headers() + UserActivity.header() } }
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, sessionHeaders, compatibility, diagnostics::nativeRequest) }
var approved by remember(qViewModel.sessionUUID) { mutableStateOf(false) }
val location = rememberForegroundLocation(resumed && approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested)
val currentScreen = sessionScreen(qViewModel, resumed, focused, approved, captureBounds != null)
@@ -273,7 +282,7 @@ class ScannerActivity : ComponentActivity() {
else WallpaperStatus.store.signedOutIfSignedIn()
}
DisposableEffect(server) { onDispose { WallpaperStatus.store.clear() } }
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, credentials::headers, compatibility, diagnostics::sessionRequest) }
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, sessionHeaders, compatibility, diagnostics::sessionRequest) }
val deviceSharing = remember(server, qViewModel.sessionUUID) { DeviceScreenSharing(applicationContext) }
val sessions = remember(sessionApi, qViewModel.sessionUUID)
{
@@ -310,6 +319,10 @@ class ScannerActivity : ComponentActivity() {
{
if (approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested) sessions.run()
}
InactivityLogoff(
((authentication as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.values?.userInactivityTimeoutSeconds,
qViewModel.isAuthenticated && !qViewModel.isLogoutRequested
) { qViewModel.logOut() }
CompositionLocalProvider(
LocalCompanySignOutEnabled provides true,
LocalBusinessNavigationEnabled provides approved,
@@ -118,7 +118,8 @@ internal fun ScannerWidget(
{
ScannerInputSession(
{ driverFactory(context) },
{ capture -> currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
// A hardware scan is a user action even when no key event reaches the activity.
{ capture -> UserActivity.record(); currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
{ message, reconnect -> scannerStatus = message; canReconnect = reconnect },
canAcceptInput = { currentInputReady() })
}
@@ -0,0 +1,104 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.os.SystemClock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
/*******************************************************************************
** Automatic logoff (UI API 1.12.0). A request is not a user action: this app sends heartbeats. When the
** server has a user inactivity timeout, only what the operator does (a touch, a key, a scan) keeps the
** session alive. This object remembers when that last happened and states its age on every session request.
**
** The time base counts device sleep, so a handheld left in a cradle ages while its screen is off. Until the
** first user action of this process the age is the device's uptime: nothing is assumed in the user's favour.
*******************************************************************************/
object UserActivity {
const val HEADER = "X-QQQ-User-Activity-Age"
const val WARNING_SECONDS = 60
@Volatile
internal var clock: () -> Long = { SystemClock.elapsedRealtime() }
@Volatile
private var lastActionAt = 0L
/*******************************************************************************
** Record a user action. Call it only from a real touch, key or scan, never from a timer or a response.
*******************************************************************************/
fun record() {
lastActionAt = clock()
}
/*******************************************************************************
** The current time on this object's time base, for a caller that needs a starting point.
*******************************************************************************/
fun now(): Long = clock()
/*******************************************************************************
** Whole seconds since the last user action, never negative. A caller that only started watching at
** notBefore (the on-screen timer) counts from there when no action has been seen since.
*******************************************************************************/
fun ageSeconds(notBefore: Long = 0L): Long = ((clock() - maxOf(lastActionAt, notBefore)) / 1000).coerceAtLeast(0)
/*******************************************************************************
** The header that states the user's activity to the server; sent with every request of a session.
*******************************************************************************/
fun header(): Map<String, String> = mapOf(HEADER to ageSeconds().toString())
/*******************************************************************************
** Seconds left before the server's timeout runs out; zero or less means the session is over.
*******************************************************************************/
fun remainingSeconds(timeoutSeconds: Int, notBefore: Long = 0L): Long = timeoutSeconds - ageSeconds(notBefore)
/*******************************************************************************
** Forget the last user action; for tests.
*******************************************************************************/
internal fun reset() {
lastActionAt = 0L
}
}
/*******************************************************************************
** Sign the operator out when the server's user inactivity timeout runs out, and warn a minute before. The
** server enforces the same timeout at the next request; this is what the operator sees. Shows nothing
** unless the server has such a timeout and somebody is signed in.
*******************************************************************************/
@Composable
fun InactivityLogoff(timeoutSeconds: Int?, signedIn: Boolean, onLogoff: () -> Unit) {
if (timeoutSeconds == null || timeoutSeconds <= 0 || !signedIn) return
var remaining by remember(timeoutSeconds) { mutableStateOf<Long?>(null) }
// A session resumed by a fresh process has seen no action yet: the on-screen timer starts now, while the
// header still states the full age and the server decides from what it recorded.
val shownAt = remember(timeoutSeconds) { UserActivity.now() }
LaunchedEffect(timeoutSeconds)
{
while (true) {
val left = UserActivity.remainingSeconds(timeoutSeconds, shownAt)
if (left <= 0) {
remaining = null
onLogoff()
break
}
remaining = left.takeIf { it <= UserActivity.WARNING_SECONDS }
delay(1000)
}
}
val left = remaining ?: return
// A dialog has its own window: touches in it do not reach the activity, so they are recorded here.
val stay = { UserActivity.record(); remaining = null }
AlertDialog(
onDismissRequest = stay,
confirmButton = { TextButton(onClick = stay) { Text("Stay signed in") } },
title = { Text("Are you still there?") },
text = { Text("You will be signed out in $left ${if (left == 1L) "second" else "seconds"} because of inactivity.") }
)
}
@@ -1,47 +0,0 @@
/* 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)
}
}
@@ -0,0 +1,102 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/*******************************************************************************
**
*******************************************************************************/
class UserActivityTest {
private var now = 0L
private val systemClock = UserActivity.clock
/*******************************************************************************
**
*******************************************************************************/
@Before
fun useAFakeClock() {
now = 7_200_000L
UserActivity.clock = { now }
UserActivity.reset()
}
/*******************************************************************************
**
*******************************************************************************/
@After
fun restoreTheClock() {
UserActivity.clock = systemClock
UserActivity.reset()
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun beforeAnyUserActionNothingIsAssumedInTheUsersFavour() {
assertEquals(7200L, UserActivity.ageSeconds())
assertTrue(UserActivity.remainingSeconds(900) <= 0)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun ageIsWholeSecondsSinceTheLastUserActionAndNeverNegative() {
UserActivity.record()
assertEquals(0L, UserActivity.ageSeconds())
now += 999
assertEquals(0L, UserActivity.ageSeconds())
now += 60_501
assertEquals(61L, UserActivity.ageSeconds())
now -= 120_000
assertEquals(0L, UserActivity.ageSeconds())
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun timePassingAloneNeverCountsOnlyARecordedActionDoes() {
UserActivity.record()
now += 899_000
assertEquals(1L, UserActivity.remainingSeconds(900))
now += 1_000
assertEquals(0L, UserActivity.remainingSeconds(900))
now += 600_000
assertEquals(-600L, UserActivity.remainingSeconds(900))
UserActivity.record()
assertEquals(900L, UserActivity.remainingSeconds(900))
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun theOnScreenTimerOfAResumedSessionStartsWhenItIsShownTheHeaderDoesNot() {
val shownAt = UserActivity.now()
now += 30_000
assertEquals(870L, UserActivity.remainingSeconds(900, shownAt))
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "7230"), UserActivity.header())
UserActivity.record()
now += 10_000
assertEquals(890L, UserActivity.remainingSeconds(900, shownAt))
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "10"), UserActivity.header())
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun everySessionRequestStatesTheAgeInTheUiApiHeader() {
UserActivity.record()
now += 42_000
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "42"), UserActivity.header())
assertEquals("X-QQQ-User-Activity-Age", UserActivity.HEADER)
assertEquals(60, UserActivity.WARNING_SECONDS)
}
}