From cf82dd5d78c64010df7cd4a8bafacd0de0fa05cc Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 10:40:01 -0600 Subject: [PATCH 1/2] fix(auth): run provider logout before host session end; show OAuth user name Company sign-out on the MC9400 (production APK, real Authentik) never opened the browser: QViewModel.performLogout ran the host beforeLogout hook (scanner device-session end, which runs the core logout server-side) before the provider logout request, so POST /qqq/v1/logout found no session and returned no nativeLogoutData. Company sign-out now requests the provider material first, then ends the host session (401 tolerated), then clears local state. Ordinary sign-out keeps the host-hook-first order. Browser login now adopts the server-reported values.user.name so Account no longer shows a blank current user. Adds QViewModelLogoutTest (ordering, failing host end, ordinary order, user name adoption, ManageSessionResponse.userName parsing) and documents the finding in AUTHENTICATION.md and SESSION-STATE.md. Co-Authored-By: Claude Fable 5.1 --- docs/AUTHENTICATION.md | 2 + docs/SESSION-STATE.md | 1 + .../authentication/ManageSessionResponse.kt | 5 + qqq/android/mobileapp/build.gradle.kts | 3 + .../android/mobileapp/viewmodel/QViewModel.kt | 34 ++- .../viewmodel/QViewModelLogoutTest.kt | 224 ++++++++++++++++++ 6 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModelLogoutTest.kt diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 7e81baa..460d581 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -67,6 +67,8 @@ Ordinary **Sign out** continues to end only the QQQ session. The scanner also ex 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":""}}` 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. +**Ordering (fix prepared 2026-11-05, not released):** for company sign-out the client sends the provider logout request *before* running the host's `beforeLogout` hook, then clears local state, then opens the browser. The scanner host's hook ends the device session (`POST /qbit/mobile-scanners/sessions/current/end`), which on the server runs the core logout and removes the QQQ session; running it first left no session for `POST /qqq/v1/logout` to describe, so `nativeLogoutData` was never returned and the browser never opened (observed on an MC9400 against a real Authentik fixture). The device-session end may now answer 401 because the login already ended; the client tolerates that and still clears local authentication. Ordinary sign-out is unchanged: host hook first, then the core logout. + The client clears QQQ session/private process state before opening AppAuth's end-session browser. It independently fetches discovery from the original login binding and checks the issuer, HTTPS end-session endpoint (without user information, query or fragment), and exact callback. A distinct in-memory, single-use state attempt binds completion to server, provider and endpoint. Login cannot start during company logout; cancellation, wrong state, reset or a late callback cannot restore a QQQ session. Browser cancellation or unavailable data explicitly reports incomplete company sign-out. Returning from the provider is protocol completion, not an independent claim about every upstream SSO session. The hint is deliberately held in transient ordinary classes with no generated value `toString`, never application preferences, ViewModel state, diagnostics, process values or application saved state. Parsing failures and HTTP diagnostics do not expose response bodies. AppAuth0.11.1 necessarily serializes its end-session request into Android Intents/saved Activity state, and the browser receives the hint in a URL query. This is not a zero-persistence guarantee. Do not enable URI/body logging, browser traces or proxy access logs containing query strings. AppAuth's warning on a state mismatch can contain state values; do not treat SDK debug output as a safe diagnostics feed. Native synthetic tests include a hint logcat/preferences canary; real-provider and hardware qualification are separate gates. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index c1eaf76..650c221 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -10,6 +10,7 @@ As of **2026-11-05**. Scope: scanner host and maintained local Echo workflow. [T - **Network:** the local QQQ server stays on a PC, reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is not a customer production deployment; customer instructions require a customer HTTPS URL with a trusted certificate. TLS is not weakened; no hostnames, IPs, ports, device/vault IDs, private paths or passwords are published. - **Server/Authentik:** real shared Authentik server discovery verified (UI API 1.10, strict nonce, scanner and printing channels/extensions/callbacks). Production-APK native login/logout/account switching is **open**, awaiting the MC wireless-debug endpoint; no native-Authentik PASS is claimed. Print nonce fix PR 11 (merged `abc3d0d`, 532 hosted tests passed) postdates published print 1.1.1; the next installer remains with the active printing owner. - **Photo policy:** live. A signed MC9400 gallery synthetic PNG became a Saved photo, `AVAILABLE`, with exact parent/type/size/hash; the previous three photos were released normally. The app has no admission policy; the server receipt controls the message. The root is running the updated disposable TC22 E2E; no full new-preview or native PASS is claimed until it completes. +- **Company sign-out bug (2026-11-05):** on the physical MC9400 with the production APK against the real Authentik fixture, **Sign out of company** never opened the browser and reported "Company sign-out did not complete". Cause: `QViewModel.performLogout` ran the host `beforeLogout` hook (scanner device-session end, which runs the core logout server-side) before the provider logout request, so `nativeLogoutData` was never returned. A fix reordering provider logout before the host hook, plus adoption of the server-reported user name after browser login (Account showed a blank "Current User"), is prepared on branch `fix/company-signout-before-session-end` with unit tests; it is **not released** and no new APK is built. See [AUTHENTICATION](AUTHENTICATION.md#optional-company-sign-out-implementation-awaiting-qualification). - **Open:** public compatible Core/Collab/document/files/scripting/bridges, final public cohort gate, GS1 physical print/rescan, full release-asset publication/distribution. Company profiles/wallpaper are tracked separately; signing CI is optional and not blocking a direct APK release. Release authorizations already exist. Statements below were written before these facts and are historical where they conflict (for example "debug update", MC9400 trigger check "outstanding", signed-build HTTPS qualification "open"). diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/ManageSessionResponse.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/ManageSessionResponse.kt index d0d3397..e97b812 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/ManageSessionResponse.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/ManageSessionResponse.kt @@ -31,4 +31,9 @@ data class ManageSessionResponse( val uuid: String? = null, val values: MapOfStringAny = emptyMap(), ) { + /******************************************************************************* + ** Display name from the optional values.user object; absent, non-object or blank names yield null. + *******************************************************************************/ + val userName: String? + get() = ((values["user"] as? Map<*, *>)?.get("name") as? String)?.takeIf { it.isNotBlank() } } diff --git a/qqq/android/mobileapp/build.gradle.kts b/qqq/android/mobileapp/build.gradle.kts index d64a319..2e1548e 100644 --- a/qqq/android/mobileapp/build.gradle.kts +++ b/qqq/android/mobileapp/build.gradle.kts @@ -72,6 +72,9 @@ android { kotlinOptions { jvmTarget = "1.8" } + testOptions { + unitTests.isReturnDefaultValues = true + } } dependencies { diff --git a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt index 7f8ebc9..291b1c7 100644 --- a/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt +++ b/qqq/android/mobileapp/src/main/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModel.kt @@ -582,8 +582,23 @@ open class QViewModel( *******************************************************************************/ fun logOut(): Job = performLogout() + /******************************************************************************* + ** Host session resources end in a bounded, best-effort step; core logout and local clearing never depend on it. + *******************************************************************************/ + private suspend fun endHostSession() { + try { + kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() } + } catch (exception: kotlinx.coroutines.CancellationException) { + throw exception + } catch (exception: Exception) { /* Core logout and local clearing still run. */ + } + } + /******************************************************************************* ** Optional provider material is captured only by the initiating coroutine, never by ViewModel state. + ** Ordinary logout ends host resources first. Provider logout runs first instead: it both returns the + ** transient hint and ends the QQQ session, and a host session end (such as the scanner device-session end, + ** which itself runs the core logout server-side) would otherwise remove that session before the hint is read. *******************************************************************************/ private fun performLogout(providerLogout: (suspend () -> Unit)? = null): Job { logoutJob?.takeIf { @@ -606,16 +621,15 @@ open class QViewModel( var serverConfirmed = false try { settingsMutex.withLock { settingsRepository.setSessionUUID(""); settingsRepository.setSessionUserFullName("") } + if (providerLogout == null) endHostSession() try { - kotlinx.coroutines.withTimeoutOrNull(3000) { beforeLogout() } - } catch (exception: kotlinx.coroutines.CancellationException) { - throw exception - } catch (exception: Exception) { /* Core logout and local clearing still run. */ + serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) { + if (providerLogout == null) qqqRepository.logout() else providerLogout() + true + } ?: false + } finally { + if (providerLogout != null) endHostSession() } - serverConfirmed = kotlinx.coroutines.withTimeoutOrNull(3000) { - if (providerLogout == null) qqqRepository.logout() else providerLogout() - true - } ?: false } catch (exception: kotlinx.coroutines.CancellationException) { throw exception } catch (exception: Exception) { /* Local sign-out still completes when the network is unavailable. */ @@ -672,7 +686,8 @@ open class QViewModel( binding.login.server == environment.baseUrl && isLogoutRequested /******************************************************************************* - ** Local invalidation finishes before the browser sees the optional transient hint. + ** Local invalidation finishes before the browser sees the optional transient hint: the provider logout + ** request runs inside performLogout, whose completion clears local state before this returns. *******************************************************************************/ suspend fun logOutWithProvider(generation: Long, binding: OAuth2LogoutBinding): NativeLogoutData? { if (!isCurrentCompanyLogout(generation, binding) || companyLogoutPhase != OAuth2LogoutPhase.PREPARING) return null @@ -846,6 +861,7 @@ open class QViewModel( if (!isCurrentOAuth2Login(generation, binding)) return@launch sessionUUID = requireNotNull(newSession) qqqRepository.setSessionUUID(sessionUUID) + response.userName?.let { doSetSessionUserFullName(it) } manageSessionLoadState = LoadState.Success(response) logInSuccessful() adopted = true diff --git a/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModelLogoutTest.kt b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModelLogoutTest.kt new file mode 100644 index 0000000..f942eda --- /dev/null +++ b/qqq/android/mobileapp/src/test/java/com/kingsrook/qqq/frontend/android/mobileapp/viewmodel/QViewModelLogoutTest.kt @@ -0,0 +1,224 @@ +/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */ +package com.kingsrook.qqq.frontend.android.mobileapp.viewmodel + +import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository +import com.kingsrook.qqq.frontend.android.core.model.Environment +import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance +import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.BaseAuthenticationMetaData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionRequest +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.ManageSessionResponse +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.NativeLogoutData +import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.OAuth2AuthenticationMetaData +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.OAuth2LoginBinding +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutBinding +import com.kingsrook.qqq.frontend.android.mobileapp.ui.authentication.OAuth2LogoutPhase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val SERVER = "https://example.invalid" +private const val ISSUER = "https://idp.example.invalid" +private const val REDIRECT = "io.qrun.test.oauth://callback/oauth2redirect" +private const val STATE = "state-0123456789abcdef" +private const val NONCE = "nonce-0123456789abcdef" +private const val VERIFIER = "verifier-0123456789abcdefghijklmnopqrstuvwxyz0123456789" + +/******************************************************************************* + ** Ordering of host session end, provider logout and local clearing; identity adoption after browser login. + *******************************************************************************/ +@OptIn(ExperimentalCoroutinesApi::class) +class QViewModelLogoutTest { + /******************************************************************************* + ** (a) Provider logout must read the hint before the host ends its device session (which ends the QQQ session). + *******************************************************************************/ + @Test + fun companyLogoutRequestsProviderMaterialBeforeHostSessionEnd() = runTest() + { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val repository = LogoutRepository() + val model = signedInModel(repository) + model.beforeLogout = { repository.calls += "beforeLogout" } + val material = companyLogout(model) + assertNotNull(material) + assertEquals(listOf("logoutWithProvider", "beforeLogout"), repository.calls) + assertEquals("Signed out.", model.logoutMessage) + assertFalse(model.isAuthenticated) + assertEquals("", model.sessionUUID) + assertEquals("", model.sessionUserFullName) + } finally { + Dispatchers.resetMain() + } + } + + /******************************************************************************* + ** (b) A failing host session end after the provider logout neither loses the hint nor leaves local state. + *******************************************************************************/ + @Test + fun failingHostSessionEndAfterProviderLogoutKeepsMaterialAndClearsLocally() = runTest() + { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val repository = LogoutRepository() + val model = signedInModel(repository) + model.beforeLogout = { repository.calls += "beforeLogout"; throw IllegalStateException("401 Unauthorized") } + val material = companyLogout(model) + assertNotNull(material) + assertEquals(listOf("logoutWithProvider", "beforeLogout"), repository.calls) + assertEquals("Signed out.", model.logoutMessage) + assertFalse(model.isAuthenticated) + assertFalse(model.logoutInProgress) + assertEquals("", model.sessionUUID) + assertEquals("", repository.activeSession) + } finally { + Dispatchers.resetMain() + } + } + + /******************************************************************************* + ** (c) Ordinary sign-out keeps ending host resources before the core logout. + *******************************************************************************/ + @Test + fun ordinaryLogoutEndsHostSessionBeforeCoreLogout() = runTest() + { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val repository = LogoutRepository() + val model = signedInModel(repository) + model.beforeLogout = { repository.calls += "beforeLogout" } + model.requestLogout() + model.logOut().join() + assertEquals(listOf("beforeLogout", "logout"), repository.calls) + assertEquals("Signed out.", model.logoutMessage) + assertFalse(model.isAuthenticated) + assertEquals("", model.sessionUUID) + } finally { + Dispatchers.resetMain() + } + } + + /******************************************************************************* + ** Browser login adopts the server-reported display name, as password login already does. + *******************************************************************************/ + @Test + fun browserLoginAdoptsServerReportedUserName() = runTest() + { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val repository = LogoutRepository() + val model = signedInModel(repository) + assertTrue(model.isAuthenticated) + assertEquals("scanner-b", model.sessionUserFullName) + } finally { + Dispatchers.resetMain() + } + } + + /******************************************************************************* + ** The user object is optional and unknown keys stay tolerated; blank or non-object users yield no name. + *******************************************************************************/ + @Test + fun manageSessionResponseExposesOptionalUserName() { + val json = Json { ignoreUnknownKeys = true } + val full = json.decodeFromString( + """{"uuid":"s","values":{"user":{"name":"scanner-b","email":"scanner-b@example.invalid","extra":1},"other":true},"unknown":"x"}""" + ) + assertEquals("scanner-b", full.userName) + assertNull(json.decodeFromString("""{"uuid":"s"}""").userName) + assertNull(json.decodeFromString("""{"uuid":"s","values":{"user":"scanner-b"}}""").userName) + assertNull(json.decodeFromString("""{"uuid":"s","values":{"user":{"name":" "}}}""").userName) + } + + /******************************************************************************* + ** Complete a native browser login against the fake repository so sign-out tests start authenticated. + *******************************************************************************/ + private fun TestScope.signedInModel(repository: LogoutRepository): QViewModel { + QAppContainer.availableEnvironments = listOf(Environment("Test", SERVER)) + val model = QViewModel(repository, MockPreferencesDataStore()) + advanceUntilIdle() + val generation = requireNotNull(model.prepareOAuth2Login(loginBinding)) + assertTrue(model.beginOAuth2Browser(generation, loginBinding, STATE, NONCE, VERIFIER)) + model.completeOAuth2Login(loginBinding, STATE, STATE, NONCE, "client", REDIRECT, "login", "code") + advanceUntilIdle() + assertTrue(model.isAuthenticated) + assertEquals("session-1", model.sessionUUID) + repository.calls.clear() + return model + } + + /******************************************************************************* + ** Run the company sign-out preparation exactly as QOAuth2Logout does before opening the browser. + *******************************************************************************/ + private suspend fun companyLogout(model: QViewModel): NativeLogoutData? { + val binding = OAuth2LogoutBinding(loginBinding, REDIRECT) + model.requestCompanyLogout(binding) + assertEquals(OAuth2LogoutPhase.PREPARING, model.companyLogoutPhase) + return model.logOutWithProvider(model.companyLogoutGeneration, binding) + } + + private val loginBinding = OAuth2LoginBinding( + SERVER, "oauth", ISSUER, "$ISSUER/.well-known/openid-configuration", "client", "openid", REDIRECT, "login", "REQUIRED" + ) + + /******************************************************************************* + ** Records the order of logout-related calls; any call after the session ended returns no provider material. + *******************************************************************************/ + private class LogoutRepository : QQQRepository { + val calls = mutableListOf() + var activeSession = "" + + override suspend fun getAuthenticationMetaData(): BaseAuthenticationMetaData = OAuth2AuthenticationMetaData( + "oauth", values = OAuth2AuthenticationMetaData.Values( + clientId = "client", scopes = "openid", + nativeSessionLogin = OAuth2AuthenticationMetaData.NativeSessionLogin( + OAuth2AuthenticationMetaData.SESSION_FLOW, "S256", ISSUER, "$ISSUER/.well-known/openid-configuration", listOf(REDIRECT), "login", "REQUIRED" + ), + nativeSessionLogout = OAuth2AuthenticationMetaData.NativeSessionLogout(OAuth2AuthenticationMetaData.LOGOUT_FLOW, listOf(REDIRECT)) + ) + ) + + override suspend fun exchangeNativeSession(body: ManageSessionRequest): ManageSessionResponse = + ManageSessionResponse("session-1", mapOf("user" to mapOf("name" to "scanner-b", "email" to "scanner-b@example.invalid"))) + + override suspend fun logout() { + calls += "logout" + activeSession = "" + } + + override suspend fun logoutWithProvider(postLogoutRedirectUri: String): NativeLogoutData? { + calls += "logoutWithProvider" + val material = if (activeSession.isBlank()) null else NativeLogoutData(ISSUER, "$ISSUER/end-session", postLogoutRedirectUri, "hint") + activeSession = "" + return material + } + + override fun setSessionUUID(sessionUUID: String) { + activeSession = sessionUUID + } + + 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 getProcessMetaData(processName: String): QProcessMetaData = error("Not used") + override suspend fun processInit(processName: String, values: Map, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used") + override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map, stepTimeoutMillis: Int?): ProcessStepResult = error("Not used") + override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult = error("Not used") + override fun getURI(path: String): String = error("Not used") + } +} -- 2.54.0 From 76bf8e4405682b9468f4a83a3424d0ffe53f88ca Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 10:45:24 -0600 Subject: [PATCH 2/2] release(scanner): prepare 1.0.2 (versionCode 4) notes and build instructions Supersedes the unpublished signed 1.0.1 (versionCode 3) candidate. The version is passed at build time (-PscannerVersionName=1.0.2 -PscannerVersionCode=4); the docs now name that pair everywhere 1.0.1 / 3 was the next build. Release notes in CHANGELOG.md: company sign-out now completes (device-session end ran before provider logout), user name shown after browser sign-in, app/OS version reported on heartbeat (effective with mobile-scanners server 1.6.0). Nothing is built, signed, tagged or published. This branch carries the local merges of PRs 17 and 18 and must be rebased after they merge. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 ++++++++ README.md | 2 +- docs/INSTALLATION.md | 2 +- docs/RELEASING.md | 17 ++++++++++++++++- docs/SESSION-STATE.md | 12 ++++++++---- docs/TODO.md | 2 +- 6 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80aa47a..5f9f49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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. + +- Company sign-out now completes: the provider logout request runs before the scanner host ends its device session, so the browser end-session page opens. Previously the device-session end ran first, removed the QQQ session server-side, and `nativeLogoutData` was never returned; observed on the MC9400 with the production APK against the real Authentik server. (PR 17) +- The signed-in user's name is shown after browser (OAuth) sign-in instead of a blank current user. (PR 17) +- The scanner host reports the installed app and OS version on every session heartbeat, so scan receipts show the new version after an in-place update. Effective with mobile-scanners server 1.6.0; older servers ignore the keys. (PR 18) + ## Unreleased - Add the QQQ Mobile scanner host for metadata-defined native apps and processes. diff --git a/README.md b/README.md index 4c95baf..d9bf3dd 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ See [emulator testing](docs/EMULATOR.md) for an emulator demo, synthetic scans a ### Signed APK (direct install / MDM) -Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). The published download remains the [signed 1.0.0 preview](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease, forge sign-in required). The signed 1.0.1 candidate has passed trusted-HTTPS enrollment, physical scanning, navigation and an in-place update on the MC9400, preserving login and enrollment. Its publication, native Authentik qualification and final dependency-cohort checks remain open. Development-signed installations require an explicit migration to the production signer. +Requires an **HTTPS** server URL and the production-signed APK built with the QRun-owned key. Follow the [installation guide](docs/INSTALLATION.md) and [release readiness](docs/RELEASING.md). The published download remains the [signed 1.0.0 preview](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0) (prerelease, forge sign-in required). The signed 1.0.1 candidate has passed trusted-HTTPS enrollment, physical scanning, navigation and an in-place update on the MC9400, preserving login and enrollment. Its publication, native Authentik qualification and final dependency-cohort checks remain open; a 1.0.2 (versionCode 4) candidate with company sign-out, user-name and heartbeat-version fixes is prepared but not yet built (see [CHANGELOG](CHANGELOG.md)). Development-signed installations require an explicit migration to the production signer. ## Build and test diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 3096137..b9f7107 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -4,7 +4,7 @@ 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. When 1.0.1 is published, use the file, checksum and link given by the release owner, and verify the same signer. +**Current release status (November 5, 2026):** the only published release is the signed preview 1.0.0 below. A signed **1.0.1 (versionCode 3) candidate** exists but is **not published and has no download link**; this guide does not point to one. Use the table below for what is actually hosted. It is superseded by the planned **1.0.2 (versionCode 4)** candidate (not yet built; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4)). When a newer version is published, use the file, checksum and link given by the release owner, and verify the same signer. | Candidate (not published) | Value | | --- | --- | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 53e1f7c..7a9a5ca 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -8,8 +8,23 @@ - **Candidate APK:** SHA-256 `5124716B59CAD303717E7834FE64DDC1537E33A25C9AB75A6EA1ACF03AC2717F`, 15015419 bytes. - **Source:** Android PR 15 `ab6e44c`, merged as `c782aaaaba3f62422232436a9000af9d4b38fc82` with the same app-source tree. Installed APK bytes, signature, non-debuggable flag and 16 KiB alignment were verified. `lintRelease` reported 0 errors, 0 warnings and 5 informational findings; the release build passed. - **Distribution:** maintainers use the existing manual signing path for direct APK/MDM distribution. Automated signing CI is optional. +- **Next candidate:** **1.0.2 (versionCode 4)** is prepared but not built; see [Next candidate](#next-candidate-102-versioncode-4). - **Open:** see [Qualification status](#qualification-status) and [TODO](TODO.md). +## Next candidate: 1.0.2 (versionCode 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). + +Build only after PRs 17 and 18 are merged to `main` and this branch has been rebased onto that `main`, from the exact merged commit, with no `SCANNER_*` signing variables set: + +``` +./gradlew --no-daemon --console=plain :scanner-host:lintRelease :scanner-host:assembleRelease -PscannerVersionName=1.0.2 -PscannerVersionCode=4 +``` + +Then sign `scanner-host/build/outputs/apk/release/scanner-host-release-unsigned.apk` in isolation with `apksigner` using the existing permanent key (certificate SHA-256 `BDDB2D2F...5B17A9FF`), verify v2+v3 with exactly one signer, 16 KiB alignment, `versionCode='4' versionName='1.0.2'` and non-debuggable, and write `QQQ-Mobile-1.0.2.apk`, `SHA256SUMS`, `signature-verification.txt`, `apk-metadata.txt` and `release-receipt.json` next to each other as for 1.0.1. Record the resulting hashes here, in [INSTALLATION](INSTALLATION.md) and in [SESSION-STATE](SESSION-STATE.md) before any tag or release. + ## Historical status (November 4, 2026, superseded where noted) Dated evidence below is preserved. As of November 4: a QRun-owned production signing key existed with a verified backup, and the signed 1.0.0 (versionCode 2) APK was published as a signed preview: [scanner-v1.0.0](https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0), release ID 1361, tag on protected `main` `3eef6431ca39b6450cf764424d921a02b2afb9ab`. It had been installed and launched once on a fresh MC9400; at that time enrollment, scanning and update were unqualified. *(Superseded November 5: the MC9400 was migrated to the signed build; see below. The statement that the MC9400 ran the development-signed debug APK is no longer current. The TC22 still runs its original development-signed installation.)* Manual TC22 acceptance was reported all-pass by the operator on November 4 (operator report, no per-case artifacts) for the development-signed build; it does not qualify the production signer and is not rerun. @@ -65,7 +80,7 @@ Open: 2. **Printing:** the print nonce fix (PR 11, merged `abc3d0d`, 532 hosted tests passed) postdates the published print 1.1.1; the next installer remains with the active printing owner. 3. Public compatible Core/Collab/document/files/scripting/bridges, the final public cohort gate, GS1 physical print/rescan, and full release-asset publication and distribution. 4. Company profiles and wallpaper are tracked separately. -5. Publish the 1.0.1 tag, release, APK, checksum and installation instructions after its remaining qualification. The published download remains the 1.0.0 signed preview; automated signing CI is optional. +5. Build, sign and publish 1.0.2 (versionCode 4) in place of the unpublished 1.0.1 candidate after PRs 17 and 18 merge (see [Next candidate](#next-candidate-102-versioncode-4)), with its checksum and installation instructions after remaining qualification. The published download remains the 1.0.0 signed preview; automated signing CI is optional. ## Release requirements diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index eb0fe4e..e8e7186 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,10 +1,14 @@ # Current status -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-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. -## Open fix (2026-11-06, uncommitted branch `fix/refresh-device-versions`) +## Release candidate 1.0.2 (versionCode 4), 2026-11-06 -- 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. +- 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. + +## Heartbeat version fix (2026-11-06, Android PR 18) + +- 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.0 on the same-named server branch. Older servers ignore the keys; the installed 1.0.1 APK does not include this change (planned for 1.0.2). ## Release facts (2026-11-05) @@ -14,7 +18,7 @@ As of **2026-11-05**. Scope: scanner host and maintained local Echo workflow. [T - **Network:** the local QQQ server stays on a PC, reached over trusted HTTPS temporarily through a user-approved private Tailscale network. This is not a customer production deployment; customer instructions require a customer HTTPS URL with a trusted certificate. TLS is not weakened; no hostnames, IPs, ports, device/vault IDs, private paths or passwords are published. - **Server/Authentik:** real shared Authentik server discovery verified (UI API 1.10, strict nonce, scanner and printing channels/extensions/callbacks). Production-APK native login/logout/account switching is **open**, awaiting the MC wireless-debug endpoint; no native-Authentik PASS is claimed. Print nonce fix PR 11 (merged `abc3d0d`, 532 hosted tests passed) postdates published print 1.1.1; the next installer remains with the active printing owner. - **Photo policy:** live. A signed MC9400 gallery synthetic PNG became a Saved photo, `AVAILABLE`, with exact parent/type/size/hash; the previous three photos were released normally. The app has no admission policy; the server receipt controls the message. The root is running the updated disposable TC22 E2E; no full new-preview or native PASS is claimed until it completes. -- **Company sign-out bug (2026-11-05):** on the physical MC9400 with the production APK against the real Authentik fixture, **Sign out of company** never opened the browser and reported "Company sign-out did not complete". Cause: `QViewModel.performLogout` ran the host `beforeLogout` hook (scanner device-session end, which runs the core logout server-side) before the provider logout request, so `nativeLogoutData` was never returned. A fix reordering provider logout before the host hook, plus adoption of the server-reported user name after browser login (Account showed a blank "Current User"), is prepared on branch `fix/company-signout-before-session-end` with unit tests; it is **not released** and no new APK is built. See [AUTHENTICATION](AUTHENTICATION.md#optional-company-sign-out-implementation-awaiting-qualification). +- **Company sign-out bug (2026-11-05):** on the physical MC9400 with the production APK against the real Authentik fixture, **Sign out of company** never opened the browser and reported "Company sign-out did not complete". Cause: `QViewModel.performLogout` ran the host `beforeLogout` hook (scanner device-session end, which runs the core logout server-side) before the provider logout request, so `nativeLogoutData` was never returned. A fix reordering provider logout before the host hook, plus adoption of the server-reported user name after browser login (Account showed a blank "Current User"), is Android PR 17 (`cf82dd5`) with unit tests; it is **not released** and no new APK is built (planned for 1.0.2, versionCode 4). See [AUTHENTICATION](AUTHENTICATION.md#optional-company-sign-out-implementation-awaiting-qualification). - **Open:** public compatible Core/Collab/document/files/scripting/bridges, final public cohort gate, GS1 physical print/rescan, full release-asset publication/distribution. Company profiles/wallpaper are tracked separately; signing CI is optional and not blocking a direct APK release. Release authorizations already exist. Statements below were written before these facts and are historical where they conflict (for example "debug update", MC9400 trigger check "outstanding", signed-build HTTPS qualification "open"). diff --git a/docs/TODO.md b/docs/TODO.md index 019cb29..4ded376 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,7 +6,7 @@ Open work only, as of **2026-11-05**. Current evidence: [SESSION-STATE](SESSION- - [ ] Qualify actual GS1 label media and physical printed-label rescan. Synthetic capture/render evidence does not close this check. - [ ] Verify actual production-APK native Authentik login, logout and account switching; awaiting the MC wireless-debug endpoint. Real-server discovery (UI API 1.10, strict nonce, scanner and printing channels) is already verified; no native-Authentik PASS is claimed. -- [ ] Publish signed candidate 1.0.1 (versionCode 3, APK SHA-256 `5124716B...AC2717F`, same permanent signer), checksum and installation instructions after remaining qualification. The published release is still the 1.0.0 signed preview. +- [ ] After Android PRs 17 and 18 merge, rebase `release/scanner-1.0.2`, then build, sign (same permanent signer) and publish 1.0.2 (versionCode 4) with checksum and installation instructions after remaining qualification; see [RELEASING](RELEASING.md#next-candidate-102-versioncode-4). The signed 1.0.1 (versionCode 3) candidate is superseded and stays unpublished. The published release is still the 1.0.0 signed preview. - [ ] Publish and distribute full release assets to remaining devices; validate MDM/company profile and wallpaper separately. A customer deployment needs a customer HTTPS URL with a trusted certificate. - [ ] Printing: the next installer remains with the active printing owner (print nonce fix PR 11 postdates published print 1.1.1). - [ ] Optional, non-blocking: automatic APK signing CI. Direct APK release does not depend on it. -- 2.54.0