diff --git a/docs/EMULATOR.md b/docs/EMULATOR.md index 80e60e5..63619b6 100644 --- a/docs/EMULATOR.md +++ b/docs/EMULATOR.md @@ -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 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 71922c6..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,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() } } 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/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) } + } + } +} 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)) + } +}