diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/OAuth2AuthenticationMetaData.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/OAuth2AuthenticationMetaData.kt index 5c9319d..83249f1 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/OAuth2AuthenticationMetaData.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/model/metadata/authentication/OAuth2AuthenticationMetaData.kt @@ -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 ) /******************************************************************************* diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeSessionLoginTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeSessionLoginTest.kt index acc832b..a6789a5 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeSessionLoginTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NativeSessionLoginTest.kt @@ -39,6 +39,8 @@ class NativeSessionLoginTest { assertEquals("login", metadata.values.nativeSessionLogin!!.prompt) assertEquals("REQUIRED", metadata.values.nativeSessionLogin!!.nonceValidation) assertNull((Json.decodeFromString("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.nativeSessionLogin) + assertNull((Json.decodeFromString("""{"type":"OAUTH2","name":"old","values":{"clientId":"server-client"}}""") as OAuth2AuthenticationMetaData).values.userInactivityTimeoutSeconds) + assertEquals(900, (Json.decodeFromString("""{"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\"")) diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt index 4f11dfc..b901e59 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerActivity.kt @@ -136,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. *******************************************************************************/ @@ -231,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 @@ -251,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) @@ -273,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) { @@ -310,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, diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt index 0b381dd..2b532ea 100644 --- a/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/ScannerWidget.kt @@ -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() }) } diff --git a/scanner-host/src/main/java/io/qrun/mobile/scanner/UserActivity.kt b/scanner-host/src/main/java/io/qrun/mobile/scanner/UserActivity.kt new file mode 100644 index 0000000..32954ff --- /dev/null +++ b/scanner-host/src/main/java/io/qrun/mobile/scanner/UserActivity.kt @@ -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 = 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(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.") } + ) +} diff --git a/scanner-host/src/test/java/io/qrun/mobile/scanner/UserActivityTest.kt b/scanner-host/src/test/java/io/qrun/mobile/scanner/UserActivityTest.kt new file mode 100644 index 0000000..24ea87e --- /dev/null +++ b/scanner-host/src/test/java/io/qrun/mobile/scanner/UserActivityTest.kt @@ -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) + } +}