fix(scanner): report osVersion and appVersion on every heartbeat #18

Merged
james.maes merged 1 commits from fix/refresh-device-versions into main 2026-11-06 23:23:24 +00:00
4 changed files with 24 additions and 3 deletions

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.
## 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)
- **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
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.
@@ -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.UiApiCompatibility
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiException
import android.os.Build
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
@@ -52,7 +53,15 @@ internal class MobileSessionApi(
private val headers: () -> Map<String, String>,
private val compatibility: UiApiCompatibility,
onOutcome: (RequestOutcome) -> Unit = {},
private val deviceInfo: Map<String, String?> = installedVersions(),
) : 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)
.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)
.put("locationStatus", locationStatus)
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 ->
json.put(
"location", JSONObject().put("latitude", fix.latitude).put("longitude", fix.longitude)
@@ -311,7 +311,10 @@ class MobileSessionTest {
server.enqueue(MockResponse().setResponseCode(204))
server.enqueue(MockResponse().setBody("{}"))
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.screen("synthetic-session", "lease-one", 4, frame)
api.end("synthetic-session", "SIGN_OUT")
@@ -324,7 +327,10 @@ class MobileSessionTest {
val body = JSONObject(heartbeat.body.readUtf8())
assertTrue(body.getBoolean("screenAvailable"))
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()
assertEquals("/qqq/v1/qbit/mobile-scanners/sessions/current/screen", screen.path)
assertEquals(4, JSONObject(screen.body.readUtf8()).getLong("sequence"))