feat(scanner): send immutable raw bytes to compatible semantic servers

Capture exact SDK bytes before callback return and submit them through the existing process API only after same-server mobile-scanners 1.5 discovery. Preserve legacy server payloads and pending event retries; redact capture string representations. Verify 185 JVM tests, zero lint errors or warnings, debug/E2E builds and QA test compilation. Hardware AIM extraction and native delivery qualification remain open; the operator installation is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
committed 2026-11-02 10:56:59 -06:00
1 parent ffc5b493fa
commit e255b7341d
16 files changed
+349 -42

No files matched your search

+3 -1
View File
@@ -1,5 +1,7 @@
## November 2 — current checkpoint
Source candidate: immutable `ScannerCapture` retains SDK raw bytes as canonical Base64 before the callback returns and routes them through the ordinary semantic process payload. New optional fields are sent only after same-server discovery advertises mobile-scanners >=1.5,<2; older servers still receive the five legacy keys. Barcode text and symbology remain unchanged, scan/capture string representations are redacted, and a failed-submit regression proves the entire event is resent unchanged. Verification passes 185 JVM tests (44 core, 56 mobileapp, 85 host), lint with zero errors/warnings (two existing informational hints), debug/E2E APK builds and QA Android-test compilation. New native tests and operator installation have not run. The SDK configuration is unchanged and AIM extraction is not implemented by this transport slice.
Completed and verified:
- Android source `fb3cc5d` is signed and published as Gitea PR 10. It passes 174 JVM tests (44 core, 55 mobileapp, 75 host), lint with zero errors and zero warnings, and the debug APK build.
@@ -11,7 +13,7 @@ Completed and verified:
Not yet done:
- No physical Echo print or rescan has been proven. Baseline print jobs were 0 and the latest read was 0. One physical scan of QQQ-TEST-123456 has been requested from the user.
- No physical Echo print or rescan has been proven. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed. One physical scan of QQQ-TEST-123456 has been requested from the user.
- Still open for 1.0: physical Code128 and leading-zero QR scan, print and rescan; full provider, camera, GPS, projection and device qualification; public coherent artifacts; primary Gitea CI; and final signing and distribution. Signing is deliberately last, by the user's decision. The first demo working does not collapse these requirements, and nothing here claims release readiness.
- Core native API merge continuation PR 1337 (a42f9e5) was reviewed with no actionable conflict regression and its 57 focused tests were verified. Normal CI and publication are not established, and default dependencies are not repinned.
+3 -1
View File
@@ -1,11 +1,13 @@
# TODO
- [x] Implement negotiated mobile1.5 raw-byte transport with immutable capture, same-server capability gating and byte-identical failed-request retry;185 JVM tests, lint0errors/0warnings, debug/E2E builds and QA test compilation pass.
- [ ] Qualify new raw-byte delivery on TC22 and complete actual AIM extraction/decoder-profile checks. Current source leaves AIM absent and does not change hardware configuration; full Echo fidelity is still open.
- [x] Verify native `Origin` compatibility:174 JVM tests, lint0errors0warnings and APK build pass; normal TC22 login/approval/heartbeat/Echo setup/printer-media choices/Cancel-Leave pass against the strict local Echo host through8080->57832, preserving data.
- [x] Verify generic INTEGER Document choice/dependency clearing on actual TC22:7 stages,112 semantic requests,99 focused/host tests and8 Node guards; simulated printing, normal data preserved.
- [x] Connect the installed print client to the single local server and publish the actual Echo4x1 document through native QQQ processes.
- [x] Resolve the printing agent's earlier 4x6 media report versus loaded 4x1 stock: the Printing owner aligned 101600 x 25400 micron/300 dpi media and the TC22 selected the real printer, fresh 4x1 media and published document; Next succeeded.
- [ ] Complete one physical Code128 (QQQ-TEST-123456) scan, print and rescan on the TC22, then leading-zero QR. No physical Echo print is proven yet (print jobs 0, latest read 0).
- [ ] Complete one physical Code128 (QQQ-TEST-123456) scan, print and rescan on the TC22, then leading-zero QR. One separate synthetic HTTP Echo event reached the actual Windows spooler; physical paper and handheld print/rescan remain unconfirmed.
- [ ] Qualify public coherent artifacts and primary Gitea CI; signing and distribution stay last by user decision. Core PR 1337 normal CI and publication are not established; do not repin default dependencies.
- [x] Add metadata-driven Echo acknowledgment/manual-entry hints with old defaults; 167 JVM tests, strict compilation, lint zero errors/warnings and debug build pass.
+2
View File
@@ -6,6 +6,8 @@ The app first requires the system's `com.symbol.emdk` shared library and enabled
## Lifecycle and process contract
When verified discovery for the current server advertises mobile-scanners 1.5 or newer within major 1, each capture can also send `rawDataBase64` and `aimIdentifier`. The EMDK callback encodes its exact `getRawData()` output once before returning; the immutable event survives HTTP retries unchanged. Older servers receive the legacy event fields. Raw input is bounded to 8192 bytes; null, empty or oversized raw input is omitted rather than truncated. These are SDK bytes, which may include framing, not a claim of original symbol codewords. Current hardware capture leaves AIM absent: no prefix inference or scanner configuration change is implemented. Decoder qualification and native raw-byte acceptance remain required for full Echo fidelity.
The adapter acquires the default scanner only while the server-defined SCANNER widget is visible and its Activity is resumed. HARD trigger reads are armed after IDLE, with one outstanding read. A decoded string is passed unchanged to the existing ScanEvent and semantic process submission, with its EMDK symbology and adapter-owned `zebra-emdk` source. New reads wait until the prior event is confirmed or explicitly abandoned by the existing process flow. Manual events remain `manual`. Neither source label is server-side attestation.
An IDLE callback with no pending read also rearms after a trigger release or timeout without a decode. If IDLE precedes the data callback, accepting that data blocks further input and cancels any read armed in between. A rapid pause/resume during an earlier asynchronous manager-open request can temporarily encounter the one-manager guard; **Reconnect scanner** retries explicitly after the prior manager has released. An unconfirmed release remains blocked until the app restarts.
@@ -44,6 +44,11 @@ class UiApiCompatibility(
@Volatile
private var verified: UiApiVersion? = null
/*******************************************************************************
** Non-blocking view of an already verified result; never performs discovery, so it is safe on the UI thread.
*******************************************************************************/
fun verifiedVersion(): UiApiVersion? = verified
/*******************************************************************************
** Call on an IO thread. Synchronization prevents competing startup requests bypassing discovery.
*******************************************************************************/
@@ -418,6 +418,37 @@ class ProcessSubmissionTest {
}
}
/*******************************************************************************
** The optional fidelity keys are ordinary undeclared event values; a lost response must resend all of them.
*******************************************************************************/
@Test
fun failedScanSubmissionResendsTheSameEntireEventMapIncludingFidelityValues() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
val repository = SubmissionRepository().apply { stepFailures = 1 }
val model = model(repository)
model.doInit()
advanceUntilIdle()
val event = mapOf(
"barcode" to "000123", "symbology" to "LABEL-TYPE-QRCODE", "scanSource" to "zebra-emdk",
"eventId" to "scan-1", "capturedAt" to "2026-01-01T00:00:00Z",
"aimIdentifier" to "]Q1", "rawDataBase64" to "AAECAw=="
)
model.runStep(event, ProcessAcknowledgment("acknowledgedEventId", "scan-1"))
advanceUntilIdle()
assertTrue(model.unconfirmedSubmission)
assertEquals(event, model.pendingStepValues)
repository.responseValues = mapOf("acknowledgedEventId" to "scan-1")
model.retryPendingStep()
advanceUntilIdle()
assertEquals(listOf(event, event), repository.submissions)
assertNull(model.pendingStepValues)
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -25,7 +25,7 @@ internal fun createScannerDriver(context: Context): ScannerDriver = object : Sca
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
listener?.scanned(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN")
listener?.scanned(ScannerCapture.of(data, intent.getStringExtra("com.symbol.datawedge.label_type") ?: "UNKNOWN"))
listener?.idle()
}
}
@@ -7,18 +7,36 @@ import java.util.UUID
/*******************************************************************************
** One physical scan remains one event even when its HTTP request is retried.
*******************************************************************************/
data class ScanEvent(
val barcode: String,
val symbology: String,
internal data class ScanEvent(
val capture: ScannerCapture,
val scanSource: String,
val eventId: String = UUID.randomUUID().toString(),
val capturedAt: String = Instant.now().toString()
) {
constructor(barcode: String, symbology: String, scanSource: String) :
this(ScannerCapture.of(barcode, symbology), scanSource)
val barcode: String get() = capture.barcode
val symbology: String get() = capture.symbology
/*******************************************************************************
** A fixed schema prevents arbitrary intent extras becoming process inputs.
** A fixed schema prevents arbitrary intent extras becoming process inputs. The optional fidelity
** keys are added only for servers verified to accept them; absent values are omitted, never blank.
*******************************************************************************/
fun values(): Map<String, Any> = mapOf(
"barcode" to barcode, "symbology" to symbology, "scanSource" to scanSource,
"eventId" to eventId, "capturedAt" to capturedAt
)
fun values(includeFidelity: Boolean = false): Map<String, Any> {
val values = linkedMapOf<String, Any>(
"barcode" to barcode, "symbology" to symbology, "scanSource" to scanSource,
"eventId" to eventId, "capturedAt" to capturedAt
)
if (includeFidelity) {
capture.aimIdentifier?.let { values["aimIdentifier"] = it }
capture.rawDataBase64?.let { values["rawDataBase64"] = it }
}
return values
}
/*******************************************************************************
** Scan content must not appear in logs; the event identity is an opaque correlation value.
*******************************************************************************/
override fun toString(): String = "ScanEvent(redacted, source=$scanSource, eventId=$eventId)"
}
@@ -0,0 +1,28 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiVersion
/*******************************************************************************
** The optional aimIdentifier/rawDataBase64 inputs exist from mobile-scanners 1.5.0; older servers
** must receive exactly the legacy keys. Derived only from already-verified discovery data.
*******************************************************************************/
internal object ScanFidelityCapability {
private val VERSION = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)")
/*******************************************************************************
** Require the single registered mobile-scanners extension at its fixed path with version >=1.5.0,<2.
*******************************************************************************/
fun supports(version: UiApiVersion?): Boolean {
val extension = version?.extensions?.singleOrNull { it.name == "mobile-scanners" } ?: return false
if (extension.pathPrefix != "/qbit/mobile-scanners") return false
val parts = VERSION.matchEntire(extension.version)?.groupValues ?: return false
return parts[1] == "1" && (parts[2].toIntOrNull() ?: return false) >= 5 && parts[3].toIntOrNull() != null
}
/*******************************************************************************
** Discovery data from another server never grants capability to this server's widgets.
*******************************************************************************/
fun available(server: String, discoveredServer: String, version: UiApiVersion?): Boolean =
server == discoveredServer && supports(version)
}
@@ -281,7 +281,10 @@ class ScannerActivity : ComponentActivity() {
},
LocalCustomWidgetRenderer provides { parameters, modifier, disabled ->
if (parameters.widgetBlock.blockType == "SCANNER") {
ScannerWidget(parameters, modifier, disabled)
ScannerWidget(
parameters, modifier, disabled,
fidelityAvailable = { ScanFidelityCapability.available(server, compatibility.server, compatibility.verifiedVersion()) }
)
true
} else if (parameters.widgetBlock.blockType == "RECORD_FILES_CAPTURE") {
RecordPhotoWidget(parameters, recordFiles, modifier, disabled)
@@ -0,0 +1,46 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import java.util.Base64
/*******************************************************************************
** One immutable decoded read: the SDK's text and symbology, the SDK's raw bytes already encoded once as
** canonical padded Base64, and an AIM identifier only when a qualified driver supplied it explicitly.
** Raw bytes are exact SDK output (possibly with SDK framing), not a claim of complete symbol capture.
*******************************************************************************/
internal data class ScannerCapture(
val barcode: String, val symbology: String, val rawDataBase64: String? = null, val aimIdentifier: String? = null
) {
init {
require(aimIdentifier == null || AIM.matches(aimIdentifier)) { "Invalid AIM identifier." }
}
/*******************************************************************************
** Scan content is business data and must not reach logs or crash reports.
*******************************************************************************/
override fun toString(): String = "ScannerCapture(redacted, raw=${if (rawDataBase64 == null) "absent" else "present"}, aim=${if (aimIdentifier == null) "absent" else "present"})"
/*******************************************************************************
** Raw limits live here so every source builds captures under the same transport limits.
*******************************************************************************/
companion object {
const val MAX_RAW_BYTES = 8192
private val AIM = Regex("\\][A-Za-z][0-9A-Za-z]")
/*******************************************************************************
** Encode SDK bytes once; the caller's array is never retained. Null, empty or oversize raw data is
** omitted rather than truncated, so an optional field is either exact SDK output or absent.
*******************************************************************************/
fun of(barcode: String, symbology: String, rawData: ByteArray? = null, aimIdentifier: String? = null): ScannerCapture =
ScannerCapture(
barcode, symbology,
if (rawData == null || rawData.isEmpty() || rawData.size > MAX_RAW_BYTES) null else Base64.getEncoder().encodeToString(rawData),
aimIdentifier
)
/*******************************************************************************
** Manual entry has no scanner evidence, so it carries neither raw bytes nor an AIM identifier.
*******************************************************************************/
fun manual(barcode: String) = ScannerCapture(barcode, "MANUAL")
}
}
@@ -17,7 +17,7 @@ internal interface ScannerDriver {
/*******************************************************************************
** Preserve raw barcode data and reject callbacks after ownership, readiness or generation changes.
*******************************************************************************/
fun scanned(barcode: String, symbology: String)
fun scanned(capture: ScannerCapture)
/*******************************************************************************
** Distinguish ordinary empty reads from rejected multi-barcode reads without fabricating a scan.
@@ -61,7 +61,7 @@ internal interface ScannerDriver {
*******************************************************************************/
internal class ScannerInputSession(
private val createDriver: () -> ScannerDriver?,
private val onScan: (String, String) -> Unit,
private val onScan: (ScannerCapture) -> Unit,
private val onStatus: (String, Boolean) -> Unit,
private val canAcceptInput: () -> Boolean = { true },
) {
@@ -122,13 +122,13 @@ internal class ScannerInputSession(
/*******************************************************************************
** Preserve raw barcode data and reject callbacks after ownership, readiness or generation changes.
*******************************************************************************/
override fun scanned(barcode: String, symbology: String) {
override fun scanned(capture: ScannerCapture) {
if (!isCurrent() || !accepting || !armed || !canAcceptInput()) return
armed = false
accepting = false
readNotice = null
onStatus("Waiting for scan confirmation…", false)
onScan(barcode, symbology)
onScan(capture)
// IDLE may precede data. Stop any read armed in that interval before accepting more input.
try {
if (driver === current && current.isReadPending()) current.cancelRead()
@@ -41,7 +41,8 @@ import com.kingsrook.qqq.frontend.android.mobileapp.ui.LocalProcessInputAllowed
@Composable
internal fun ScannerWidget(
parameters: WidgetBlockParameters, modifier: Modifier, disabled: Boolean,
driverFactory: (Context) -> ScannerDriver? = ::createScannerDriver
driverFactory: (Context) -> ScannerDriver? = ::createScannerDriver,
fidelityAvailable: () -> Boolean = { false }
) {
val context = LocalContext.current
val view = LocalView.current
@@ -82,7 +83,8 @@ internal fun ScannerWidget(
/*******************************************************************************
** Each new physical scan has an identity; retries are handled by ProcessViewModel.
*******************************************************************************/
fun submit(barcode: String, symbology: String, source: String): Boolean {
fun submit(capture: ScannerCapture, source: String): Boolean {
val barcode = capture.barcode
if (process == null) {
notice = "This scanner control requires an active process."
return false
@@ -96,9 +98,10 @@ internal fun ScannerWidget(
return false
}
notice = "Sending scan…"
val event = ScanEvent(barcode, symbology, source)
val event = ScanEvent(capture, source)
// Capability is read from cached verified discovery on the UI thread; it never triggers HTTP.
process.runStep(
event.values(), ProcessAcknowledgment(
event.values(includeFidelity = fidelityAvailable()), ProcessAcknowledgment(
"acknowledgedEventId", event.eventId,
requiredValues = mapOf("scanSaved" to true), nonEmptyStringFields = setOf("scanId")
)
@@ -111,12 +114,12 @@ internal fun ScannerWidget(
return accepted
}
val currentSubmit by rememberUpdatedState<(String, String, String) -> Boolean>(::submit)
val currentSubmit by rememberUpdatedState<(ScannerCapture, String) -> Boolean>(::submit)
val input = remember(context, process, driverFactory)
{
ScannerInputSession(
{ driverFactory(context) },
{ barcode, symbology -> currentSubmit(barcode, symbology, HARDWARE_SCAN_SOURCE) },
{ capture -> currentSubmit(capture, HARDWARE_SCAN_SOURCE) },
{ message, reconnect -> scannerStatus = message; canReconnect = reconnect },
canAcceptInput = { currentInputReady() })
}
@@ -156,7 +159,7 @@ internal fun ScannerWidget(
)
Button(enabled = ready && manual.isNotEmpty(), onClick = {
focusManager.clearFocus(); keyboard?.hide()
if (submit(manual, "MANUAL", "manual")) manual = ""
if (submit(ScannerCapture.manual(manual), "manual")) manual = ""
}) { Text(scannerMetaData.submitLabel) }
}
}
@@ -30,18 +30,24 @@ internal class ZebraScannerDriver(private val context: Context) : ScannerDriver
private var opening = false
private var ownsClaim = false
private val dataListener = Scanner.DataListener { collection ->
// Copy SDK-owned data now; the collection must not be read after this callback returns. AIM is never
// inferred here: it stays absent until a qualified driver obtains it from an explicit source.
val scans = collection?.scanData
var capture: ScannerCapture? = null
var multiple = false
if (collection?.result == com.symbol.emdk.barcode.ScannerResults.SUCCESS && !scans.isNullOrEmpty()) {
if (scans.size != 1) multiple = true
else {
val scan = scans.single()
val barcode = scan?.data
if (!barcode.isNullOrEmpty())
capture = ScannerCapture.of(barcode, scan.labelType?.toString() ?: "UNKNOWN", scan.rawData)
}
}
val delivered = capture
main.post {
if (!closed) {
val scans = collection?.scanData
if (collection?.result != com.symbol.emdk.barcode.ScannerResults.SUCCESS || scans.isNullOrEmpty())
listener?.noData(false)
else if (scans.size != 1) listener?.noData(true)
else {
val scan = scans.single()
val barcode = scan?.data
if (barcode.isNullOrEmpty()) listener?.noData(false)
else listener?.scanned(barcode, scan.labelType?.toString() ?: "UNKNOWN")
}
if (delivered != null) listener?.scanned(delivered) else listener?.noData(multiple)
}
}
}
@@ -139,7 +139,7 @@ class ScannerOverlayTest {
**
*******************************************************************************/
fun emit(value: String) {
pending = false; listener.scanned(value, "QRCODE"); listener.idle()
pending = false; listener.scanned(ScannerCapture.of(value, "QRCODE")); listener.idle()
}
}
@@ -1,14 +1,31 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiExtension
import com.kingsrook.qqq.frontend.android.core.controllers.UiApiVersion
import java.util.Base64
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
/*******************************************************************************
** Retrying a submission must not turn it into a second captured scan.
*******************************************************************************/
class ScanEventTest {
private val legacyKeys = setOf("barcode", "symbology", "scanSource", "eventId", "capturedAt")
/*******************************************************************************
**
*******************************************************************************/
private fun version(scanners: String?, path: String = "/qbit/mobile-scanners") = UiApiVersion(
"1.8.0", extensions = listOfNotNull(scanners?.let { UiApiExtension("mobile-scanners", it, path) })
)
/*******************************************************************************
**
*******************************************************************************/
@@ -19,4 +36,126 @@ class ScanEventTest {
assertEquals("001234", event.values()["barcode"])
assertNotEquals(event.eventId, ScanEvent("001234", "LABEL-TYPE-CODE128", "scanner").eventId)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun oldServerGetsExactlyTheLegacyFiveKeysEvenWhenFidelityIsCaptured() {
val event = ScanEvent(ScannerCapture.of("A", "QRCODE", byteArrayOf(1, 2), "]Q1"), "zebra-emdk")
assertEquals(legacyKeys, event.values().keys)
assertEquals(legacyKeys, event.values(includeFidelity = false).keys)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun fidelityValuesAreFlatStringsAndMissingKeysAreOmitted() {
val raw = byteArrayOf(0x1d, 0x41)
val full = ScanEvent(ScannerCapture.of("A", "QRCODE", raw, "]Q1"), "zebra-emdk").values(true)
assertEquals("]Q1", full["aimIdentifier"])
assertEquals(Base64.getEncoder().encodeToString(raw), full["rawDataBase64"])
val rawOnly = ScanEvent(ScannerCapture.of("A", "QRCODE", raw), "zebra-emdk").values(true)
assertEquals(legacyKeys + "rawDataBase64", rawOnly.keys)
assertEquals(legacyKeys, ScanEvent(ScannerCapture.manual("A"), "manual").values(true).keys)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun retryPayloadIsUnchangedAcrossRepeatedValueBuilding() {
val source = byteArrayOf(0, 1, 2)
val event = ScanEvent(ScannerCapture.of("A", "QRCODE", source, "]Q1"), "zebra-emdk")
val first = event.values(true)
source[0] = 9
assertEquals(first, event.values(true))
assertEquals(first, event.copy().values(true))
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun captureCopiesInputAndAllByteValuesRoundTrip() {
val all = ByteArray(256) { it.toByte() }
val capture = ScannerCapture.of("A", "QRCODE", all)
all.fill(0)
val expected = ByteArray(256) { it.toByte() }
assertArrayEquals(expected, Base64.getDecoder().decode(capture.rawDataBase64))
assertEquals(capture, ScannerCapture.of("A", "QRCODE", expected))
assertEquals(capture.hashCode(), ScannerCapture.of("A", "QRCODE", expected).hashCode())
assertNotEquals(capture, ScannerCapture.of("A", "QRCODE", expected, "]Q1"))
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun rawLengthLimitsOmitInsteadOfTruncating() {
assertNull(ScannerCapture.of("A", "Q", null).rawDataBase64)
assertNull(ScannerCapture.of("A", "Q", ByteArray(0)).rawDataBase64)
assertNull(ScannerCapture.of("A", "Q", ByteArray(8193)).rawDataBase64)
val max = ScannerCapture.of("A", "Q", ByteArray(8192) { 1 })
assertEquals(10924, max.rawDataBase64!!.length)
assertEquals("AA==", ScannerCapture.of("A", "Q", ByteArray(1)).rawDataBase64)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun aimMustBeOriginalWellFormedIdentifier() {
listOf("]Q1", "]c0", "]A0", "]z9").forEach { assertEquals(it, ScannerCapture.of("A", "Q", null, it).aimIdentifier) }
listOf("", "Q1", "]Q", "]Q11", "]11", "]Q!", "]Q1 ", "]Qé").forEach {
assertThrows(IllegalArgumentException::class.java) { ScannerCapture.of("A", "Q", null, it) }
}
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun stringFormsNeverLeakScanContent() {
val event = ScanEvent(ScannerCapture.of("SECRET-BARCODE", "QRCODE", "SECRET-RAW".toByteArray(), "]Q1"), "zebra-emdk")
listOf(event.toString(), event.capture.toString()).forEach {
assertFalse(it, it.contains("SECRET"))
assertFalse(it, it.contains(Base64.getEncoder().encodeToString("SECRET-RAW".toByteArray())))
assertFalse(it, it.contains("]Q1"))
}
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun capabilityRequiresMobileScanners15WithinMajor1AtRegisteredPath() {
assertTrue(ScanFidelityCapability.supports(version("1.5.0")))
assertTrue(ScanFidelityCapability.supports(version("1.12.3")))
listOf("1.4.9", "1.1.0", "2.0.0", "2.5.0", "1.5", "1.5.0-rc1", "01.5.0", "1.5.0.1", "", "1.99999999999.0").forEach {
assertFalse(it, ScanFidelityCapability.supports(version(it)))
}
assertFalse(ScanFidelityCapability.supports(version("1.5.0", "/qbit/other")))
assertFalse(ScanFidelityCapability.supports(version(null)))
assertFalse(ScanFidelityCapability.supports(null))
val duplicate = UiApiVersion(
"1.8.0", extensions = listOf(
UiApiExtension("mobile-scanners", "1.5.0", "/qbit/mobile-scanners"),
UiApiExtension("mobile-scanners", "1.5.0", "/qbit/mobile-scanners")
)
)
assertFalse(ScanFidelityCapability.supports(duplicate))
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun capabilityIsScopedToTheServerThatWasDiscovered() {
val v = version("1.5.0")
assertTrue(ScanFidelityCapability.available("https://a/", "https://a/", v))
assertFalse(ScanFidelityCapability.available("https://b/", "https://a/", v))
assertFalse(ScanFidelityCapability.available("https://a/", "https://a/", null))
}
}
@@ -65,8 +65,8 @@ class ScannerInputSessionTest {
/*******************************************************************************
**
*******************************************************************************/
fun scan(value: String = "001234567890") {
pending = false; listener.scanned(value, "QRCODE")
fun scan(value: String = "001234567890", raw: ByteArray? = null) {
pending = false; listener.scanned(ScannerCapture.of(value, "QRCODE", raw))
}
}
@@ -76,10 +76,11 @@ class ScannerInputSessionTest {
private class Fixture {
val devices = mutableListOf<Device>()
val scans = mutableListOf<Pair<String, String>>()
val captures = mutableListOf<ScannerCapture>()
val statuses = mutableListOf<String>()
val session = ScannerInputSession(
{ Device().also { devices.add(it) } },
{ value, type -> scans.add(value to type) }, { message, _ -> statuses.add(message) })
{ capture -> captures.add(capture); scans.add(capture.barcode to capture.symbology) }, { message, _ -> statuses.add(message) })
/*******************************************************************************
**
@@ -97,7 +98,7 @@ class ScannerInputSessionTest {
@Test
fun missingHardwareKeepsManualFallback() {
val statuses = mutableListOf<String>()
val session = ScannerInputSession({ null }, { _, _ -> error("Unexpected scan") }, { message, _ -> statuses.add(message) })
val session = ScannerInputSession({ null }, { error("Unexpected scan") },{ message, _ -> statuses.add(message) })
session.setAccepting(true)
session.setActive(true)
assertTrue(statuses.single().contains("manually"))
@@ -111,7 +112,7 @@ class ScannerInputSessionTest {
val device = Device()
var allowed = true
val scans = mutableListOf<String>()
val session = ScannerInputSession({ device }, { value, _ -> scans.add(value) }, { _, _ -> }, { allowed })
val session = ScannerInputSession({ device }, { capture -> scans.add(capture.barcode) }, { _, _ -> }, { allowed })
session.setAccepting(true)
session.setActive(true)
device.idle()
@@ -135,7 +136,7 @@ class ScannerInputSessionTest {
fun hiddenSurfaceNeverArmsEvenWhenOldReadinessFlagIsTrue() {
val device = Device()
var allowed = false
val session = ScannerInputSession({ device }, { _, _ -> error("Unexpected scan") }, { _, _ -> }, { allowed })
val session = ScannerInputSession({ device }, { error("Unexpected scan") }, { _, _ -> }, { allowed })
session.setAccepting(true)
session.setActive(true)
device.idle()
@@ -176,6 +177,27 @@ class ScannerInputSessionTest {
assertEquals(2, device.reads)
}
/*******************************************************************************
**
*******************************************************************************/
@Test
fun rawBytesReachTheSinkUnchangedAndStaleOwnersDeliverNothing() {
val fixture = Fixture()
val first = fixture.start()
first.idle()
val raw = ByteArray(256) { it.toByte() }
fixture.session.setActive(false)
fixture.session.setActive(true)
first.scan("stale", raw)
assertTrue(fixture.captures.isEmpty())
val second = fixture.devices.last()
second.idle()
second.scan("current", raw)
assertEquals("current", fixture.captures.single().barcode)
assertEquals(java.util.Base64.getEncoder().encodeToString(raw), fixture.captures.single().rawDataBase64)
assertTrue(fixture.captures.single().aimIdentifier == null)
}
/*******************************************************************************
**
*******************************************************************************/
@@ -188,7 +210,7 @@ class ScannerInputSessionTest {
device.idle()
assertEquals(2, device.reads)
// Deliver the first read's data while the second read is pending.
device.listener.scanned("001234567890", "QRCODE")
device.listener.scanned(ScannerCapture.of("001234567890", "QRCODE"))
assertEquals(1, device.cancels)
assertEquals(1, fixture.scans.size)
device.idle()
@@ -261,7 +283,7 @@ class ScannerInputSessionTest {
device.idle()
device.pending = false
device.idle()
device.listener.scanned("001234567890", "QRCODE")
device.listener.scanned(ScannerCapture.of("001234567890", "QRCODE"))
device.listener.noData(false)
device.idle()
assertEquals(1, device.cancels)