Building a QR scanner that never opens a link for you
Most QR scanner apps do the same thing the moment they see a code: open it. That's convenient right up until the code on a parking meter or a restaurant table has been swapped for a sticker that points somewhere else. F
Most QR scanner apps do the same thing the moment they see a code: open it. That's convenient right up until the code on a parking meter or a restaurant table has been swapped for a sticker that points somewhere else.
For MintScan, my QR and barcode scanner for Android, I wanted the opposite default: scan fast, show what the code actually contains, and let you decide. Here's how the scanning side works, and the small safety checks that sit between "decoded" and "opened".
Scanning: keep only the latest frame
The camera side is CameraX with an ImageAnalysis use case, and recognition is Google's bundled ML Kit barcode model, so decoding works offline and nothing is downloaded at first run.
The thing that matters most for feel is not the decoder, it's what you do with frames you can't process yet:
-
STRATEGY_KEEP_ONLY_LATEST. If analysis is still busy, older frames are dropped rather than queued. A queue means the result you see belongs to where the camera was pointing half a second ago. -
One engine, created once. An application-scoped
ScanEnginecaches an ML Kit client per mode (All, QR only, barcodes only) and warms the All client off the main thread, so the first scan isn't the slow one. - No overlapping work. Analysis runs on its own executor, with a guard so a second frame can't start while the first is still decoding, and a single-shot scan can't be delivered twice.
-
Mode changes don't restart the camera. Frames that were captured under the previous mode are simply ignored, and every
ImageProxyis closed when its work finishes. - The result doesn't wait for the database. The parsed record goes straight to the screen; saving it to history happens behind it.
The "All" mode reads QR, Aztec, Data Matrix and PDF417 plus the linear formats (EAN-13, EAN-8, UPC-A, UPC-E, Code 128, Code 39, Code 93, Codabar and ITF): 13 formats in total.
Decoded is not the same as opened
When a code contains a web address, MintScan shows it and offers an Open button. It never opens it by itself. Before you tap, it runs a few cheap checks on the URL:
fun linkWarnings(value: String): List<String> = buildList {
val uri = URI(value)
if (uri.scheme.equals("http", true)) add("This address uses an unencrypted HTTP connection.")
if (uri.userInfo != null) add("This address contains a username section. Check the actual destination carefully.")
if (uri.host?.contains("xn--") == true) add("The domain uses international characters. Check that it matches the site you expect.")
if (uri.host?.matches(Regex("[0-9.]+")) == true) add("This address uses an IP address instead of a domain name.")
}
Each one catches a classic trick:
-
userInfo:https://[email protected]/reads like your bank's address, but everything before the@is a username. The real host isevil.example. -
xn--: an internationalised domain arrives as punycode. A domain that looks like a familiar name but uses a lookalike letter from another alphabet shows up this way. - A bare IP address where you'd expect a brand's domain.
- Plain HTTP, which anyone on the same network can read or alter.
I want to be honest about what this is: these are advisory checks, not malware detection, and they can't promise a site is safe. What they do is turn "it opened" into "here's what it is, and here's anything unusual about it".
Sensitive codes aren't saved without asking
Some codes carry things you don't want lying around in a history list. A Wi-Fi code contains the network password; a contact code contains someone's phone number and email.
So history is on by default, but Wi-Fi and contact scans are not saved automatically. You can still save or favourite them yourself. Wi-Fi passwords stay hidden until you tap to reveal them, and copying or sharing one asks first. On Android 11 and later, MintScan can join the network directly from the code.
Two export details worth stealing
CSV formula injection. MintScan can export your library as CSV. A QR code's content is attacker-controlled text, and a cell starting with =, +, - or @ is a formula when the CSV is opened in a spreadsheet. So every cell is neutralised before it's written:
// Neutralise spreadsheet formula prefixes even when a payload is quoted.
val value = if (s.trimStart().firstOrNull() in listOf('=', '+', '-', '@', '\t', '\r')) "'$s" else s
return "\"${value.replace("\"", "\"\"")}\""
Encrypted backups. The backup file is AES-256-GCM with a random salt and IV, the key derived with PBKDF2 at 600,000 iterations, and the file header authenticated as additional data, so a tampered header fails just like a wrong password. Input is capped at 32 MiB, and restore merges by record ID instead of wiping what's already there. There's no password recovery: if there were, the encryption wouldn't mean much.
Creating codes
The other half of the app makes codes: websites, text, Wi-Fi, contacts, email, phone, SMS, locations and calendar events as QR, plus Code 128, EAN-13, EAN-8 and UPC-A barcodes. Generation uses ZXing, which also helps check that a styled code still scans. Wi-Fi and vCard payloads need their own escaping (;, :, , and quotes in a network name break a Wi-Fi code surprisingly easily).
What I took from it
- Drop frames instead of queueing them. Responsiveness is about freshness, not throughput.
- Treat every decoded string as untrusted input, the same way you'd treat a form field: before opening it, before saving it, and before exporting it.
- Make the safe behaviour the default and the convenient one a single tap away.
MintScan is on Google Play. Scanning, creating codes and history are free, with no ads and no account; Pro is a one-time purchase for batch scanning, collections, CSV export and custom-styled codes. If you've seen a QR trick these checks would miss, I'd like to hear it.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.