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
12 changed files with 239 additions and 203 deletions

No files matched your search

+1 -18
View File
@@ -41,24 +41,7 @@ Only the disposable E2E APK accepts synthetic broadcasts while its scanner scree
adb shell am broadcast -a io.qrun.mobile.scanner.SCAN -c android.intent.category.DEFAULT -p io.qrun.mobile.scanner.e2e --es com.symbol.datawedge.data_string DEMO-001 --es com.symbol.datawedge.label_type LABEL-TYPE-CODE128
```
`am broadcast` cannot send a byte-array extra. A scan that needs fidelity metadata (for example GS1-128) adds the AIM identifier and the raw bytes as canonical padded Base64 text, both quoted for the device shell:
```text
--es io.qrun.mobile.scanner.e2e.aim_identifier ']C1' --es io.qrun.mobile.scanner.e2e.raw_data_base64 'XUMxMDEwOTUwMTEwMTUzMDAwMzExMjAxMDE0MjFURVNUMDAwMTIz'
```
The byte-array extra `io.qrun.mobile.scanner.e2e.raw_data` wins when both are present. A broadcast with malformed Base64 or an invalid AIM identifier is ignored and the read stays pending. `am to-intent-uri` with the same arguments prints how the device parsed them.
This validates intent parsing only; the server treats source/symbology as client-reported metadata. The E2E package signs in with its own callback (`io.qrun.mobile.scanner.e2e.oauth://callback/oauth2redirect`, see [AUTHENTICATION.md](AUTHENTICATION.md)), so a server that advertises only the normal scanner callback offers it no browser sign-in.
### Emulator test build under the normal application ID
For such a server, `:scanner-host:assembleEmu` builds **QQQ Mobile EMULATOR TEST**: the same fixture driver under the normal application ID `io.qrun.mobile.scanner`, so it signs in with the normal callback. Its version name ends in `-emu`. Send it the broadcast above with `-p io.qrun.mobile.scanner`.
- It is debug-signed, so it can never be installed beside or over the production-signed application. `adb uninstall io.qrun.mobile.scanner` must come first, which removes that installation's session and device enrollment; going back means uninstalling the test build and reinstalling the signed APK.
- It does not use the E2E manifest or network security config: cleartext stays off and only the system trust store is used.
- The fixture driver is created only when `android.os.Build` describes an SDK emulator image, and every signal must agree: `HARDWARE` is `goldfish` or `ranchu`, `PRODUCT` starts with `sdk_`, `MODEL` starts with `sdk_` or `Android SDK built for `, and `FINGERPRINT` names that same product. Anywhere else there is no driver and the scan control reports hardware scanning as unavailable. The disposable `.e2e` package is exempt and behaves as before.
- Never install it on a handheld. It is a local test aid, not a release artifact, and CI does not build it.
This validates intent parsing only; the server treats source/symbology as client-reported metadata.
## Automated checks
@@ -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\""))
+3 -14
View File
@@ -13,7 +13,7 @@ aboutLibraries {
fetchRemoteLicense = false
fetchRemoteFunding = false
includePlatform = false
filterVariants = arrayOf("debug", "release", "e2e", "qa", "emu")
filterVariants = arrayOf("debug", "release", "e2e", "qa")
// The plugin also scans compile classpaths. This SDK is not redistributed;
// verifyEmdkSdk separately forbids it in every runtime classpath.
exclusionPatterns = listOf(Regex("^com\\.symbol:emdk$").toPattern())
@@ -26,7 +26,7 @@ aboutLibraries {
// changing a selected dependency can reuse the previous dependency_cache.json.
tasks.withType<com.mikepenz.aboutlibraries.plugin.AboutLibrariesCollectorTask>().configureEach {
inputs.property("resolvedDependencyCoordinates", providers.provider {
listOf("debug", "release", "e2e", "qa", "emu").flatMap { variant ->
listOf("debug", "release", "e2e", "qa").flatMap { variant ->
listOf("CompileClasspath", "RuntimeClasspath").flatMap { suffix ->
val configurationName = "$variant$suffix"
configurations.getByName(configurationName).incoming.resolutionResult.allComponents.map { component ->
@@ -89,22 +89,11 @@ android {
applicationIdSuffix = ".qa"
matchingFallbacks += "debug"
}
// Emulator-only test build: the E2E scan fixture under the normal application ID, so a server that
// registers only the normal sign-in callback accepts it. Debug-signed, so it can never update or sit
// beside the production-signed application, and its fixture driver refuses to exist off an emulator.
create("emu") {
initWith(getByName("debug"))
versionNameSuffix = "-emu"
matchingFallbacks += "debug"
}
}
testBuildType = scannerTestBuildType
sourceSets.getByName("debug").java.srcDir("src/production/java")
sourceSets.getByName("release").java.srcDir("src/production/java")
sourceSets.getByName("qa").java.srcDir("src/production/java")
// Only the fixture driver and its tests are shared; the E2E manifest and network security config are not.
sourceSets.getByName("emu").java.srcDir("src/e2e/java")
sourceSets.getByName("testEmu").java.srcDir("src/testE2e/java")
if(scannerTestBuildType == "qa") {
sourceSets.getByName("androidTestQa").java.srcDir("src/qaAndroidTest/java")
sourceSets.getByName("androidTestQa").manifest.srcFile("src/qaAndroidTest/AndroidManifest.xml")
@@ -159,7 +148,7 @@ val verifyEmdkSdk = tasks.register("verifyEmdkSdk") {
}.artifacts.artifacts
check(vendorRuntime.isEmpty()) { "Zebra EMDK must remain compile-only; found in ${runtime.name}" }
}
listOf("debug", "release", "e2e", "qa", "emu").forEach { variant ->
listOf("debug", "release", "e2e", "qa").forEach { variant ->
val artifacts = configurations.getByName("${variant}CompileClasspath").incoming.artifactView {
componentFilter {
it is org.gradle.api.artifacts.component.ModuleComponentIdentifier && it.group == "com.symbol" && it.module == "emdk"
@@ -5,53 +5,16 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.core.content.ContextCompat
import java.util.Base64
internal const val HARDWARE_SCAN_SOURCE = "e2e-fixture"
internal const val E2E_PACKAGE = "io.qrun.mobile.scanner.e2e"
internal const val E2E_RAW_DATA_EXTRA = "io.qrun.mobile.scanner.e2e.raw_data"
internal const val E2E_RAW_DATA_BASE64_EXTRA = "io.qrun.mobile.scanner.e2e.raw_data_base64"
internal const val E2E_AIM_IDENTIFIER_EXTRA = "io.qrun.mobile.scanner.e2e.aim_identifier"
/*******************************************************************************
** `adb shell am broadcast` cannot send a byte-array extra, so a fixture may send the same bytes as canonical
** padded Base64 text. The byte array wins when both are present; other text throws IllegalArgumentException.
** Disposable E2E package only. No debug/release source set contains this ingress.
*******************************************************************************/
internal fun e2eRawData(raw: ByteArray?, base64: String?): ByteArray? = raw ?: base64?.let { text ->
Base64.getDecoder().decode(text).also { require(Base64.getEncoder().encodeToString(it) == text) { "Raw data must be canonical padded Base64." } }
}
/*******************************************************************************
** An Android SDK emulator image, judged conservatively from android.os.Build: every signal must agree.
** The virtual board (goldfish/ranchu), an `sdk_` product, an SDK model name, and a fingerprint that names
** that same product. A real handheld fails the first test; one odd property never passes alone.
*******************************************************************************/
internal fun isAndroidEmulator(hardware: String?, product: String?, model: String?, fingerprint: String?): Boolean =
(hardware == "goldfish" || hardware == "ranchu") &&
product != null && product.startsWith("sdk_") &&
model != null && (model.startsWith("sdk_") || model.startsWith("Android SDK built for ")) &&
fingerprint != null && fingerprint.contains("/$product/")
/*******************************************************************************
** The disposable E2E package may take fixture scans anywhere, as before. Any other package that carries
** this source (the `emu` build type uses the normal application ID) may do so only on an emulator.
*******************************************************************************/
internal fun scanFixtureAllowed(packageName: String, hardware: String?, product: String?, model: String?, fingerprint: String?): Boolean =
packageName == E2E_PACKAGE || isAndroidEmulator(hardware, product, model, fingerprint)
/*******************************************************************************
** E2E and emulator test builds only. No debug/qa/release source set contains this ingress, and off the
** disposable E2E package there is no driver at all unless the device is an Android emulator.
*******************************************************************************/
internal fun createScannerDriver(context: Context): ScannerDriver? =
if (scanFixtureAllowed(context.packageName, Build.HARDWARE, Build.PRODUCT, Build.MODEL, Build.FINGERPRINT)) fixtureScannerDriver(context) else null
/*******************************************************************************
** Takes one broadcast per pending read, only while the scanner widget holds it open.
*******************************************************************************/
private fun fixtureScannerDriver(context: Context): ScannerDriver = object : ScannerDriver {
internal fun createScannerDriver(context: Context): ScannerDriver = object : ScannerDriver {
private var listener: ScannerDriver.Listener? = null
private var registered = false
private var pending = false
@@ -63,17 +26,11 @@ private fun fixtureScannerDriver(context: Context): ScannerDriver = object : Sca
if (!pending || intent?.action != "io.qrun.mobile.scanner.SCAN") return
val data = intent.getStringExtra("com.symbol.datawedge.data_string") ?: return
if (intent.getStringExtra("com.symbol.datawedge.decode_mode") == "multiple" || intent.hasExtra("com.symbol.datawedge.barcodes")) return
// Test-only fixture metadata: exact bytes and an explicit AIM identifier, never derived from the text.
// Malformed metadata leaves the read pending, so the broadcast is ignored like any other invalid one.
val capture = try {
val raw = e2eRawData(intent.getByteArrayExtra(E2E_RAW_DATA_EXTRA), intent.getStringExtra(E2E_RAW_DATA_BASE64_EXTRA))
val aim = intent.getStringExtra(E2E_AIM_IDENTIFIER_EXTRA)
ScannerCapture.of(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN", raw, aim)
} catch (invalid: IllegalArgumentException) {
return
}
pending = false
listener?.scanned(capture)
// Test-only fixture metadata: exact bytes and an explicit AIM identifier, never derived from the text.
val raw = intent.getByteArrayExtra(E2E_RAW_DATA_EXTRA)
val aim = intent.getStringExtra(E2E_AIM_IDENTIFIER_EXTRA)
listener?.scanned(ScannerCapture.of(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN", raw, aim))
listener?.idle()
}
}
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- Emulator-only test build under the normal application ID. Only the label differs from the main manifest:
cleartext stays off and there is no network security config, so HTTPS with system trust is the only transport. -->
<application android:label="QQQ Mobile EMULATOR TEST" tools:replace="android:label" />
</manifest>
@@ -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)
}
}
@@ -1,46 +0,0 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
/*******************************************************************************
** The E2E fixture's text alternative for raw bytes, which `adb shell am broadcast` can send.
*******************************************************************************/
class E2eRawDataTest {
private val framed = "]C101095011015300031120101421TEST000123"
/*******************************************************************************
** The decoded text reaches the capture as the same canonical Base64 the byte-array extra produces.
*******************************************************************************/
@Test
fun base64TextDecodesToTheExactBytes() {
val text = java.util.Base64.getEncoder().encodeToString(framed.toByteArray(Charsets.US_ASCII))
assertArrayEquals(framed.toByteArray(Charsets.US_ASCII), e2eRawData(null, text))
assertEquals(text, ScannerCapture.of(framed.substring(3), "LABEL-TYPE-EAN128", e2eRawData(null, text), "]C1").rawDataBase64)
assertArrayEquals(byteArrayOf(0x00, 0xFF.toByte(), 0x80.toByte()), e2eRawData(null, "AP+A"))
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun byteArrayWinsAndAbsenceStaysAbsent() {
assertArrayEquals(byteArrayOf(1, 2), e2eRawData(byteArrayOf(1, 2), "AP+A"))
assertArrayEquals(byteArrayOf(1, 2), e2eRawData(byteArrayOf(1, 2), "not base64"))
assertNull(e2eRawData(null, null))
}
/*******************************************************************************
** Anything but canonical padded Base64 is refused, so the driver ignores that broadcast.
*******************************************************************************/
@Test
fun malformedOrNonCanonicalTextIsRefused() {
for (text in listOf("not base64", "AP+A ", " AP+A", "AP-A", "QQ", "QQ=", "QR==", "AP+A\n")) {
assertThrows(text, IllegalArgumentException::class.java) { e2eRawData(null, text) }
}
}
}
@@ -1,65 +0,0 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/*******************************************************************************
** The fixture driver exists off the disposable E2E package only on an Android emulator.
*******************************************************************************/
class ScanFixtureGuardTest {
private val hardware = "ranchu"
private val product = "sdk_gphone64_x86_64"
private val model = "sdk_gphone64_x86_64"
private val fingerprint = "google/sdk_gphone64_x86_64/emu64xa:15/AE3A.240806.043/12960925:userdebug/dev-keys"
private val handheld = "Zebra/TC22/TC22:13/13-00-00.00-TG-U00-STD/000:user/release-keys"
/*******************************************************************************
** Values as an API 35 Google APIs emulator image reports them, and an older generic image.
*******************************************************************************/
@Test
fun sdkEmulatorImagesAreRecognised() {
assertTrue(isAndroidEmulator(hardware, product, model, fingerprint))
assertTrue(isAndroidEmulator("goldfish", "sdk_x86", "Android SDK built for x86", "generic/sdk_x86/generic_x86:9/PSR1.180720.075/5124027:userdebug/test-keys"))
}
/*******************************************************************************
** Fake handheld values: nothing about a real device may pass, whatever it is.
*******************************************************************************/
@Test
fun realHardwareIsRefused() {
assertFalse(isAndroidEmulator("qcom", "TC22", "TC22", handheld))
assertFalse(isAndroidEmulator("qcom", "MC9400", "MC9400", "Zebra/MC9400/MC9400:13/13-00-00.00-TG-U00-STD/000:user/release-keys"))
assertFalse(isAndroidEmulator(null, null, null, null))
assertFalse(isAndroidEmulator("", "", "", ""))
}
/*******************************************************************************
** Every signal is required; one emulator-looking property on otherwise real values proves nothing.
*******************************************************************************/
@Test
fun everySignalMustAgree() {
assertFalse(isAndroidEmulator("qcom", product, model, fingerprint))
assertFalse(isAndroidEmulator("ranchu2", product, model, fingerprint))
assertFalse(isAndroidEmulator(hardware, "TC22", model, fingerprint))
assertFalse(isAndroidEmulator(hardware, "sdk", model, fingerprint))
assertFalse(isAndroidEmulator(hardware, product, "TC22", fingerprint))
assertFalse(isAndroidEmulator(hardware, product, model, handheld))
assertFalse(isAndroidEmulator(hardware, product, model, "google/sdk_gphone64_arm64/emu64a:15/AE3A.240806.043/12960925:userdebug/dev-keys"))
assertFalse(isAndroidEmulator(hardware, product, model, null))
assertFalse(isAndroidEmulator("ranchu", "TC22", "TC22", handheld))
}
/*******************************************************************************
** The disposable E2E package keeps its behaviour; the normal application ID is emulator-only.
*******************************************************************************/
@Test
fun onlyTheE2ePackageIsExemptFromTheEmulatorRule() {
assertTrue(scanFixtureAllowed("io.qrun.mobile.scanner.e2e", "qcom", "TC22", "TC22", handheld))
assertFalse(scanFixtureAllowed("io.qrun.mobile.scanner", "qcom", "TC22", "TC22", handheld))
assertFalse(scanFixtureAllowed("io.qrun.mobile.scanner.qa", "qcom", "TC22", "TC22", handheld))
assertFalse(scanFixtureAllowed("io.qrun.mobile.scanner.e2e.other", "qcom", "TC22", "TC22", handheld))
assertTrue(scanFixtureAllowed("io.qrun.mobile.scanner", hardware, product, model, fingerprint))
}
}