A screenshot of a QR code is the easy case: sharp edges, perfect contrast, square to the camera. A photo of a printed shop sticker is not. It is faded, a little curled, shot at an angle, often under one tube light. I had exactly that case: Google ML Kit's barcode scanner read the screenshot of a QR instantly, and returned nothing for a phone photo of the same sticker. This post is the fallback ladder that fixed it, and two smaller lessons about passing the decoded text on.
Disclosure: I'm the developer of Auto UPI QR, the app this code comes from, and this post is published from my account. It was written and published by an AI agent working on my behalf. The scanner described here is in the next version of the app and is not on Google Play yet.
The idea: try harder only when the cheap pass fails
Most images decode on the first try, so the expensive work must never run for them. The order is:
- ML Kit on the image as it is.
- ML Kit on smaller copies (longest side 1600, 1000 and 640 px).
- ZXing with
TRY_HARDER, over two different binarizers, also looking for inverted codes.
It stops at the first result that is a valid UPI link, not merely any QR.
suspend fun scan(context: Context, imageUri: Uri): Result = try {
(mlKit(InputImage.fromFilePath(context, imageUri))
?: withContext(Dispatchers.Default) { retry(context, imageUri) })
?.let { Result.Found(it) } ?: Result.NoUpiQr
} catch (e: Exception) {
Result.Error
}
Why smaller copies help
It feels backwards, but a full-resolution phone photo can be harder to decode. At 4000 px across, sensor noise and print texture break each QR module into many speckled pixels. Downscaling averages that away and makes the modules look like clean squares again. So the retry builds a few copies:
private val SIZES = intArrayOf(1600, 1000, 640)
private suspend fun retry(context: Context, uri: Uri): UpiQr? {
val base = load(context, uri, SIZES.first()) ?: return null
val copies = SIZES.map { scaled(base, it) }
try {
for (b in copies) mlKit(InputImage.fromBitmap(b, 0))?.let { return it }
for (b in copies) zxing(b)?.let { return it }
return null
} finally {
copies.forEach { if (it !== base) it.recycle() }
base.recycle()
}
}
Two memory details matter on low-end phones:
-
Decode with
inSampleSize, not by loading the full bitmap and shrinking it. A first pass withinJustDecodeBounds = truereads only the dimensions; then the image is decoded at a power-of-two sample size close to 1600 px. -
Recycle in
finally, and do not recycle the base twice when a "copy" is the base itself (the!==check). Without it, a run of failed scans on a 2 GB phone adds up fast.
Why a second decoder, and why two binarizers
ML Kit and ZXing fail differently. ZXing with these hints succeeded on the sticker where ML Kit failed at every size:
private val zxingHints = mapOf(
DecodeHintType.TRY_HARDER to true,
DecodeHintType.ALSO_INVERTED to true,
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.CHARACTER_SET to "UTF-8",
)
Before decoding, ZXing turns the picture into pure black and white (binarisation). HybridBinarizer chooses a threshold per small block, which suits most photos. GlobalHistogramBinarizer uses one threshold for the whole image, which sometimes copes better with a smooth light-to-dark gradient across the sticker. Trying both costs milliseconds and only runs after everything else has failed.
ALSO_INVERTED handles light-on-dark codes, which some printed designs use.
Lesson 2: Android intent schemes are case-sensitive
Some printed QR codes carry UPI://PAY?... in capitals. The URI scheme is supposed to be case-insensitive, but Android's intent-filter matching is not, so an ACTION_VIEW with UPI://PAY finds no app at all. The parser lower-cases only the scheme and leaves everything else alone:
private val PREFIX = Regex("^\\s*upi://pay", RegexOption.IGNORE_CASE)
fun parse(text: String?): UpiQr? {
val m = PREFIX.find(text ?: return null) ?: return null
val normalised = "upi://pay" + text.substring(m.range.last + 1).trimEnd()
val qr = UpiQr(normalised)
return if (qr.payeeVpa.isNullOrBlank()) null else qr
}
Lesson 3: pass the rest on byte-for-byte
It is tempting to rebuild a clean link from the parameters you parsed (pa, pn, am). Don't. A merchant QR can carry signed fields such as mc, tr, mode, orgid and sign, and re-encoding or reordering them can make the UPI app reject the request. The app reads the payee address and name for display, and hands the original text, with only the scheme case fixed, to the user's own UPI app.
The debug build logs which pass decoded the image and the exact text, so a byte-for-byte comparison against the QR is one adb logcat away.
Results worth checking in your own app
- Keep a folder of the images that failed, including real photos of real stickers, and run every change against it.
- Measure the slow path on your slowest supported phone; it should only ever run on images that already failed once.
- Reject anything that is not a valid payment link, so a random QR in the photo (a Wi-Fi code, a website) is not mistaken for one.
Where this lives
Auto UPI QR reads a UPI QR from an image you share to it, on the device and offline, and opens the UPI app you already use with the details filled in. It does not move money; your UPI app does that after you confirm. Always check the payee name before entering your UPI PIN.









