Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1912cf59c
|
||
|
|
9bae976138
|
||
|
|
b87171cafc
|
||
|
|
3b15183f52
|
||
|
|
b165dd34a8
|
||
|
|
4467d8ec64
|
||
|
|
9208416fa4
|
||
|
|
778ac58612
|
No files matched your search
@@ -1,5 +1,24 @@
|
||||
# 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)
|
||||
|
||||
## 1.0.2 (versionCode 4), candidate
|
||||
|
||||
Not yet built, signed, tagged or published (prepared 2026-11-06). Supersedes the unpublished signed 1.0.1 (versionCode 3) candidate and updates it in place with the same permanent signer.
|
||||
|
||||
@@ -63,7 +63,7 @@ The fresh-login prompt is a request to the provider, not cryptographic proof of
|
||||
|
||||
## Optional company sign-out (implementation awaiting qualification)
|
||||
|
||||
Ordinary **Sign out** continues to end only the QQQ session. The scanner also exposes a separate **Sign out of company** action in Account when the server advertises API1.10 or later within major1, feature `nativeSessionLogout`, and OAuth2 metadata `nativeSessionLogout` with flow `OIDC_RP_INITIATED_LOGOUT` and `postLogoutRedirectUris`. Other library hosts opt into that separate action with `LocalCompanySignOutEnabled`; it is disabled by default. No automatic provider-logout policy is selected. The confirmation explains that company sign-out may affect other applications in the browser.
|
||||
Since 1.0.4, **Sign out** in Account ends the QQQ session and, when the server advertises API1.10 or later within major1, feature `nativeSessionLogout`, and OAuth2 metadata `nativeSessionLogout` with flow `OIDC_RP_INITIATED_LOGOUT` and `postLogoutRedirectUris`, also ends the provider session in the browser (formerly the separate **Sign out of company** action; without that support Sign out stays QQQ-only). Other library hosts opt into provider sign-out with `LocalCompanySignOutEnabled`; it is disabled by default. No automatic provider-logout policy is selected. The confirmation explains that company sign-out may affect other applications in the browser.
|
||||
|
||||
Register the installed package's exact callback separately as a provider **post-logout** redirect, even when it matches the existing login callback. The client sends `{"nativeLogout":{"postLogoutRedirectUri":"<registered callback>"}}` to the existing `POST /qqq/v1/logout`. Its optional `nativeLogoutData` contains `issuer`, `endSessionEndpoint`, `postLogoutRedirectUri` and sensitive `idTokenHint`. Old sessions or unsupported servers may legitimately omit this data. The ordinary logout request remains unchanged.
|
||||
|
||||
|
||||
+12
-15
@@ -4,28 +4,22 @@ Use this for the **production-signed** APK on a small number of devices. Move to
|
||||
|
||||
This guide is not the local demo. The demo uses the development-signed debug APK, plain HTTP and `adb reverse` port forwarding to a workstation server; see [README](../README.md#installing-on-a-device). The two builds have different signers and cannot update each other.
|
||||
|
||||
**Current release status (November 5, 2026):** the only published release is the signed preview 1.0.0 below. A signed **1.0.1 (versionCode 3) candidate** exists but is **not published and has no download link**; this guide does not point to one. Use the table below for what is actually hosted. It is superseded by the planned **1.0.2 (versionCode 4)** candidate (not yet built; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4)). When a newer version is published, use the file, checksum and link given by the release owner, and verify the same signer.
|
||||
|
||||
| Candidate (not published) | Value |
|
||||
| --- | --- |
|
||||
| Version | 1.0.1, versionCode 3 |
|
||||
| APK SHA-256 | `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F` (15015419 bytes) |
|
||||
| Signing certificate SHA-256 | Same as below (`BDDB2D2F...5B17A9FF`) |
|
||||
**Current release status (November 9, 2026):** the latest published build is **1.0.4 (versionCode 6)** below, a Gitea prerelease. It updates 1.0.3, 1.0.2, 1.0.1 and 1.0.0 in place. A **1.0.5 (versionCode 7)** candidate with longer network limits for slow links is prepared but not signed or published (see [RELEASING](RELEASING.md#next-candidate-105-versioncode-7)). When a newer version is published, use the file, checksum and link on its release page, and verify the same signer.
|
||||
|
||||
Details and qualification status: [RELEASING](RELEASING.md).
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Source | [Signed preview release scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease; forge sign-in required) |
|
||||
| File | `QQQ-Mobile-1.0.0.apk`, with `SHA256SUMS` on the same release page |
|
||||
| Source | [Release scanner-v1.0.4](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.4) (prerelease; downloads without sign-in) |
|
||||
| File | `QQQ-Mobile-1.0.4.apk` (15015419 bytes), with `SHA256SUMS`, `INSTALL.md` and `RELEASE-NOTES.md` on the same release page |
|
||||
| Application ID | `io.qrun.mobile.scanner` |
|
||||
| Version | 1.0.0, versionCode 2 |
|
||||
| APK SHA-256 | `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51` |
|
||||
| Version | 1.0.4, versionCode 6 |
|
||||
| APK SHA-256 | `F643872F4435B55F6A0B929D1762CAACB18010E5CC0B473C7B4BABBDC4299489` |
|
||||
| Signing certificate SHA-256 | `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF` |
|
||||
|
||||
The same APK is used for every customer; branding comes from the server at runtime. Download the APK and `SHA256SUMS` from the release page and compare the SHA-256 before installing, then verify the signing certificate matches the value above. The checksum shows the file is intact, not who signed it. This is a preview, not a stable release. Only the APK and `SHA256SUMS` are hosted; there is no hosted install kit.
|
||||
The same APK is used for every customer; branding comes from the server at runtime. Download the APK and `SHA256SUMS` from the release page and compare the SHA-256 before installing, then verify the signing certificate matches the value above. The checksum shows the file is intact, not who signed it. This is a prerelease, not a stable release. Earlier releases: `scanner-v1.0.2` and the signed preview `scanner-v1.0.0` (`QQQ-Mobile-1.0.0.apk`, SHA-256 `EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51`).
|
||||
|
||||
**Device evidence.** *Historical (November 4):* this APK was installed and launched once on a fresh Zebra MC9400 (Android 13) and showed the Connect screen; it was not yet enrolled. *Current (November 5):* with the user's explicit authorization, the MC9400 was migrated: 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready were observed, and the operator confirmed a physical Saved scan that the backend verified. These checks are done and need not be repeated for this device. The 1.0.1 candidate is still unpublished, native Authentik login/logout/account switching is not yet qualified with the production APK, and the TC22 keeps its original development-signed installation.
|
||||
**Device evidence (1.0.0 and 1.0.1; 1.0.4 had not been installed on a device when it was published).** *Historical (November 4):* the 1.0.0 APK was installed and launched once on a fresh Zebra MC9400 (Android 13) and showed the Connect screen; it was not yet enrolled. *Current (November 5):* with the user's explicit authorization, the MC9400 was migrated: 1.0.0 enrollment over trusted HTTPS passed, then an in-place 1.0.1 update preserved login and enrollment. Apps/ScanCapture navigation and EMDK Scanner-ready were observed, and the operator confirmed a physical Saved scan that the backend verified. These checks are done and need not be repeated for this device. The 1.0.1 candidate is still unpublished, native Authentik login/logout/account switching is not yet qualified with the production APK, and the TC22 keeps its original development-signed installation.
|
||||
|
||||
## Before you start
|
||||
|
||||
@@ -35,7 +29,7 @@ The same APK is used for every customer; branding comes from the server at runti
|
||||
|
||||
## Install: copy the file (recommended)
|
||||
|
||||
1. Copy the verified `QQQ-Mobile-1.0.0.apk` to the device's **Downloads** folder.
|
||||
1. Copy the verified `QQQ-Mobile-1.0.4.apk` to the device's **Downloads** folder.
|
||||
2. Open **Files**, go to Downloads, and tap the APK.
|
||||
3. If Android asks, allow that Files app to install apps, then continue. You can turn the permission off again afterward.
|
||||
|
||||
@@ -45,9 +39,12 @@ Requires USB debugging enabled on the device, a computer the device has been ask
|
||||
|
||||
```powershell
|
||||
adb devices
|
||||
adb -s DEVICE_SERIAL install -r QQQ-Mobile-1.0.0.apk
|
||||
adb -s DEVICE_SERIAL install -r QQQ-Mobile-1.0.4.apk
|
||||
adb -s DEVICE_SERIAL shell dumpsys package io.qrun.mobile.scanner | findstr /i "versionName versionCode"
|
||||
```
|
||||
|
||||
The last command confirms the installed version (expect `versionCode=6` and `versionName=1.0.4`).
|
||||
|
||||
Replace `DEVICE_SERIAL` with the serial of the device you chose from `adb devices`. Always name the target with `-s`. Do not add `-d` (downgrade) or `-t`.
|
||||
|
||||
## First launch and enrollment
|
||||
|
||||
+25
-1
@@ -1,6 +1,28 @@
|
||||
# Scanner APK release readiness
|
||||
|
||||
## Current status (November 5, 2026)
|
||||
## Current status (November 9, 2026)
|
||||
|
||||
- **Latest published:** [scanner-v1.0.4](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.4), QQQ Mobile **1.0.4 (versionCode 6)**, a Gitea prerelease. The tag is on `main` `9bae976138c4447f3fa451ee49b8585e80f01847` (1.0.3 source plus Android PRs 26 and 27; a lightweight tag created with the release, like `scanner-v1.0.2`). Assets: `QQQ-Mobile-1.0.4.apk` (15015419 bytes, SHA-256 `F643872F4435B55F6A0B929D1762CAACB18010E5CC0B473C7B4BABBDC4299489`), `SHA256SUMS`, `INSTALL.md`, `RELEASE-NOTES.md`. The APK was downloaded back without sign-in and matched; `aapt2` shows versionCode 6 and versionName 1.0.4; `apksigner` verifies v2 and v3 with one signer whose certificate SHA-256 is `BDDB2D2F45A5925C643A83ABAF616C1EAB2387DC5CDA7B645A6F4C0D5B17A9FF`.
|
||||
- **Earlier releases:** `scanner-v1.0.2` (1.0.2, versionCode 4, signed candidate, prerelease) and `scanner-v1.0.0` (signed preview). 1.0.3 (versionCode 5) was signed and installed on the TC22 and MC9400 but never tagged or published; 1.0.4 includes it.
|
||||
- **Qualification of 1.0.4:** hosted CI on the tagged commit passed source checks, JVM unit tests, debug lint and the debug build; the emulator tests did not run because the runner had no usable `/dev/kvm`. No `lintRelease` result was recorded for the 1.0.4 build, and 1.0.4 had not been installed on a device when it was published.
|
||||
- **Next candidate:** **1.0.5 (versionCode 7)**, slow-link network limits; see [Next candidate](#next-candidate-105-versioncode-7). Not signed, tagged or published.
|
||||
- **CI note:** the shared workflow's Gradle step currently runs only the first line of `tasks` (unit tests, debug lint, debug build); the QA assembly line does not run. Run the release-variant lint and build locally before signing.
|
||||
|
||||
## Next candidate: 1.0.5 (versionCode 7)
|
||||
|
||||
Prepared on 2026-11-09 on branch `fix/slow-link-timeouts`. **Nothing is signed, tagged, installed or published for 1.0.5.** It updates 1.0.4 in place with the same signer and a higher `versionCode`.
|
||||
|
||||
Change since 1.0.4 (details in [CHANGELOG](../CHANGELOG.md#105-versioncode-7-candidate)): the remaining short network limits are raised for slow links, from one shared set (`NetworkTimeouts`).
|
||||
|
||||
Build from the exact merged `main` commit, with no `SCANNER_*` signing variables set:
|
||||
|
||||
```
|
||||
./gradlew --no-daemon --console=plain :scanner-host:lintRelease :scanner-host:assembleRelease -PscannerVersionName=1.0.5 -PscannerVersionCode=7
|
||||
```
|
||||
|
||||
Then sign `scanner-host/build/outputs/apk/release/scanner-host-release-unsigned.apk` in isolation, as for 1.0.4: `zipalign -P 16 -f 4`, then `apksigner sign` with the existing permanent key (certificate SHA-256 `BDDB2D2F...5B17A9FF`, recovered from the vault only for this step), then verify v2 and v3 with exactly one signer, `versionCode='7' versionName='1.0.5'`, and write `QQQ-Mobile-1.0.5.apk` and `SHA256SUMS`. Tag the built commit `scanner-v1.0.5`, create the Gitea release with the APK, `SHA256SUMS`, install notes and release notes, download the APK back and compare its SHA-256. Record the hashes here and in [INSTALLATION](INSTALLATION.md).
|
||||
|
||||
## Status on November 5, 2026 (historical)
|
||||
|
||||
- **Published:** the only published release is still [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), a **signed preview** (Gitea prerelease, not a stable release). It is unchanged.
|
||||
- **Candidate, not published:** QQQ Mobile **1.0.1 (versionCode 3)** is a signed candidate. Its release qualification is incomplete, and no tag or download link is available yet.
|
||||
@@ -13,6 +35,8 @@
|
||||
|
||||
## Next candidate: 1.0.2 (versionCode 4)
|
||||
|
||||
*(Historical: 1.0.2 was built, signed and published as prerelease `scanner-v1.0.2` on 2026-11-06, and is superseded by 1.0.4.)*
|
||||
|
||||
Prepared on 2026-11-06 on branch `release/scanner-1.0.2` (Android PRs 17 and 18 merged locally plus these notes). **Nothing is built, signed, tagged, installed or published for 1.0.2.** The signed 1.0.1 candidate stays unpublished and is superseded; 1.0.2 updates it in place with the same signer and a higher `versionCode`.
|
||||
|
||||
Changes since 1.0.1 (details in [CHANGELOG](../CHANGELOG.md#102-versioncode-4-candidate)): company sign-out now completes (the device-session end ran before the provider logout, so no `nativeLogoutData` came back and the browser never opened on the MC9400); the user name is shown after browser sign-in; app/OS version are reported on every heartbeat (effective with mobile-scanners server 1.6.0).
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
As of **2026-11-06**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is not a final-release or full-qualification claim. See [Release facts](#release-facts-2026-11-05) below.
|
||||
|
||||
## Release facts (2026-11-09)
|
||||
|
||||
- **Published:** `scanner-v1.0.4` (1.0.4, versionCode 6, prerelease) on `main` `9bae976`: the 60 second metadata load limit (PR 26), sign-out that ends the identity-provider session (PR 27) and 1.0.3's widget icon fix (PR 24). APK SHA-256 `F643872F4435B55F6A0B929D1762CAACB18010E5CC0B473C7B4BABBDC4299489`. Not yet installed on a device when published. Details: [RELEASING](RELEASING.md).
|
||||
- **Candidate:** 1.0.5 (versionCode 7) raises the remaining short network limits for slow links from one shared set (`NetworkTimeouts`); branch `fix/slow-link-timeouts`. Not signed, tagged or published.
|
||||
- **CI:** emulator tests do not run on the hosted runners (no usable `/dev/kvm`), and the shared workflow runs only the first Gradle task line.
|
||||
|
||||
## Release candidate 1.0.2 (versionCode 4), 2026-11-06
|
||||
|
||||
- Branch `release/scanner-1.0.2` carries Android PR 17 (company sign-out ordering, OAuth user name) and PR 18 (heartbeat app/OS version) plus the [1.0.2 changelog](../CHANGELOG.md#102-versioncode-4-candidate) and build instructions in [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). It depends on both PRs merging first and must be rebased onto the resulting `main`. **Nothing is built, signed, tagged, installed or published for 1.0.2**; the signed 1.0.1 candidate stays unpublished.
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ Open work only, as of **2026-11-05**. Current evidence: [SESSION-STATE](SESSION-
|
||||
|
||||
- [ ] Qualify actual GS1 label media and physical printed-label rescan. Synthetic capture/render evidence does not close this check.
|
||||
- [ ] Verify actual production-APK native Authentik login, logout and account switching; awaiting the MC wireless-debug endpoint. Real-server discovery (UI API 1.10, strict nonce, scanner and printing channels) is already verified; no native-Authentik PASS is claimed.
|
||||
- [ ] After Android PRs 17 and 18 merge, rebase `release/scanner-1.0.2`, then build, sign (same permanent signer) and publish 1.0.2 (versionCode 4) with checksum and installation instructions after remaining qualification; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). The signed 1.0.1 (versionCode 3) candidate is superseded and stays unpublished. The published release is still the 1.0.0 signed preview.
|
||||
- [ ] Install published 1.0.4 (`scanner-v1.0.4`) on the handhelds and confirm the in-place update. Then merge the 1.0.5 slow-link change, build, sign (same permanent signer), tag `scanner-v1.0.5` and publish; see [RELEASING](RELEASING.md#next-candidate-105-versioncode-7).
|
||||
- [ ] (Superseded by 1.0.4; kept for its notes.) After Android PRs 17 and 18 merge, rebase `release/scanner-1.0.2`, then build, sign (same permanent signer) and publish 1.0.2 (versionCode 4) with checksum and installation instructions after remaining qualification; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). The signed 1.0.1 (versionCode 3) candidate is superseded and stays unpublished. The published release is still the 1.0.0 signed preview.
|
||||
- [ ] Publish and distribute full release assets to remaining devices; validate MDM/company profile and wallpaper separately. A customer deployment needs a customer HTTPS URL with a trusted certificate.
|
||||
- [ ] Printing: the next installer remains with the active printing owner (print nonce fix PR 11 postdates published print 1.1.1).
|
||||
- [ ] Optional, non-blocking: automatic APK signing CI. Direct APK release does not depend on it.
|
||||
|
||||
+51
-11
@@ -44,18 +44,36 @@ import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Retrofit
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.util.TimeZone
|
||||
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.
|
||||
*******************************************************************************/
|
||||
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)
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val compatibility: UiApiCompatibility? = null, private val additionalHeaders: (String) -> Map<String, String> = { emptyMap() }) : QQQRepository {
|
||||
class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val compatibility: UiApiCompatibility? = null, private val metaDataTimeoutMillis: Long = METADATA_LOAD_TIMEOUT_SECONDS * 1000, private val additionalHeaders: (String) -> Map<String, String> = { emptyMap() }) : QQQRepository {
|
||||
private val sessionUUIDs = ConcurrentHashMap<String, String>()
|
||||
private val retroJson = Json { ignoreUnknownKeys = true }
|
||||
private val compatibilityByServer = ConcurrentHashMap<String, UiApiCompatibility>()
|
||||
|
||||
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()
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@@ -63,9 +81,17 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
baseUrl = environment.baseUrl
|
||||
|
||||
if (qqqApiServiceMap[baseUrl] == null) {
|
||||
val thisRetrofit = buildRetrofit(baseUrl)
|
||||
val thisQQQApiService = thisRetrofit.create(QQQApiService::class.java)
|
||||
qqqApiServiceMap[baseUrl] = thisQQQApiService
|
||||
val client = buildClient(baseUrl)
|
||||
qqqApiServiceMap[baseUrl] = buildRetrofit(baseUrl, client).create(QQQApiService::class.java)
|
||||
val metaDataClient = client.newBuilder()
|
||||
.callTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||
.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()
|
||||
@@ -74,11 +100,21 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
private fun buildRetrofit(baseUrl: String): Retrofit {
|
||||
private fun buildRetrofit(baseUrl: String, client: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType()))
|
||||
.client(
|
||||
.client(client)
|
||||
.baseUrl(baseUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the shared client (same-origin, session, compatibility and error interceptors) with the small-call limits.
|
||||
*******************************************************************************/
|
||||
private fun buildClient(baseUrl: String): OkHttpClient {
|
||||
return (
|
||||
OkHttpClient.Builder()
|
||||
.slowLinkTimeouts()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.addInterceptor { chain ->
|
||||
@@ -110,8 +146,6 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
.addInterceptor(SameOriginInterceptor())
|
||||
.build()
|
||||
)
|
||||
.baseUrl(baseUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -309,7 +343,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
||||
override suspend fun getMetaData(
|
||||
frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String
|
||||
): QInstance {
|
||||
return qqqApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion)
|
||||
try {
|
||||
return metaDataApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion)
|
||||
} catch (e: InterruptedIOException) {
|
||||
// OkHttp reports read and call timeouts as InterruptedIOException (SocketTimeoutException); the bare "timeout" is not diagnosable.
|
||||
// Coroutine cancellation is a CancellationException, not an IOException, so it is unaffected.
|
||||
throw IOException("Timed out after ${metaDataTimeoutMillis / 1000.0}s loading the server's metadata from ${baseUrl.trimEnd('/')}/qqq/v1/metaData", e)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -321,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 {
|
||||
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 {
|
||||
return qqqApiServiceMap[baseUrl]!!.processStep(processName, processUUID, stepName, buildProcessInitOrStepRequestBodyV1(values, stepTimeoutMillis = stepTimeoutMillis))
|
||||
return processApiServiceMap[baseUrl]!!.processStep(processName, processUUID, stepName, buildProcessInitOrStepRequestBodyV1(values, stepTimeoutMillis = stepTimeoutMillis))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+55
@@ -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
-3
@@ -2,7 +2,6 @@
|
||||
package com.kingsrook.qqq.frontend.android.core.controllers
|
||||
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -38,8 +37,8 @@ class UiApiCompatibility(
|
||||
val server: String, private val requireMobileScanners: Boolean = false,
|
||||
private val observer: (RequestOutcome) -> Unit = {}
|
||||
) {
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(observer)).build()
|
||||
internal val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.slowLinkCallTimeouts().addInterceptor(RequestOutcomeInterceptor(observer)).build()
|
||||
|
||||
@Volatile
|
||||
private var verified: UiApiVersion? = null
|
||||
|
||||
+3
-2
@@ -27,8 +27,9 @@ import kotlinx.serialization.Serializable
|
||||
*******************************************************************************/
|
||||
@Serializable
|
||||
data class QHelpContent(
|
||||
val content: String,
|
||||
val format: String,
|
||||
val content: String? = null, // QQQ core now sends summary-only help entries
|
||||
val summary: String? = null,
|
||||
val format: String? = null,
|
||||
val roles: Set<String> = emptySet(),
|
||||
val contentAsText: String? = null
|
||||
) {
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@
|
||||
package com.kingsrook.qqq.frontend.android.core.model.metadata
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/*******************************************************************************
|
||||
*******************************************************************************/
|
||||
@@ -30,7 +31,7 @@ import kotlinx.serialization.Serializable
|
||||
data class QWidgetMetaData(
|
||||
val name: String,
|
||||
val label: String? = null,
|
||||
val icon: QIcon? = null,
|
||||
val icon: JsonElement? = null, // QQQ core serializes a widget icon as a plain string (e.g. "troubleshoot"); tolerate an object too
|
||||
val type: String,
|
||||
) {
|
||||
}
|
||||
+56
@@ -303,4 +303,60 @@ class NetworkQQQRepositoryTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The metadata response is large and slow to build; a response that starts after the old 10 second read timeout must still load.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun metaDataLoadSurvivesAServerThatAnswersAfterTheOldDefaultTimeout() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setHeadersDelay(11, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"tables":{},"processes":{},"widgets":{},"apps":{}}"""))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin)
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
val instance = repository.getMetaData("qqq-android", "1.0", "app", "1.0")
|
||||
assertTrue(instance.tables!!.isEmpty())
|
||||
server.takeRequest()
|
||||
assertEquals("/qqq/v1/metaData", server.takeRequest().requestUrl!!.encodedPath)
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A metadata timeout names what timed out and the URL, without any query values, tokens or cookies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun metaDataTimeoutNamesTheMetadataLoadAndUrlWithoutSecrets() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setHeadersDelay(3, java.util.concurrent.TimeUnit.SECONDS).setBody("{}"))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin, metaDataTimeoutMillis = 500)
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
repository.setSessionUUID("fixture-session-value")
|
||||
val failure = runCatching { repository.getMetaData("qqq-android", "1.0", "app", "1.0") }.exceptionOrNull()
|
||||
assertTrue(failure is java.io.IOException)
|
||||
val message = failure!!.message!!
|
||||
assertTrue(message, message.startsWith("Timed out after 0.5s loading the server's metadata from "))
|
||||
assertTrue(message, message.endsWith(origin.trimEnd('/') + "/qqq/v1/metaData"))
|
||||
assertFalse(message.contains("fixture-session-value"))
|
||||
assertFalse(message.contains("?"))
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Since 1.0.5 other calls also survive a server that answers after the old 10 second read timeout
|
||||
** (NetworkTimeoutsTest asserts the configured limits).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun otherCallsSurviveAServerThatAnswersAfterTheOldDefaultTimeout() = runBlocking() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||
server.enqueue(MockResponse().setHeadersDelay(12, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"records":[]}"""))
|
||||
val origin = server.url("/").toString()
|
||||
val repository = NetworkQQQRepository(origin)
|
||||
repository.setEnvironment(Environment("test", origin))
|
||||
assertTrue(repository.queryRecords("items", "id", 0, 10).isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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))
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2004-2024. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.android.core.model.metadata
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/*******************************************************************************
|
||||
** Current QQQ core serializes widget icons as plain strings and sends
|
||||
** summary-only help entries; the decoder must accept those shapes and the
|
||||
** older object/content shapes.
|
||||
*******************************************************************************/
|
||||
class MetadataDecodingTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun widgetIconAsStringDecodes() {
|
||||
val widget = json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table","icon":"troubleshoot"}""")
|
||||
assertEquals(JsonPrimitive("troubleshoot"), widget.icon)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun widgetIconAsObjectStillDecodes() {
|
||||
val widget = json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table","icon":{"name":"troubleshoot"}}""")
|
||||
assertTrue(widget.icon is JsonObject)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun widgetWithoutIconDecodes() {
|
||||
assertNull(json.decodeFromString<QWidgetMetaData>("""{"name":"w","type":"table"}""").icon)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun summaryOnlyHelpDecodes() {
|
||||
val help = json.decodeFromString<QHelpContent>("""{"summary":"short help","roles":["ALL_SCREENS"]}""")
|
||||
assertEquals("short help", help.summary)
|
||||
assertNull(help.content)
|
||||
assertNull(help.format)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun contentHelpStillDecodes() {
|
||||
val help = json.decodeFromString<QHelpContent>("""{"content":"body","format":"TEXT"}""")
|
||||
assertEquals("body", help.content)
|
||||
assertEquals("TEXT", help.format)
|
||||
assertNull(help.summary)
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -132,7 +132,9 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
|
||||
/*******************************************************************************
|
||||
** Route sign-out through explicit process-exit confirmation so pending work is not silently abandoned.
|
||||
*******************************************************************************/
|
||||
fun requestSignOut() = requestLeave("Sign out?", "Sign out", alwaysConfirm = true) { qViewModel.requestLogout() }
|
||||
fun requestSignOut(providerSignOut: com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding? = null) =
|
||||
if (providerSignOut != null) requestCompanySignOut(providerSignOut)
|
||||
else requestLeave("Sign out?", "Sign out", alwaysConfirm = true) { qViewModel.requestLogout() }
|
||||
|
||||
/*******************************************************************************
|
||||
** The same guarded leave/cancel policy applies to explicitly enabled provider sign-out.
|
||||
|
||||
+9
-1
@@ -43,6 +43,13 @@ import kotlin.coroutines.resumeWithException
|
||||
*******************************************************************************/
|
||||
val LocalOAuth2Discovery = staticCompositionLocalOf<suspend (OAuth2LoginBinding) -> AuthorizationServiceConfiguration> { ::discoverOAuth2 }
|
||||
|
||||
/*******************************************************************************
|
||||
** Sent with every interactive sign-in next to prompt=login. OIDC max_age=0 independently requires the provider to
|
||||
** re-authenticate, so a provider session left in the browser cannot silently sign the next person in; the real
|
||||
** Authentik test showed prompt=login alone reusing retained SSO (docs/AUTHENTICATION.md).
|
||||
*******************************************************************************/
|
||||
val OAUTH2_FORCE_REAUTH_PARAMETERS: Map<String, String> = mapOf("max_age" to "0")
|
||||
|
||||
/*******************************************************************************
|
||||
** Browser authorization is standard AppAuth; API1.10 exchanges the code and validates the retained original nonce.
|
||||
*******************************************************************************/
|
||||
@@ -93,7 +100,8 @@ fun QOAuth2Login(viewModel: QViewModel, metadata: OAuth2AuthenticationMetaData,
|
||||
val request = AuthorizationRequest.Builder(
|
||||
configuration, binding.clientId, ResponseTypeValues.CODE,
|
||||
Uri.parse(binding.redirectUri)
|
||||
).setScope(binding.scopes).setPrompt(binding.prompt).setResponseMode("query").build()
|
||||
).setScope(binding.scopes).setPrompt(binding.prompt).setAdditionalParameters(OAUTH2_FORCE_REAUTH_PARAMETERS)
|
||||
.setResponseMode("query").build()
|
||||
check(request.codeVerifierChallengeMethod == "S256")
|
||||
if (viewModel.beginOAuth2Browser(
|
||||
generation, binding, requireNotNull(request.state),
|
||||
|
||||
+20
-8
@@ -24,6 +24,7 @@ import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.LoadState
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
|
||||
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.oauth2LogoutBinding
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -34,10 +35,20 @@ val LocalOpenDiagnostics = compositionLocalOf<(() -> Unit)?> { null }
|
||||
val LocalBusinessNavigationEnabled = compositionLocalOf { true }
|
||||
|
||||
/*******************************************************************************
|
||||
** Expose account actions while keeping optional company sign-out separate from ordinary local sign-out.
|
||||
** Expose account actions; sign-out also ends the provider session where the host and server support it.
|
||||
*******************************************************************************/
|
||||
/*******************************************************************************
|
||||
** Sign-out ends the identity-provider session too whenever the host enables it and the server supports it;
|
||||
** otherwise a session left in the browser silently signs the next sign-in back in. Null means local-only sign-out.
|
||||
*******************************************************************************/
|
||||
fun providerSignOutBinding(binding: OAuth2LogoutBinding?, serverSupportsProviderLogout: Boolean): OAuth2LogoutBinding? =
|
||||
if (serverSupportsProviderLogout) binding else null
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the provider sign-out binding for the current server, or null when it is disabled or unsupported.
|
||||
*******************************************************************************/
|
||||
@Composable
|
||||
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
|
||||
fun rememberProviderSignOutBinding(model: QViewModel): OAuth2LogoutBinding? {
|
||||
val companyLogout = if (LocalCompanySignOutEnabled.current)
|
||||
((model.authenticationMetaDataLoadState as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.let {
|
||||
oauth2LogoutBinding(it, model.environment.baseUrl, LocalContext.current.packageName)
|
||||
@@ -53,6 +64,12 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
|
||||
supportsCompanyLogout = false
|
||||
}
|
||||
}
|
||||
return providerSignOutBinding(companyLogout, supportsCompanyLogout)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
|
||||
val providerSignOut = rememberProviderSignOutBinding(model)
|
||||
AlertDialog(
|
||||
onDismissRequest = dismiss, title = { Text("Account", Modifier.testTag("accountDialog.title")) },
|
||||
text = {
|
||||
@@ -60,13 +77,8 @@ internal fun AccountDialog(model: QViewModel, dismiss: () -> Unit) {
|
||||
Text(if (model.isAuthenticated) "Current User: ${model.sessionUserFullName}" else "Not currently logged in")
|
||||
TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = {
|
||||
dismiss()
|
||||
model.qNavigator.requestSignOut()
|
||||
model.qNavigator.requestSignOut(providerSignOut)
|
||||
}) { Text("Sign out") }
|
||||
if (companyLogout != null && supportsCompanyLogout)
|
||||
TextButton(enabled = model.isAuthenticated && !model.isLogoutRequested, onClick = {
|
||||
dismiss()
|
||||
model.qNavigator.requestCompanySignOut(companyLogout)
|
||||
}) { Text("Sign out of company") }
|
||||
}
|
||||
}, confirmButton = { TextButton(onClick = dismiss) { Text("Close") } })
|
||||
}
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro
|
||||
OutlinedButton(onClick = { processViewModel.loadRemoteOptions(field) }) { Text("Retry choices") }
|
||||
}
|
||||
semanticFieldHint(field)?.let { Text(it) }
|
||||
field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: plainText(it.content)) }
|
||||
field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: it.content?.let { c -> plainText(c) } ?: it.summary.orEmpty()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-3
@@ -13,7 +13,9 @@ import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAu
|
||||
import com.kingsrook.qqq.frontend.android.core.model.processes.ProcessStepResult
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAUTH2_FORCE_REAUTH_PARAMETERS
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LoginBinding
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.providerSignOutBinding
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -145,12 +147,60 @@ class QViewModelLogoutTest {
|
||||
assertNull(json.decodeFromString<ManageSessionResponse>("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** After sign-out (ordinary or provider) a restarted app finds no stored session and does not resume one.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun restartAfterSignOutDoesNotRestoreTheSession() = runTest()
|
||||
{
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
try {
|
||||
for (provider in listOf(false, true)) {
|
||||
val store = MockPreferencesDataStore()
|
||||
val repository = LogoutRepository()
|
||||
val model = signedInModel(repository, store)
|
||||
assertEquals("session-1", store.preferences[com.kingsrook.qqq.frontend.android.mobileapp.data.SESSION_UUID_KEY])
|
||||
if (provider) assertNotNull(companyLogout(model))
|
||||
else { model.requestLogout(); model.logOut().join() }
|
||||
assertNull(store.preferences[com.kingsrook.qqq.frontend.android.mobileapp.data.SESSION_UUID_KEY]?.takeIf { it.isNotEmpty() })
|
||||
val restartedRepository = LogoutRepository()
|
||||
val restarted = QViewModel(restartedRepository, store)
|
||||
advanceUntilIdle()
|
||||
assertFalse(restarted.isAuthenticated)
|
||||
assertEquals("", restarted.sessionUUID)
|
||||
assertEquals(0, restartedRepository.metaDataCalls)
|
||||
}
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sign-out reaches the identity provider whenever the server supports it, never silently local-only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun signOutUsesProviderLogoutWhenSupported() {
|
||||
val binding = OAuth2LogoutBinding(loginBinding, REDIRECT)
|
||||
assertEquals(binding, providerSignOutBinding(binding, true))
|
||||
assertNull(providerSignOutBinding(binding, false))
|
||||
assertNull(providerSignOutBinding(null, true))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every interactive sign-in forces re-authentication, so a lingering provider session cannot sign the next user in.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
fun authorizationRequestForcesReauthentication() {
|
||||
assertEquals(mapOf("max_age" to "0"), OAUTH2_FORCE_REAUTH_PARAMETERS)
|
||||
assertEquals("login", loginBinding.prompt)
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete a native browser login against the fake repository so sign-out tests start authenticated.
|
||||
*******************************************************************************/
|
||||
private fun TestScope.signedInModel(repository: LogoutRepository): QViewModel {
|
||||
private fun TestScope.signedInModel(repository: LogoutRepository, store: MockPreferencesDataStore = MockPreferencesDataStore()): QViewModel {
|
||||
QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER))
|
||||
val model = QViewModel(repository, MockPreferencesDataStore())
|
||||
val model = QViewModel(repository, store)
|
||||
advanceUntilIdle()
|
||||
val generation = requireNotNull(model.prepareOAuth2Login(loginBinding))
|
||||
assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER))
|
||||
@@ -182,6 +232,7 @@ class QViewModelLogoutTest {
|
||||
private class LogoutRepository : QQQRepository {
|
||||
val calls = mutableListOf<String>()
|
||||
var activeSession = ""
|
||||
var metaDataCalls = 0
|
||||
|
||||
override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData(
|
||||
"oauth", values = OAuth2AuthenticationMetaData.Values(
|
||||
@@ -214,7 +265,7 @@ class QViewModelLogoutTest {
|
||||
|
||||
override fun setEnvironment(environment: Environment) {}
|
||||
override suspend fun manageSession(body: ManageSessionRequest): ManageSessionResponse = error("Not used")
|
||||
override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance = QInstance()
|
||||
override suspend fun getMetaData(frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String): QInstance { metaDataCalls++; return QInstance() }
|
||||
override suspend fun getProcessMetaData(processName: String): QProcessMetaData = error("Not used")
|
||||
override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
|
||||
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used")
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.os.Build
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.slowLinkTimeouts
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -22,6 +23,12 @@ import org.json.JSONObject
|
||||
*******************************************************************************/
|
||||
class EnrollmentConflict : IllegalStateException("Registration already exists but its credentials were not received. Start a new registration and ask the administrator to approve it.")
|
||||
|
||||
/*******************************************************************************
|
||||
** Device enrollment and status client: small calls with the shared slow-link limits and, as before, no call timeout.
|
||||
*******************************************************************************/
|
||||
internal fun deviceApiClient(onRequestOutcome: (RequestOutcome) -> Unit): OkHttpClient = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.slowLinkTimeouts().addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
|
||||
/*******************************************************************************
|
||||
** The native QQQ session also authenticates device management.
|
||||
*******************************************************************************/
|
||||
@@ -33,8 +40,7 @@ class DeviceApi(
|
||||
private set
|
||||
var displayName: String = ""
|
||||
private set
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.addInterceptor(RequestOutcomeInterceptor(onRequestOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
private val client = deviceApiClient(onRequestOutcome)
|
||||
|
||||
/*******************************************************************************
|
||||
** Check enrollment on demand; approval remains a server-side decision.
|
||||
|
||||
@@ -4,10 +4,10 @@ package io.qrun.mobile.scanner
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.MOBILE_SCANNERS_PATH
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.slowLinkCallTimeouts
|
||||
import java.net.URI
|
||||
import java.time.Instant
|
||||
import java.util.ArrayDeque
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.pow
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -137,8 +137,8 @@ internal class HostRuntime(private val server: String, val diagnostics: Diagnost
|
||||
*******************************************************************************/
|
||||
suspend fun verifyCompatibility() = withContext(Dispatchers.IO) { compatibility.verify() }
|
||||
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.callTimeout(5, TimeUnit.SECONDS).build()
|
||||
internal val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.slowLinkCallTimeouts().build()
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound presentation responses and record safe outcomes while preserving coroutine cancellation.
|
||||
|
||||
@@ -3,11 +3,13 @@ package io.qrun.mobile.scanner
|
||||
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.ANDROID_CLIENT
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.MOBILE_SCANNERS_PATH
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.NetworkTimeouts
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcome
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException
|
||||
import com.kingsrook.qqq.frontend.android.core.controllers.slowLinkCallTimeouts
|
||||
import android.os.Build
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -62,8 +64,13 @@ internal class MobileSessionApi(
|
||||
fun installedVersions(): Map<String, String?> = mapOf("osVersion" to Build.VERSION.RELEASE, "appVersion" to BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
// Heartbeats report the device as online; a 5 second limit abandoned any heartbeat a slow link needed longer to deliver.
|
||||
internal val heartbeatClient = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.slowLinkCallTimeouts().addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
|
||||
|
||||
// Ending the session and live screen frames stay short (see NetworkTimeouts.BEST_EFFORT_CALL_SECONDS).
|
||||
internal val bestEffortClient = heartbeatClient.newBuilder()
|
||||
.callTimeout(NetworkTimeouts.BEST_EFFORT_CALL_SECONDS, TimeUnit.SECONDS).build()
|
||||
|
||||
/*******************************************************************************
|
||||
** Send the current eligible session metadata and accept only the server's bounded lease policy.
|
||||
@@ -87,7 +94,7 @@ internal class MobileSessionApi(
|
||||
"stepName" to state.stepName, "screenLabel" to state.label, "captureBlockedReason" to state.blockedReason
|
||||
)
|
||||
.forEach { (name, value) -> json.put(name, value ?: JSONObject.NULL) }
|
||||
return SessionHeartbeat.parse(post(session, "/sessions/heartbeat", json, 200))
|
||||
return SessionHeartbeat.parse(post(session, "/sessions/heartbeat", json, 200, heartbeatClient))
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -112,7 +119,7 @@ internal class MobileSessionApi(
|
||||
/*******************************************************************************
|
||||
** Verify compatibility and close bounded responses while connecting coroutine cancellation to the HTTP call.
|
||||
*******************************************************************************/
|
||||
private suspend fun post(session: String, path: String, body: JSONObject, expected: Int): String = withContext(Dispatchers.IO)
|
||||
private suspend fun post(session: String, path: String, body: JSONObject, expected: Int, client: OkHttpClient = bestEffortClient): String = withContext(Dispatchers.IO)
|
||||
{
|
||||
require(session.isNotBlank())
|
||||
require(compatibility.server == server)
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.LocalCompanySignOutEnabled
|
||||
import com.kingsrook.qqq.frontend.android.mobileapp.ui.horseshoe.rememberProviderSignOutBinding
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.SideEffect
|
||||
@@ -405,6 +406,7 @@ private fun DeviceGate(
|
||||
viewModel: QViewModel, api: DeviceApi, credentials: DeviceCredentials, onApproval: (Boolean) -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val providerSignOut = rememberProviderSignOutBinding(viewModel)
|
||||
var attempt by remember { mutableIntStateOf(0) }
|
||||
var status by remember(viewModel.sessionUUID) { mutableStateOf("CHECKING") }
|
||||
var error by remember { mutableStateOf("") }
|
||||
@@ -444,6 +446,6 @@ private fun DeviceGate(
|
||||
Text("Your administrator can edit this device’s name, short name and description in QQQ before approval.")
|
||||
Button(enabled = status != "CHECKING", onClick = { attempt++ }) { Text("Check approval") }
|
||||
if (enrollmentConflict) Button(onClick = { credentials.restartPendingEnrollment(); attempt++ }) { Text("Start new registration") }
|
||||
Button(onClick = { viewModel.qNavigator.requestSignOut() }) { Text("Sign out") }
|
||||
Button(onClick = { viewModel.qNavigator.requestSignOut(providerSignOut) }) { Text("Sign out") }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user