WIP: feat(auth): automatic logoff after user inactivity (UI API 1.12.0) #30
No files matched your search
+3
-1
@@ -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
|
||||
)
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+2
@@ -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\""))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user