fix(scanner): report osVersion and appVersion on every heartbeat
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 4m35s
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 10m21s
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 22s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 20s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 18s
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 1h27m9s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s

After the MC9400 in-place 1.0.0 to 1.0.1 update, scan receipts still showed
appVersion 1.0.0: the host sent osVersion/appVersion only at enrollment and
the server never refreshed them. MobileSessionApi now carries both values
(Build.VERSION.RELEASE and BuildConfig.VERSION_NAME, blank values omitted,
200-character cap) on each heartbeat so a mobile-scanners 1.6 server
refreshes the device record; older servers ignore the optional keys.

MobileSessionTest verifies the heartbeat payload carries only the two
version keys and no other device metadata. UI-API.md and SESSION-STATE.md
record the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-11-06 10:40:03 -06:00
1 parent 3325bd6061
commit f733a9906c
4 files changed
+24 -3

No files matched your search

+4
View File
@@ -2,6 +2,10 @@
As of **2026-11-05**. Scope: scanner host and maintained local Echo workflow. [TODO](TODO.md) tracks remaining work; [history](history/README.md) preserves earlier evidence. This is not a final-release or full-qualification claim. See [Release facts](#release-facts-2026-11-05) below. As of **2026-11-05**. 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.
## Open fix (2026-11-06, uncommitted branch `fix/refresh-device-versions`)
- After the MC9400 in-place 1.0.0 to 1.0.1 update, scan receipts still reported `appVersion` 1.0.0: the host only sent `osVersion`/`appVersion` at enrollment and the server never refreshed them. `MobileSessionApi` now sends both on every heartbeat (`MobileSessionTest.networkPayloadUsesSemanticRoutesAndCarriesOnlyScreenMetadata` covers the payload); the matching server change is mobile-scanners 1.6 on the same-named server branch. Older servers ignore the keys; the installed 1.0.1 APK does not include this change.
## Release facts (2026-11-05) ## Release facts (2026-11-05)
- **Published:** still `scanner-v1.0.0`, a signed preview. Signed candidate **1.0.1 (versionCode 3)** is **not published**, has no tag and no download link, and is not production-complete. Signer: existing permanent QRun key (certificate SHA-256 `BDDB2D2F...5B17A9FF`); no new key. APK SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes. Details: [RELEASING](RELEASING.md). - **Published:** still `scanner-v1.0.0`, a signed preview. Signed candidate **1.0.1 (versionCode 3)** is **not published**, has no tag and no download link, and is not production-complete. Signer: existing permanent QRun key (certificate SHA-256 `BDDB2D2F...5B17A9FF`); no new key. APK SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes. Details: [RELEASING](RELEASING.md).
+1 -1
View File
@@ -63,7 +63,7 @@ The scanner event preserves the barcode string, event UUID and capture timestamp
## Session activity and screen viewing ## Session activity and screen viewing
The approved, authenticated host sends foreground activity through the registered extension. Heartbeats contain app/process/step names and availability, never form values, barcodes, passwords or credentials. The server derives identity from the verified QQQ session and device. Ending a session in QQQ causes the host to clear its local login when the next protected operation or heartbeat returns an authentication failure. Signing out or changing servers stops capture immediately and attempts to end the current server session before standard logout. The approved, authenticated host sends foreground activity through the registered extension. Heartbeats contain app/process/step names and availability, never form values, barcodes, passwords or credentials. Each heartbeat also carries the installed `osVersion` and `appVersion` (the values enrollment reported); a mobile-scanners 1.6 server refreshes the device record when they change so scan receipts after an in-place update show the new version, and older servers ignore the extra optional keys. The server derives identity from the verified QQQ session and device. Ending a session in QQQ causes the host to clear its local login when the next protected operation or heartbeat returns an authentication failure. Signing out or changing servers stops capture immediately and attempts to end the current server session before standard logout.
An administrator viewing the ordinary QQQ session record renews a short screen-viewing lease through its standard widget. The host uses PixelCopy on its own app-content rectangle only, at most once per second, bounded to 640×1280 pixels and 256 KiB JPEG. No OS screen, other application or remote control is exposed. Capture requires the resumed, focused, approved authenticated screen, completed rendering and a live lease. Password screens, loading transitions, backgrounding and logout block capture and clear the server's displayed frame. Screen/session state is checked after capture and immediately before upload. Lease timing uses server-relative duration and Android's monotonic clock, not the handheld wall clock. Frames are not saved locally. An administrator viewing the ordinary QQQ session record renews a short screen-viewing lease through its standard widget. The host uses PixelCopy on its own app-content rectangle only, at most once per second, bounded to 640×1280 pixels and 256 KiB JPEG. No OS screen, other application or remote control is exposed. Capture requires the resumed, focused, approved authenticated screen, completed rendering and a live lease. Password screens, loading transitions, backgrounding and logout block capture and clear the server's displayed frame. Screen/session state is checked after capture and immediately before upload. Lease timing uses server-relative duration and Android's monotonic clock, not the handheld wall clock. Frames are not saved locally.
@@ -8,6 +8,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.RequestOutcomeInterce
import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor import com.kingsrook.qqq.frontend.android.core.controllers.SameOriginInterceptor
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility import com.kingsrook.qqq.frontend.android.core.controllers.UiApiCompatibility
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException
import android.os.Build
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -52,7 +53,15 @@ internal class MobileSessionApi(
private val headers: () -> Map<String, String>, private val headers: () -> Map<String, String>,
private val compatibility: UiApiCompatibility, private val compatibility: UiApiCompatibility,
onOutcome: (RequestOutcome) -> Unit = {}, onOutcome: (RequestOutcome) -> Unit = {},
private val deviceInfo: Map<String, String?> = installedVersions(),
) : MobileSessionTransport { ) : MobileSessionTransport {
companion object {
/*******************************************************************************
** The same software versions enrollment reported, so an in-place update refreshes the device record and later scan receipts.
*******************************************************************************/
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) private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build() .callTimeout(5, TimeUnit.SECONDS).addInterceptor(RequestOutcomeInterceptor(onOutcome)).addInterceptor(SameOriginInterceptor()).build()
@@ -65,6 +74,8 @@ internal class MobileSessionApi(
val json = JSONObject().put("foreground", state.foreground).put("screenAvailable", state.available) val json = JSONObject().put("foreground", state.foreground).put("screenAvailable", state.available)
.put("locationStatus", locationStatus) .put("locationStatus", locationStatus)
if (state.captureMode != CaptureMode.APP_CONTENT) json.put("captureMode", state.captureMode.name) if (state.captureMode != CaptureMode.APP_CONTENT) json.put("captureMode", state.captureMode.name)
// Omitted versions leave the enrolled values unchanged on the server (mobile-scanners 1.6); blank ones are never sent.
deviceInfo.forEach { (name, value) -> if (!value.isNullOrBlank()) json.put(name, value.take(200)) }
state.location.fix?.takeIf { locationAvailable }?.let { fix -> state.location.fix?.takeIf { locationAvailable }?.let { fix ->
json.put( json.put(
"location", JSONObject().put("latitude", fix.latitude).put("longitude", fix.longitude) "location", JSONObject().put("latitude", fix.latitude).put("longitude", fix.longitude)
@@ -311,7 +311,10 @@ class MobileSessionTest {
server.enqueue(MockResponse().setResponseCode(204)) server.enqueue(MockResponse().setResponseCode(204))
server.enqueue(MockResponse().setBody("{}")) server.enqueue(MockResponse().setBody("{}"))
val origin = server.url("/").toString() val origin = server.url("/").toString()
val api = MobileSessionApi(origin, { mapOf("X-QQQ-Device-Token" to "synthetic-token") }, UiApiCompatibility(origin, true)) val api = MobileSessionApi(
origin, { mapOf("X-QQQ-Device-Token" to "synthetic-token") }, UiApiCompatibility(origin, true),
deviceInfo = mapOf("osVersion" to "14", "appVersion" to "1.0.1", "manufacturer" to null, "model" to " ")
)
api.heartbeat("synthetic-session", ready()) api.heartbeat("synthetic-session", ready())
api.screen("synthetic-session", "lease-one", 4, frame) api.screen("synthetic-session", "lease-one", 4, frame)
api.end("synthetic-session", "SIGN_OUT") api.end("synthetic-session", "SIGN_OUT")
@@ -324,7 +327,10 @@ class MobileSessionTest {
val body = JSONObject(heartbeat.body.readUtf8()) val body = JSONObject(heartbeat.body.readUtf8())
assertTrue(body.getBoolean("screenAvailable")) assertTrue(body.getBoolean("screenAvailable"))
assertEquals("count", body.getString("processName")) assertEquals("count", body.getString("processName"))
listOf("user", "deviceId", "barcode", "sessionUUID", "password", "values").forEach { assertFalse(body.has(it)) } // Installed versions ride on every heartbeat so an in-place update refreshes the device record; blank/null ones are omitted.
assertEquals("14", body.getString("osVersion"))
assertEquals("1.0.1", body.getString("appVersion"))
listOf("user", "deviceId", "barcode", "sessionUUID", "password", "values", "manufacturer", "model").forEach { assertFalse(body.has(it)) }
val screen = server.takeRequest() val screen = server.takeRequest()
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/screen", screen.path) assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/screen", screen.path)
assertEquals(4, JSONObject(screen.body.readUtf8()).getLong("sequence")) assertEquals(4, JSONObject(screen.body.readUtf8()).getLong("sequence"))