ML Kit barcode scanning on Android in 2026: how Stocky adds a pantry item in under a second
A practical 2026 guide to on-device barcode scanning with ML Kit and CameraX: format tuning, offline product lookup, and the partial-use math behind a pantry app.
Typing a grocery item into an app is a small tax you pay every single time you buy it. Name, brand, size, maybe a category — for someone stocking a kitchen, that’s thirty seconds of friction multiplied by every product they own. The only version of a pantry app worth using has to beat that math, which means the barcode has to do almost all the work.
That’s the constraint Stocky is built around: point the camera at a barcode, and the product is in the pantry a second later — name, size, category, nutrition data, all filled in. Here’s the pipeline behind that second, and the parts that took longer to get right than the scanning itself.
The scanning pipeline: CameraX + ML Kit Barcode Scanning
ML Kit’s Barcode Scanning API is a separate model from the Text Recognition one — smaller, faster, and built for exactly one job: finding a barcode’s format and encoded value inside a camera frame. Setup is a single Gradle line plus a CameraX analyzer:
// app/build.gradle.kts
dependencies {
implementation("com.google.mlkit:barcode-scanning:17.3.0")
implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0")
}
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.common.InputImage
import androidx.camera.core.ImageProxy
private val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(
Barcode.FORMAT_EAN_13,
Barcode.FORMAT_EAN_8,
Barcode.FORMAT_UPC_A,
Barcode.FORMAT_UPC_E,
)
.build()
private val scanner = BarcodeScanning.getClient(options)
@androidx.camera.core.ExperimentalGetImage
fun analyzeFrame(image: ImageProxy, onFound: (String) -> Unit) {
val media = image.image ?: return image.close()
val input = InputImage.fromMediaImage(media, image.imageInfo.rotationDegrees)
scanner.process(input)
.addOnSuccessListener { barcodes ->
barcodes.firstOrNull()?.rawValue?.let(onFound)
}
.addOnCompleteListener { image.close() }
}
The setBarcodeFormats call matters more than it looks. Left at the default, the scanner also hunts for QR codes, PDF417, Aztec, and half a dozen other formats it will never see on a can of tomatoes — every frame pays for that search. Retail products live almost entirely in EAN-13, EAN-8, UPC-A, and UPC-E. Restricting to those four formats cuts wasted work on every frame and, more usefully, cuts false-positive detections from stray patterns in the background.
From a 13-digit number to a product you can show
A barcode is just digits. The actual product — name, brand, package size, nutrition facts — has to come from somewhere else. Stocky resolves it against Open Food Facts, the open, crowd-sourced product database, layered with community-submitted Turkish products the public dataset misses.
The lookup itself is a plain REST call keyed on the scanned code:
suspend fun lookupProduct(barcode: String): ProductInfo? {
localCache.find(barcode)?.let { return it }
return runCatching {
api.getProduct(barcode).toProductInfo()
}.getOrNull()?.also { localCache.save(barcode, it) }
}
Two decisions here carry more weight than the code suggests:
- Cache-first, not network-first. Once a barcode has been resolved once — by anyone, including a previous scan on the same device — it’s in Room and the next scan is instant and offline. For a pantry app, the same dozen products (milk, eggs, the same brand of bread) get rescanned constantly; the cache hit rate climbs fast.
- A miss isn’t a dead end. Unbranded or region-specific products won’t always be in Open Food Facts. When the lookup comes back empty, Stocky drops straight into a short manual-entry form pre-filled with the barcode, instead of a bare error. The user finishes in two fields, and — with consent — that entry can strengthen the community dataset for the next person who scans it.
Keeping “under a second” honest
The scanning model itself resolves a barcode in well under 100ms on a mid-range phone. Almost all of the perceived latency budget goes to everything around it: camera warm-up, the analyzer’s frame rate, and avoiding duplicate detections.
The detail that actually matters for feel: without debouncing, a held camera fires the analyzer on every frame — twenty-plus detections of the same barcode in the time it takes a user’s hand to steady. Firing a lookup, a haptic, and a UI update for each one reads as jank, not speed.
private var lastCode: String? = null
private var lastAt = 0L
fun onBarcodeFound(code: String, now: Long) {
if (code == lastCode && now - lastAt < 1_500) return
lastCode = code
lastAt = now
handleScan(code)
}
A 1.5-second debounce window per code is enough to collapse a whole detection burst into one scan, while still letting the user immediately rescan the same item on purpose (add a second yogurt cup to the pantry, say) once they’ve moved the camera away and back.
The partial-use problem
A pantry item isn’t a binary present-or-absent flag, and that’s the part barcode scanning alone doesn’t solve. Stocky’s actual data model tracks a remaining quantity against each package: a 1-liter milk carton isn’t “in stock” or “gone,” it’s 1000ml, then 700ml after breakfast, then 0 and quietly moved onto the shopping list. Scanning the barcode again on the same package lets the user log a partial draw — “drink 300 ml” — instead of forcing a full “used it, add a new one” cycle for something that’s still half full in the fridge.
That single design choice is what makes the runs-out predictions honest later: they’re built on a real consumption curve per product, not a coarse in-stock/out-of-stock toggle that resets to zero every time and tells you nothing about pace.
What stays on the device, and what doesn’t
The frame never leaves the phone — ML Kit’s barcode model runs entirely on-device, same as its text recognition sibling. The only thing that crosses the network is the barcode digits themselves, sent to look up a product that millions of other shoppers have already scanned. No photo is captured or stored, and nothing about who is scanning goes with the request. Pantry contents, consumption history, and spending stay local; only the anonymous product lookup is shared.
That’s a smaller privacy surface than OCR, and it’s worth remembering when you’re choosing between vision APIs for a feature: if the value you need is a fixed, structured identifier — a barcode, not a page of prose — a scanner model is both faster to build against and easier to defend in a privacy policy than a recognizer that has to read someone’s document.
Stocky is live on Google Play — scan a barcode, and the rest of the kitchen tracking follows from there. More about it on the app page.
// Related reading
More from the journal
Jetpack DataStore in 2026: migrating off SharedPreferences without losing a setting
A practical guide to moving from SharedPreferences to Jetpack DataStore on Android — the async pitfalls, the migration path that preserves existing values, and how to test it.
Room database migrations in 2026: shipping schema changes without losing a row
A practical guide to Room database migrations on Android — AutoMigration, hand-written Migration objects, and how to test a migration before your users find the bug.
Baseline Profiles on Android: what actually moves your cold-start time in 2026
A practical guide to Android Baseline Profiles — generating one with Macrobenchmark, wiring it into Gradle, measuring the real gain, and the three other things that move cold start more.