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
james.maes 9bae976138 Merge pull request 'fix(auth): end the identity-provider session on sign-out and force re-authentication on sign-in' (#27) from fix/signout-idp-session into main
android / android (push) Failing after 3m53s
build / android (push) Failing after 3m53s
2026-11-09 20:10:27 +00:00
6 changed files with 198 additions and 10 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
+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>
@@ -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))
}
}