Compare commits

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

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 12:38:26 -06:00
james.maes 9bae976138 Merge pull request 'fix(auth): end the identity-provider session on sign-out and force re-authentication on sign-in' (#27) from fix/signout-idp-session into main
android / android (push) Failing after 3m53s
build / android (push) Failing after 3m53s
2026-11-09 20:10:27 +00:00
james.maesandClaude Sonnet 5.5 b87171cafc fix(auth): end the identity-provider session on sign-out and force re-authentication on sign-in
android / android (push) Failing after 3m53s
build / android (push) Failing after 3m53s
Ordinary Sign out cleared only the QQQ session; the provider session left in the
browser silently signed the same person back in (prompt=login alone was already
observed reusing retained SSO on real Authentik). Sign out now runs the provider
end-session flow when the server supports it, and every sign-in also sends
max_age=0. Stored-session restore after sign-out was checked: not a cause.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:10:00 -06:00
james.maes 3b15183f52 Merge pull request 'fix(core): allow 60 s for the metadata load and name the URL on timeout' (#26) from fix/metadata-load-timeout into main
android / android (push) Failing after 3m47s
build / android (push) Failing after 3m47s
2026-11-09 20:01:50 +00:00
james.maes b165dd34a8 Merge pull request 'fix(android): accept string widget icons and summary-only help content' (#24) from fix/widget-icon-string-main into main
android / android (push) Failing after 3m28s
build / android (push) Failing after 3m28s
2026-11-09 20:01:24 +00:00
james.maesandClaude Sonnet 5.5 4467d8ec64 fix(core): allow 60 s for the metadata load and name the URL on timeout
android / android (push) Successful in 15m7s
build / android (push) Successful in 15m7s
GET /qqq/v1/metaData returns about 1.7 MB and takes the server 4 to 8 s to
build; the default 10 s read timeout surfaced as 'Error loading QInstance:
timeout' on handhelds. Only the metadata call gets the long limit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:01:07 -06:00
14 changed files with 419 additions and 29 deletions

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 1.0.4 (versionCode 6)
- 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.
+1 -1
View File
@@ -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.
@@ -44,18 +44,30 @@ 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 for a
** platform administrator on the staging server) and the server needs 4 to 8 seconds to build it, so the default
** 10 second OkHttp read timeout expired on handhelds. Only this one call gets the long limit; every other call
** keeps the short defaults.
*******************************************************************************/
const val METADATA_LOAD_TIMEOUT_SECONDS = 60L
/*******************************************************************************
*******************************************************************************/
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()
/*******************************************************************************
*******************************************************************************/
@@ -63,9 +75,13 @@ 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)
}
// retrofit = buildRetrofit()
@@ -74,10 +90,19 @@ 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 default timeouts.
*******************************************************************************/
private fun buildClient(baseUrl: String): OkHttpClient {
return (
OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
@@ -110,8 +135,6 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
.addInterceptor(SameOriginInterceptor())
.build()
)
.baseUrl(baseUrl)
.build()
}
/*******************************************************************************
@@ -309,7 +332,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)
}
}
/*******************************************************************************
@@ -20,7 +20,9 @@ data class OAuth2AuthenticationMetaData(
val clientId: String = "", val baseUrl: String = "", val scopes: String = "",
val nativeClientId: String? = null, val nativeClientBaseUrl: String? = null,
val bearerTokensAccepted: Boolean = false, val nativeSessionLogin: NativeSessionLogin? = null,
val nativeSessionLogout: NativeSessionLogout? = null
val nativeSessionLogout: NativeSessionLogout? = null,
// Automatic logoff (UI API 1.12.0): seconds without a user action after which the server ends the session.
val userInactivityTimeoutSeconds: Int? = null
)
/*******************************************************************************
@@ -39,6 +39,8 @@ class NativeSessionLoginTest {
assertEquals("login", metadata.values.nativeSessionLogin!!.prompt)
assertEquals("REQUIRED", metadata.values.nativeSessionLogin!!.nonceValidation)
assertNull((Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.nativeSessionLogin)
assertNull((Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.userInactivityTimeoutSeconds)
assertEquals(900, (Json.decodeFromString<BaseAuthenticationMetaData>("""{"type":"OAUTH2","name":"logoff","values":{"clientId":"server-client","userInactivityTimeoutSeconds":900}}""") as OAuth2AuthenticationMetaData).values.userInactivityTimeoutSeconds)
val wire = Json.encodeToString(request)
assertTrue(wire.contains("\"authenticationFlow\":\"AUTHORIZATION_CODE_PKCE_SESSION\""))
assertTrue(wire.contains("\"expectedNonce\":\"original-nonce-canary-1234\""))
@@ -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("?"))
}
}
/*******************************************************************************
** Only metadata gets the long limit: other calls keep the short default and still fail fast.
*******************************************************************************/
@Test
fun otherCallsStillUseTheShortDefaultTimeout() = runBlocking() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setHeadersDelay(12, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"records":[]}"""))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin))
val failure = runCatching { repository.queryRecords("items", "id", 0, 10) }.exceptionOrNull()
assertTrue(failure is java.io.InterruptedIOException)
}
}
}
@@ -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.
@@ -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),
@@ -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") } })
}
@@ -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")
@@ -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
@@ -135,6 +136,13 @@ class ScannerActivity : ComponentActivity() {
watchingManagedConfiguration = false
}
/*******************************************************************************
** Every touch and key dispatched to this activity is a user action for the server's inactivity timeout.
*******************************************************************************/
override fun onUserInteraction() {
super.onUserInteraction(); UserActivity.record()
}
/*******************************************************************************
** Reflect focus changes so obscured windows cannot keep accepting scanner input.
*******************************************************************************/
@@ -230,7 +238,7 @@ class ScannerActivity : ComponentActivity() {
val hash = MessageDigest.getInstance("SHA-256").digest(server.toByteArray()).joinToString("") { "%02x".format(it) }
val dataStore = storesByServer.getOrPut(server) { PreferenceDataStoreFactory.create { File(noBackupFilesDir, "session-$hash.preferences_pb") } }
val repository = NetworkQQQRepository(server, requestObserver = diagnostics::nativeRequest, compatibility = compatibility) { requestedServer ->
if (requestedServer == server) credentials.headers() else emptyMap()
if (requestedServer == server) credentials.headers() + UserActivity.header() else emptyMap()
}
@Suppress("UNCHECKED_CAST")
return QViewModel(repository, dataStore).apply { supportedCustomWidgetTypes = setOf("SCANNER", "RECORD_FILES_CAPTURE") } as T
@@ -250,7 +258,9 @@ class ScannerActivity : ComponentActivity() {
return
}
val api = remember(server, diagnostics) { DeviceApi(server, credentials, diagnostics::deviceRequest, compatibility) }
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, credentials::headers, compatibility, diagnostics::nativeRequest) }
// Every request of the session states the age of the operator's last action; heartbeats alone keep nothing alive.
val sessionHeaders = remember(server) { { credentials.headers() + UserActivity.header() } }
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, sessionHeaders, compatibility, diagnostics::nativeRequest) }
var approved by remember(qViewModel.sessionUUID) { mutableStateOf(false) }
val location = rememberForegroundLocation(resumed && approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested)
val currentScreen = sessionScreen(qViewModel, resumed, focused, approved, captureBounds != null)
@@ -272,7 +282,7 @@ class ScannerActivity : ComponentActivity() {
else WallpaperStatus.store.signedOutIfSignedIn()
}
DisposableEffect(server) { onDispose { WallpaperStatus.store.clear() } }
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, credentials::headers, compatibility, diagnostics::sessionRequest) }
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, sessionHeaders, compatibility, diagnostics::sessionRequest) }
val deviceSharing = remember(server, qViewModel.sessionUUID) { DeviceScreenSharing(applicationContext) }
val sessions = remember(sessionApi, qViewModel.sessionUUID)
{
@@ -309,6 +319,10 @@ class ScannerActivity : ComponentActivity() {
{
if (approved && qViewModel.isAuthenticated && !qViewModel.isLogoutRequested) sessions.run()
}
InactivityLogoff(
((authentication as? LoadState.Success)?.value as? OAuth2AuthenticationMetaData)?.values?.userInactivityTimeoutSeconds,
qViewModel.isAuthenticated && !qViewModel.isLogoutRequested
) { qViewModel.logOut() }
CompositionLocalProvider(
LocalCompanySignOutEnabled provides true,
LocalBusinessNavigationEnabled provides approved,
@@ -405,6 +419,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 +459,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") }
}
}
@@ -118,7 +118,8 @@ internal fun ScannerWidget(
{
ScannerInputSession(
{ driverFactory(context) },
{ capture -> currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
// A hardware scan is a user action even when no key event reaches the activity.
{ capture -> UserActivity.record(); currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
{ message, reconnect -> scannerStatus = message; canReconnect = reconnect },
canAcceptInput = { currentInputReady() })
}
@@ -0,0 +1,104 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.os.SystemClock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
/*******************************************************************************
** Automatic logoff (UI API 1.12.0). A request is not a user action: this app sends heartbeats. When the
** server has a user inactivity timeout, only what the operator does (a touch, a key, a scan) keeps the
** session alive. This object remembers when that last happened and states its age on every session request.
**
** The time base counts device sleep, so a handheld left in a cradle ages while its screen is off. Until the
** first user action of this process the age is the device's uptime: nothing is assumed in the user's favour.
*******************************************************************************/
object UserActivity {
const val HEADER = "X-QQQ-User-Activity-Age"
const val WARNING_SECONDS = 60
@Volatile
internal var clock: () -> Long = { SystemClock.elapsedRealtime() }
@Volatile
private var lastActionAt = 0L
/*******************************************************************************
** Record a user action. Call it only from a real touch, key or scan, never from a timer or a response.
*******************************************************************************/
fun record() {
lastActionAt = clock()
}
/*******************************************************************************
** The current time on this object's time base, for a caller that needs a starting point.
*******************************************************************************/
fun now(): Long = clock()
/*******************************************************************************
** Whole seconds since the last user action, never negative. A caller that only started watching at
** notBefore (the on-screen timer) counts from there when no action has been seen since.
*******************************************************************************/
fun ageSeconds(notBefore: Long = 0L): Long = ((clock() - maxOf(lastActionAt, notBefore)) / 1000).coerceAtLeast(0)
/*******************************************************************************
** The header that states the user's activity to the server; sent with every request of a session.
*******************************************************************************/
fun header(): Map<String, String> = mapOf(HEADER to ageSeconds().toString())
/*******************************************************************************
** Seconds left before the server's timeout runs out; zero or less means the session is over.
*******************************************************************************/
fun remainingSeconds(timeoutSeconds: Int, notBefore: Long = 0L): Long = timeoutSeconds - ageSeconds(notBefore)
/*******************************************************************************
** Forget the last user action; for tests.
*******************************************************************************/
internal fun reset() {
lastActionAt = 0L
}
}
/*******************************************************************************
** Sign the operator out when the server's user inactivity timeout runs out, and warn a minute before. The
** server enforces the same timeout at the next request; this is what the operator sees. Shows nothing
** unless the server has such a timeout and somebody is signed in.
*******************************************************************************/
@Composable
fun InactivityLogoff(timeoutSeconds: Int?, signedIn: Boolean, onLogoff: () -> Unit) {
if (timeoutSeconds == null || timeoutSeconds <= 0 || !signedIn) return
var remaining by remember(timeoutSeconds) { mutableStateOf<Long?>(null) }
// A session resumed by a fresh process has seen no action yet: the on-screen timer starts now, while the
// header still states the full age and the server decides from what it recorded.
val shownAt = remember(timeoutSeconds) { UserActivity.now() }
LaunchedEffect(timeoutSeconds)
{
while (true) {
val left = UserActivity.remainingSeconds(timeoutSeconds, shownAt)
if (left <= 0) {
remaining = null
onLogoff()
break
}
remaining = left.takeIf { it <= UserActivity.WARNING_SECONDS }
delay(1000)
}
}
val left = remaining ?: return
// A dialog has its own window: touches in it do not reach the activity, so they are recorded here.
val stay = { UserActivity.record(); remaining = null }
AlertDialog(
onDismissRequest = stay,
confirmButton = { TextButton(onClick = stay) { Text("Stay signed in") } },
title = { Text("Are you still there?") },
text = { Text("You will be signed out in $left ${if (left == 1L) "second" else "seconds"} because of inactivity.") }
)
}
@@ -0,0 +1,102 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/*******************************************************************************
**
*******************************************************************************/
class UserActivityTest {
private var now = 0L
private val systemClock = UserActivity.clock
/*******************************************************************************
**
*******************************************************************************/
@Before
fun useAFakeClock() {
now = 7_200_000L
UserActivity.clock = { now }
UserActivity.reset()
}
/*******************************************************************************
**
*******************************************************************************/
@After
fun restoreTheClock() {
UserActivity.clock = systemClock
UserActivity.reset()
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun beforeAnyUserActionNothingIsAssumedInTheUsersFavour() {
assertEquals(7200L, UserActivity.ageSeconds())
assertTrue(UserActivity.remainingSeconds(900) <= 0)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun ageIsWholeSecondsSinceTheLastUserActionAndNeverNegative() {
UserActivity.record()
assertEquals(0L, UserActivity.ageSeconds())
now += 999
assertEquals(0L, UserActivity.ageSeconds())
now += 60_501
assertEquals(61L, UserActivity.ageSeconds())
now -= 120_000
assertEquals(0L, UserActivity.ageSeconds())
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun timePassingAloneNeverCountsOnlyARecordedActionDoes() {
UserActivity.record()
now += 899_000
assertEquals(1L, UserActivity.remainingSeconds(900))
now += 1_000
assertEquals(0L, UserActivity.remainingSeconds(900))
now += 600_000
assertEquals(-600L, UserActivity.remainingSeconds(900))
UserActivity.record()
assertEquals(900L, UserActivity.remainingSeconds(900))
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun theOnScreenTimerOfAResumedSessionStartsWhenItIsShownTheHeaderDoesNot() {
val shownAt = UserActivity.now()
now += 30_000
assertEquals(870L, UserActivity.remainingSeconds(900, shownAt))
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "7230"), UserActivity.header())
UserActivity.record()
now += 10_000
assertEquals(890L, UserActivity.remainingSeconds(900, shownAt))
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "10"), UserActivity.header())
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun everySessionRequestStatesTheAgeInTheUiApiHeader() {
UserActivity.record()
now += 42_000
assertEquals(mapOf("X-QQQ-User-Activity-Age" to "42"), UserActivity.header())
assertEquals("X-QQQ-User-Activity-Age", UserActivity.HEADER)
assertEquals(60, UserActivity.WARNING_SECONDS)
}
}