Blog

One Scan, Many Frames: Handing a Barcode to the App Once

A camera can surface the same barcode across frames. How a small gate and a generation guard keep one scan cycle from becoming duplicate or stale results.

Epicnesia article cover with the title One Scan, Many Frames: Handing a Barcode to the App Once, beside a stack of identical barcode frames converging through a narrow gate into one highlighted barcode.

When I hold a barcode in front of the camera, I have scanned once. The scanner pipeline sees something different. The camera produces consecutive frames, and the decoder can find the same logical barcode in more than one of them. From the app’s side, a single scan can arrive as several candidate results.

The question for the Inventory Kit scanner was how to turn those candidates into one result the app acts on, without picking a delay and hoping. Two small pieces do that: a class called ScanGate, and a generation counter in ScannerViewModel. This article walks through both, and through what their tests do and don’t show.

Why not just wait a moment

The obvious fix is a timer: ignore detections for some number of milliseconds after the first one. I didn’t build that, because a time window can’t answer the questions that matter. It doesn’t know whether the app has finished with the previous result. It doesn’t know whether the barcode is still in front of the camera. And it doesn’t know whether the user has started a new scan.

The documentation on ScanGate says this directly. Its two-signal re-arm path waits for the host to acknowledge the result and for the barcode to be absent, and the docs state it plainly: “Neither condition is time-based.” The app as shipped ends a cycle with an explicit reset() when the user taps Scan Another, which I come back to below. I call it a single-consumption gate: it hands the app one accepted barcode per scan cycle, and a cycle ends when the app says so.

A gate with two states

The gate is a small state machine. It is either ready, or locked on a barcode. These are the types, from ScanGate.kt, lines 10 to 17, with the shared source indentation removed:

sealed interface ScanGateState {
    data object Ready : ScanGateState
    data class Locked(
        val barcode: DecodedBarcode,
        val acknowledged: Boolean,
        val absent: Boolean,
    ) : ScanGateState
}

Locked carries the barcode that took the lock and two flags I’ll come back to. One AtomicReference holds this whole state, and acceptance is a single compare-and-set on it (ScanGate.kt, lines 58 to 61, same indentation treatment):

fun tryAcquire(barcode: DecodedBarcode): DecodedBarcode? {
    val locked = ScanGateState.Locked(barcode, acknowledged = false, absent = false)
    return if (current.compareAndSet(ScanGateState.Ready, locked)) barcode else null
}

If the state is still Ready, the caller swaps in Locked and gets the barcode back. Otherwise the call returns null. A detection that arrives while the gate is locked is rejected whether it carries the same value or a different one, so a second code drifting into view can’t take over the cycle.

BarcodeImageAnalyzer also looks at the gate before it decodes a frame. If the state isn’t Ready, it closes the frame and returns. That check saves work, and I don’t count on it for correctness. Decoding is asynchronous, so a frame can be in flight when the gate locks and finish afterwards. Its result still goes through tryAcquire and is rejected there like any other detection made while locked.

One more rule sits in front of the gate. If a single frame yields two or more distinct barcodes, the analyzer acquires nothing, because picking one would let the decoder’s internal ordering decide which product gets processed. Identical results within a frame collapse into one.

State diagram of the shipped scan cycle: Ready to Locked when one of many callers wins tryAcquire(), later candidates rejected while Locked, and Scan Another calling reset() back to Ready. A separate dashed panel shows a tested re-arm capability, not wired into the current app flow, where acknowledge() and previousBarcodeBecameAbsent() return Locked to Ready once both have arrived, in either order.

What the concurrency test shows

The claim I most wanted a test for is that concurrent callers can’t both win. This is concurrentFrames_allowExactlyOneThroughPerArmedCycle, from ScanGateTest.kt, lines 152 to 174, shown with the shared source indentation removed:

@Test
fun concurrentFrames_allowExactlyOneThroughPerArmedCycle() {
    val threads = 8
    val cycles = 3_000
    withPool(threads) { pool ->
        val gate = ScanGate()
        repeat(cycles) { cycle ->
            val accepted = AtomicInteger(0)
            val start = CyclicBarrier(threads)
            val done = CountDownLatch(threads)
            repeat(threads) {
                pool.execute {
                    start.await()
                    if (gate.tryAcquire(barcodeA) != null) accepted.incrementAndGet()
                    done.countDown()
                }
            }
            assertTrue(done.await(10, TimeUnit.SECONDS))
            assertEquals("cycle $cycle: exactly one frame may pass", 1, accepted.get())
            gate.reset()
        }
    }
}

Eight real threads wait on a CyclicBarrier, then all call tryAcquire at once. The test counts how many get a barcode back, asserts that the count is one, resets the gate and goes again, 3,000 times. There are no sleeps. A check-then-act race in the gate could show up as two winners in a cycle.

What that shows is narrow: concurrent callers reaching the gate resolve to one winner per cycle. The app’s analysis executor has a single thread, so the test pushes harder than that setup does. The decoder’s results come back through ML Kit’s task listeners, and I haven’t pinned down which thread those run on, so I didn’t want the gate to depend on it. The test says nothing about how CameraX schedules frames on any particular phone. It also can’t tell me how often a real camera produces repeated detections. The gate is designed to stay safe when repeated candidates reach it.

The second race is a late answer

The gate decides who may start a scan result. It has nothing to say about whether that result is still wanted when the product lookup finishes.

The lookup is asynchronous. Say the lookup for code A is running and the user taps Scan Another, or types a code by hand. Lookup B starts. If A’s repository call returns after that, it must not overwrite what B is doing.

ScannerViewModel handles this with a generation counter. Starting a lookup, tapping Scan Another and submitting a manual code each advance it, under a lock that guards only that bookkeeping. Every lookup remembers the generation it started with, and publishing checks it. This is publishIfCurrent, from ScannerViewModel.kt, lines 143 to 148, with the shared source indentation removed:

private fun publishIfCurrent(lookupGeneration: Long, outcome: ScannerStatus) {
    synchronized(lock) {
        if (lookupGeneration != generation) return
        setState { it.copy(status = outcome) }
    }
}

If the numbers differ, the result is dropped. Success, not found, ambiguous and error outcomes all pass through this same check. I don’t rely on cancelling the old job for correctness. A comment in the code notes that a repository call can complete despite cancellation, so the generation check is what makes a stale answer harmless.

Two ownership rules sit alongside it. Camera detections are accepted only while the screen is in its Ready status, so a detection can’t take over a pending lookup or replace a result already on screen. A manual submission is a deliberate user action, so it takes over: it cancels the pending lookup and invalidates it. The concurrency tests complete each lookup by hand, in an order the test picks, which makes these races deterministic. They show that the ordering rules hold. They aren’t a measurement of real timing.

What the app does after a scan

This is where the shipped behaviour differs from what the gate can do. In the current Inventory Kit scanner, the first accepted barcode locks the gate, and it stays locked until the user taps Scan Another. That handler calls viewModel.onNewScanRequested() and then scanGate.reset(), which returns the gate to Ready unconditionally.

ScanGate also supports a two-signal re-arm path. The host calls acknowledge() once it has handled the result, and whatever owns presence tracking for the camera and decoder calls previousBarcodeBecameAbsent() when the barcode has left. The gate doesn’t observe absence itself. When both signals have arrived, in either order, it goes back to Ready. The tests cover each signal alone being insufficient, both orders, and the two signals arriving from different threads. Production code supplies neither signal today, so reset() is the path the app takes, and the two-signal re-arm is a tested capability of the gate.

What this doesn’t establish

The gate’s own state is thread-safe: one AtomicReference holds it, and every transition is a compare-and-set apart from reset(), which sets the value directly. One concurrent attempt wins Ready to Locked. The ViewModel rejects stale lookup results using generations. JVM unit tests exercise all of that.

That is the extent of it. A gate and a generation counter don’t establish that a barcode can never be acted on twice somewhere further downstream. CameraX scheduling, decoder timing, camera hardware and differences between phones are outside what these tests cover, and JVM tests can’t stand in for trying the scanner on real hardware. Nothing here describes how any particular device behaves.

Scanning is the visible part of this feature in Inventory Kit. The part I find worth writing down sits behind it: who is allowed to start a result, and what happens to an answer that arrives after the question has changed. Those rules are small enough to read in one sitting, which is also what makes them testable.