Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.2 0419cdea91 test(emu): emulator-only build type with the scan fixture under the normal application ID
android / android (push) Failing after 11m43s
build / android (push) Failing after 11m43s
A server that registers only the normal scanner callback offers the E2E
package no sign-in, so the fixture could not be used against it. The new
`emu` build type (initWith debug, no applicationIdSuffix, versionNameSuffix
-emu, label "QQQ Mobile EMULATOR TEST") reuses the E2E fixture driver source
under io.qrun.mobile.scanner. It does not take the E2E manifest or network
security config: cleartext stays off and only system trust is used.

Because it carries the normal application ID, the fixture driver is now
created only for the disposable .e2e package (unchanged behaviour) or when
android.os.Build describes an SDK emulator image: HARDWARE goldfish or
ranchu, PRODUCT sdk_*, MODEL sdk_* or "Android SDK built for *", and a
FINGERPRINT naming that same product. Otherwise there is no driver.

Debug, qa and release source sets are unchanged and contain no ingress.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:48:01 -06:00
james.maesandClaude Fable 5.2 d5c3ac2d6a test(e2e): accept the scan fixture's raw bytes as Base64 text
android / android (push) Failing after 4m0s
build / android (push) Failing after 4m0s
adb shell am broadcast cannot send a byte-array extra, so a manual emulator
scan could not carry the raw bytes a GS1-128 duplication needs. The E2E
driver now also reads io.qrun.mobile.scanner.e2e.raw_data_base64 (canonical
padded Base64). The byte-array extra still wins when both are present.
Malformed Base64, and an invalid AIM identifier (which used to throw inside
the receiver), now leave the read pending so the broadcast is ignored.

E2E source set only: no change to main, debug, qa, production or release.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:36:00 -06:00
12 changed files with 203 additions and 239 deletions

No files matched your search

+18 -1
View File
@@ -41,7 +41,24 @@ 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
```
This validates intent parsing only; the server treats source/symbology as client-reported metadata.
`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.
## Automated checks
@@ -20,9 +20,7 @@ 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,
// Automatic logoff (UI API 1.12.0): seconds without a user action after which the server ends the session.
val userInactivityTimeoutSeconds: Int? = null
val nativeSessionLogout: NativeSessionLogout? = null
)
/*******************************************************************************
@@ -39,8 +39,6 @@ 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\""))
+14 -3
View File
@@ -13,7 +13,7 @@ aboutLibraries {
fetchRemoteLicense = false
fetchRemoteFunding = false
includePlatform = false
filterVariants = arrayOf("debug", "release", "e2e", "qa")
filterVariants = arrayOf("debug", "release", "e2e", "qa", "emu")
// 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").flatMap { variant ->
listOf("debug", "release", "e2e", "qa", "emu").flatMap { variant ->
listOf("CompileClasspath", "RuntimeClasspath").flatMap { suffix ->
val configurationName = "$variant$suffix"
configurations.getByName(configurationName).incoming.resolutionResult.allComponents.map { component ->
@@ -89,11 +89,22 @@ 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")
@@ -148,7 +159,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").forEach { variant ->
listOf("debug", "release", "e2e", "qa", "emu").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,16 +5,53 @@ 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"
/*******************************************************************************
** Disposable E2E package only. No debug/release source set contains this ingress.
** `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.
*******************************************************************************/
internal fun createScannerDriver(context: Context): ScannerDriver = object : ScannerDriver {
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 {
private var listener: ScannerDriver.Listener? = null
private var registered = false
private var pending = false
@@ -26,11 +63,17 @@ internal fun createScannerDriver(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
pending = false
// 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))
// 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)
listener?.idle()
}
}
+6
View File
@@ -0,0 +1,6 @@
<?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,13 +136,6 @@ 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.
*******************************************************************************/
@@ -238,7 +231,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() + UserActivity.header() else emptyMap()
if (requestedServer == server) credentials.headers() else emptyMap()
}
@Suppress("UNCHECKED_CAST")
return QViewModel(repository, dataStore).apply { supportedCustomWidgetTypes = setOf("SCANNER", "RECORD_FILES_CAPTURE") } as T
@@ -258,9 +251,7 @@ class ScannerActivity : ComponentActivity() {
return
}
val api = remember(server, diagnostics) { DeviceApi(server, credentials, diagnostics::deviceRequest, compatibility) }
// 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) }
val recordFiles = remember(server, diagnostics) { RecordFilesApi(server, credentials::headers, 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)
@@ -282,7 +273,7 @@ class ScannerActivity : ComponentActivity() {
else WallpaperStatus.store.signedOutIfSignedIn()
}
DisposableEffect(server) { onDispose { WallpaperStatus.store.clear() } }
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, sessionHeaders, compatibility, diagnostics::sessionRequest) }
val sessionApi = remember(server, diagnostics) { MobileSessionApi(server, credentials::headers, compatibility, diagnostics::sessionRequest) }
val deviceSharing = remember(server, qViewModel.sessionUUID) { DeviceScreenSharing(applicationContext) }
val sessions = remember(sessionApi, qViewModel.sessionUUID)
{
@@ -319,10 +310,6 @@ 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,8 +118,7 @@ internal fun ScannerWidget(
{
ScannerInputSession(
{ driverFactory(context) },
// A hardware scan is a user action even when no key event reaches the activity.
{ capture -> UserActivity.record(); currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
{ capture -> currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
{ message, reconnect -> scannerStatus = message; canReconnect = reconnect },
canAcceptInput = { currentInputReady() })
}
@@ -1,104 +0,0 @@
/* 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.") }
)
}
@@ -1,102 +0,0 @@
/* 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)
}
}
@@ -0,0 +1,46 @@
/* 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) }
}
}
}
@@ -0,0 +1,65 @@
/* 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))
}
}