From d5c3ac2d6af8e4d43fac0b67454805e1d675318a Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 10:36:00 -0600 Subject: [PATCH 1/2] test(e2e): accept the scan fixture's raw bytes as Base64 text 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 --- docs/EMULATOR.md | 10 +++- .../mobile/scanner/ScannerDriverFactory.kt | 24 ++++++++-- .../io/qrun/mobile/scanner/E2eRawDataTest.kt | 46 +++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 scanner-host/src/testE2e/java/io/qrun/mobile/scanner/E2eRawDataTest.kt diff --git a/docs/EMULATOR.md b/docs/EMULATOR.md index 80e60e5..2eac124 100644 --- a/docs/EMULATOR.md +++ b/docs/EMULATOR.md @@ -41,7 +41,15 @@ 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. ## Automated checks diff --git a/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt b/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt index 71922c6..faa174c 100644 --- a/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt +++ b/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt @@ -6,11 +6,21 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.core.content.ContextCompat +import java.util.Base64 internal const val HARDWARE_SCAN_SOURCE = "e2e-fixture" 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. + *******************************************************************************/ +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." } } +} + /******************************************************************************* ** Disposable E2E package only. No debug/release source set contains this ingress. *******************************************************************************/ @@ -26,11 +36,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() } } diff --git a/scanner-host/src/testE2e/java/io/qrun/mobile/scanner/E2eRawDataTest.kt b/scanner-host/src/testE2e/java/io/qrun/mobile/scanner/E2eRawDataTest.kt new file mode 100644 index 0000000..3f16535 --- /dev/null +++ b/scanner-host/src/testE2e/java/io/qrun/mobile/scanner/E2eRawDataTest.kt @@ -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) } + } + } +} -- 2.54.0 From 0419cdea91d39abecccc334361a4263f9bf7f771 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 10:48:01 -0600 Subject: [PATCH 2/2] test(emu): emulator-only build type with the scan fixture under the normal application ID 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 --- docs/EMULATOR.md | 9 +++ scanner-host/build.gradle.kts | 17 ++++- .../mobile/scanner/ScannerDriverFactory.kt | 31 ++++++++- scanner-host/src/emu/AndroidManifest.xml | 6 ++ .../mobile/scanner/ScanFixtureGuardTest.kt | 65 +++++++++++++++++++ 5 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 scanner-host/src/emu/AndroidManifest.xml create mode 100644 scanner-host/src/testE2e/java/io/qrun/mobile/scanner/ScanFixtureGuardTest.kt diff --git a/docs/EMULATOR.md b/docs/EMULATOR.md index 2eac124..63619b6 100644 --- a/docs/EMULATOR.md +++ b/docs/EMULATOR.md @@ -51,6 +51,15 @@ The byte-array extra `io.qrun.mobile.scanner.e2e.raw_data` wins when both are pr 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 The GitHub **Scanner Android** workflow runs JVM tests, lint, APK assembly, the shared native UI suite, and the real-server scanner E2E on pushes and pull requests. The emulator job checks out exact core and companion qbit commits, verifies them in a fresh private Maven repository, and starts a fresh isolated demo database. [Fixture provenance](CI-FIXTURE.md) describes the source pins, runtime checks and distinction from published release dependencies. Sanitized instrumentation results are uploaded as workflow artifacts. Hosted execution is verified separately from a local run. diff --git a/scanner-host/build.gradle.kts b/scanner-host/build.gradle.kts index e0d6eee..4dedcb1 100644 --- a/scanner-host/build.gradle.kts +++ b/scanner-host/build.gradle.kts @@ -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().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" diff --git a/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt b/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt index faa174c..6a57c97 100644 --- a/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt +++ b/scanner-host/src/e2e/java/io/qrun/mobile/scanner/ScannerDriverFactory.kt @@ -5,10 +5,12 @@ 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" @@ -22,9 +24,34 @@ internal fun e2eRawData(raw: ByteArray?, base64: String?): ByteArray? = raw ?: b } /******************************************************************************* - ** Disposable E2E package only. No debug/release source set contains this ingress. + ** 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 createScannerDriver(context: Context): ScannerDriver = object : ScannerDriver { +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 diff --git a/scanner-host/src/emu/AndroidManifest.xml b/scanner-host/src/emu/AndroidManifest.xml new file mode 100644 index 0000000..8e7d8e8 --- /dev/null +++ b/scanner-host/src/emu/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/scanner-host/src/testE2e/java/io/qrun/mobile/scanner/ScanFixtureGuardTest.kt b/scanner-host/src/testE2e/java/io/qrun/mobile/scanner/ScanFixtureGuardTest.kt new file mode 100644 index 0000000..e125cb4 --- /dev/null +++ b/scanner-host/src/testE2e/java/io/qrun/mobile/scanner/ScanFixtureGuardTest.kt @@ -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)) + } +} -- 2.54.0