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:
16 files changed
+349
-42
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
+31
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user