Android port of Scan, an app for reading and generating 1D and 2D barcodes. Built in Jetpack Compose on top of CameraX + ML Kit (live scanning), ZXing (generation), Room (history), and Hilt (DI). The point of the app is not just to decode a code, but to understand what's in it: scan a Wi-Fi QR and it shows the SSID and offers to open Wi-Fi Settings; scan a SEPA invoice and it surfaces the IBAN, beneficiary, and amount as separate copyable rows; scan a fiscal receipt and it shows the fiscal markers; and so on.
The payload-decomposition layer is a line-for-line Kotlin port of the iOS app's parsers — same recognised formats, same field labelling, same smart actions where the platform offers an equivalent intent.
Live-camera scanning of every symbology ML Kit's bundled barcode model supports: QR, Aztec, PDF417, Data Matrix, EAN-8 / EAN-13, UPC-A / UPC-E, Code 39, Code 93, Code 128, ITF, and Codabar. The viewfinder uses CameraX's PreviewView with an ImageAnalysis use-case running the ML Kit BarcodeScanner on each frame; results are streamed to the UI via a StateFlow with a 1.5 s dedupe window so the analyzer can run at full frame-rate without spamming the result sheet. Torch toggle, Photo-Picker import (uses ActivityResultContracts.PickVisualMedia so no READ_MEDIA_IMAGES permission is required), inline error surface for malformed images.
The decoded string is parsed and rendered as structured fields with per-row tap-to-copy. Recognised formats:
| Domain | Formats |
|---|---|
| Web / messaging | URL, mailto:, tel:, sms: / smsto: |
| Connectivity | WIFI: (SSID + password + security) |
| Geolocation | geo: |
| Identity | vCard (3.0), MECARD |
| Calendar | iCalendar VEVENT (line-folded, UTC / TZID / all-day dates) |
| Authentication | otpauth:// |
| Retail | EAN-8 / EAN-13 / UPC-A / UPC-E / ITF-14 product codes |
| Cryptocurrency | Bitcoin (BIP-21), Ethereum (EIP-681 with chain ID), Litecoin, Bitcoin Cash, Dogecoin, Monero, Cardano, Solana, Lightning (BOLT-11) |
| Bank payments | EPC SEPA Payment QR / GiroCode (EU), Swiss QR-bill (SPC), Czech SPD (Spayd), Slovak Pay by Square (recognition only — decoding needs LZMA), EMVCo Merchant QR with nested-template drilling for Pix, PayNow, PromptPay, CoDi, UPI-via-EMVCo, DuitNow, QRIS, FPS, NAPAS, NETS and friends, Indian UPI (upi://pay), Bezahlcode (German legacy bank:// / bezahlcode://), Serbian NBS IPS QR (Prenesi — PR / PT / PK) |
| Mobile-payment apps | Swish (Sweden, base64-JSON-encoded swish://), Vipps (Norway), MobilePay (Denmark / Finland), Bizum (Spain), iDEAL (Netherlands) |
| Receipts | Serbian SUF fiscal receipt |
Per payload type, dispatched via standard Android Intents so any installed handler app picks them up:
- URL —
ACTION_VIEWwith the URI. - Email / Phone / SMS —
ACTION_SENDTOwithmailto:/tel:/sms:, withsubject/bodyquery params populated. - Wi-Fi — Show network details, copy password, open
Settings.ACTION_WIFI_SETTINGS. - Location —
ACTION_VIEWwithgeo:URI; resolved by Google Maps, OsmAnd, etc. - Contact —
ContactsContract.Intents.Insert.ACTIONopens the system "New Contact" form pre-filled from the vCard / MECARD fields. - Calendar —
ACTION_INSERTagainstCalendarContract.Events.CONTENT_URIopens the system "Add Event" form with summary / location / description / start / end / all-day pre-filled. - Crypto —
ACTION_VIEWwith the original BIP-21 / EIP-681 / BOLT-11 URI; the OS picks an installed wallet via the URI scheme. - Bank payments — Per-field copy (IBAN, amount, recipient, reference, INN, KPP, KBK, OKTMO, Czech variable / constant / specific symbols, …). Currency mapped via ISO 4217 numeric → alpha for EMVCo. Nested EMVCo templates render with a "↳" marker so individual sub-fields (Pix key, PayNow merchant ID, PromptPay phone, etc.) are individually copyable.
- UPI —
ACTION_VIEWwith theupi://URI; the OS picks an installed UPI app — PhonePe, GPay, Paytm, BHIM… - Mobile-payment apps —
ACTION_VIEWwith the registered URI scheme. - Serbian SUF receipt — Open the official PURS verification page.
- All payloads — Copy raw to the system clipboard, Share via
ACTION_SEND.
A dedicated Generate tab builds 1D / 2D codes from structured input via ZXing's MultiFormatWriter:
- Inputs — Text, URL, Contact (emits well-formed vCard 3.0), Wi-Fi (emits the standard
WIFI:payload with proper escaping). - Symbologies — QR, Aztec, PDF417, Code 128.
- Outputs — Save the rendered PNG to
MediaStoreunderPictures/Scan/, share viaACTION_SENDwith the resulting content URI, or copy the encoded string to the clipboard. - Live preview that re-renders on every keystroke; integer-scaled rendering for crisp module edges.
- Saved scans persist to Room (
scan_database/scan_recordstable), exposed to Compose as aStateFlow<List<ScanRecord>>. - Searchable list with relative timestamps and a payload-kind icon.
- Per-record detail bottom sheet with editable notes (auto-persist on change), smart actions, and delete.
The launcher icon takes the QR motif from the iOS asset (a real, scannable QR code that decodes to https://nettrash.me) and presents it on a deep-blue radial gradient. The yellow viewfinder brackets from the iOS icon are intentionally omitted on Android — they fought with the various launcher mask shapes (squircle, teardrop, circle) which clipped one or two of the four brackets unevenly. Packaged as an adaptive icon: the cleaned QR is the foreground (transparent-background per-density PNGs at mipmap-*/ic_launcher_foreground.png, 108–432 px) and the gradient is a vector drawable (drawable/ic_launcher_background.xml). Legacy ic_launcher.png and round ic_launcher_round.png are composed at build-time from the same two layers so pre-Android 8 launchers see the identical look.
compileSdk/targetSdk: 36 (mirrorsGeo.Android).minSdk: 28 (Android 9).- JDK: 17 (set via
compileOptionsandkotlin.compilerOptions.jvmTarget). - Gradle: 9.4.1 (per
gradle/wrapper/gradle-wrapper.properties). - AGP: 9.1.1.
- Kotlin: 2.3.20.
- Android Studio: latest stable that ships with a Compose Compiler matching Kotlin 2.3.
Declared in AndroidManifest.xml:
| Permission | Used for |
|---|---|
android.permission.CAMERA |
Live barcode scanning. Requested at runtime via Accompanist Permissions when the user first opens the Scan tab. |
android.permission.INTERNET |
Reserved for future use; no network calls today. |
android.permission.READ_MEDIA_IMAGES |
Declared for SDK 33+, but the Photo Picker flow uses ActivityResultContracts.PickVisualMedia and does not require this permission to be granted on Android 13+. |
android.permission.READ_EXTERNAL_STORAGE (maxSdkVersion=32) |
Legacy storage for picked-image reads on Android 12 and below. |
Hardware features:
android.hardware.camera— required.android.hardware.camera.autofocus— optional.
The app never reads the user's contacts, calendar, or photos directly — every privileged action is mediated by a system-supplied edit-and-save UI launched via Intent.
git clone https://github.com/nettrash/Scan.Android.git
cd Scan.Android
./gradlew :Scan:assembleDebugOr open the project root in Android Studio. The first sync downloads the Gradle distribution (9.4.1) and the AGP / Compose / CameraX / ML Kit / ZXing / Room / Hilt artifacts via the google() and mavenCentral() repositories declared in settings.gradle.kts. Subsequent builds are incremental.
To install on a connected device or emulator:
./gradlew :Scan:installDebugUnit tests live under Scan/src/test/java/me/nettrash/scan/ and mirror the iOS ScanTests/ScanTests.swift suite line for line — same fixtures, same assertions, against the Kotlin port of every parser. They run on the JVM with JUnit 4 + Robolectric (Robolectric provides a real android.net.Uri and android.util.Base64 so the parsers that touch those APIs exercise the same code paths as on a device).
Coverage:
data/payload/ScanPayloadParserTest— URL, Wi-Fi (with escaped semicolon),mailto:/tel:/sms:/geo:, vCard 3.0, MECARD, EAN-13 product code, kind labels, EPC-priority-over-text.data/payload/BankPaymentParserTest— EPC SEPA Payment, EMVCo Merchant QR (top-level + nested-template drilling for Pix), Swiss QR-bill (31-line minimum), Serbian SUF receipt URL, Serbian NBS IPS QR.data/payload/CryptoUriParserTest— Bitcoin (BIP-21), Ethereum with@chainId(EIP-681), Lightning (BOLT-11).data/payload/CalendarParserTest— VEVENT (UTCZsuffix), all-day event (VALUE=DATE).data/payload/RegionalPaymentParserTest— UPI (upi://pay), Czech SPD (with+-as-space), Slovak Pay by Square recognition + lookalike rejection, Bezahlcode (bank://), Swish (base64-JSONdata=blob), Vipps.generator/CodeComposerTest— vCard composer + parser round-trip, Wi-Fi composer + parser round-trip, open-network composer omits theP:field.
Run from the terminal:
./gradlew :Scan:testDebugUnitTest # the standard bucket
./gradlew :Scan:test # both debug + release
./gradlew :Scan:test --tests "*BankPaymentParserTest" # one classIn Android Studio, right-click Scan/src/test/java in the Project pane → Run 'Tests in 'java'', or click the green ▶ in the gutter next to a test class / method.
The unit-test bucket doesn't trigger the assemble* / bundle* lifecycle, so running tests does not bump versionCode.
Scan.Android/
├─ build.gradle.kts root project plugins
├─ settings.gradle.kts single :Scan module
├─ gradle/libs.versions.toml version catalog
└─ Scan/
├─ build.gradle.kts module config (compose, hilt, ksp, room)
└─ src/main/
├─ AndroidManifest.xml
├─ res/ themes, strings, adaptive launcher icons
└─ java/me/nettrash/scan/
├─ ScanApplication.kt @HiltAndroidApp + WorkManager Configuration
├─ MainActivity.kt single-activity Compose host
│
├─ di/AppModule.kt Hilt module — Room database + DAO
│
├─ data/
│ ├─ db/ Room: ScanRecord entity, DAO, database
│ └─ payload/ payload models + parsers (port of iOS)
│ ├─ ScanPayload.kt sealed class + master parser
│ ├─ BankPaymentPayloads.kt EPC, Swiss, EMVCo, Serbian
│ ├─ RegionalPaymentPayloads.kt UPI, Czech SPD, Pay by Square, Bezahlcode, Swish, Vipps, MobilePay, Bizum, iDEAL
│ ├─ CryptoPayload.kt BIP-21 / EIP-681 / BOLT-11
│ ├─ CalendarPayload.kt RFC 5545 VEVENT parser
│ └─ LabelledField.kt
│
├─ scanner/ live + still-image scanning
│ ├─ Symbology.kt ML Kit format → display name mapping
│ ├─ ScannedCode.kt
│ ├─ BarcodeAnalyzer.kt CameraX ImageAnalysis.Analyzer + ML Kit
│ └─ ImageDecoder.kt decode from a content Uri (Photo Picker)
│
├─ generator/ code generation
│ ├─ CodeGenerator.kt ZXing MultiFormatWriter wrapper
│ └─ CodeComposer.kt vCard 3.0 + WIFI: composers
│
└─ ui/ Compose UI
├─ MainScreen.kt NavigationBar host (Scan / Generate / History)
├─ theme/ Material 3 dark theme
├─ scanner/ ScannerScreen + ScannerViewModel + result sheet
├─ generator/ GeneratorScreen
├─ history/ HistoryScreen + HistoryViewModel + ScanDetailDialog
└─ components/ PayloadActions (smart actions + LabelledFieldsList)
- Single Activity, Compose-only navigation.
MainActivityhostsMainScreenwhich wiresandroidx.navigation.compose.NavHostto three composable destinations. - Hilt + KSP for DI.
@HiltViewModelclasses are constructor-injected with theirScanRecordDao. The singleAppModuleprovides the Room database as a@Singleton. - CameraX + ML Kit on the analyzer thread.
BarcodeAnalyzeris bound as anImageAnalysis.AnalyzerwithSTRATEGY_KEEP_ONLY_LATESTand runs on a single-thread executor. Successful decodes are forwarded to the view model on the main thread; the dedupe filter is checked there to avoid races on the dedupe state. - Room exposes
Flow<List<ScanRecord>>which the history view model collects withstateIn(SharingStarted.Eagerly)so the list survives configuration changes without re-querying. - Privileged actions are intent-launched, never directly performed. Add-to-Contacts and Add-to-Calendar both go through the system-provided edit forms; the app never holds
READ_CONTACTSorREAD_CALENDAR. Save-to-Photos uses scoped storage viaMediaStore.Images.Media.RELATIVE_PATH = "Pictures/Scan". versionCodeis auto-bumped after every successful build. Mirrors the iOS app'sagvtool bumppost-build action. The current value lives inversion.propertiesat the repo root (tracked in git); after each successfulassembleDebug/assembleRelease/bundleDebug/bundleReleaseadoLastfinalizer rewrites the file withversionCode + 1. The new value is effective on the next build. Pass-PnoBumpto skip the bump for one invocation; the GitHub Actions release workflow uses this so CI runs don't produce uncommitted file diffs on the runner. Commitversion.propertiesalong with your release so the bump propagates between machines.versionNamedefaults to1.0but can be overridden with-PversionName=1.2.3— the release workflow does this from the pushed git tag (v1.2.3→1.2.3).
- ML Kit barcode scanner module download via Play Services for smaller APKs (currently using the bundled model for offline-first behaviour).
- Real decoding of Slovak Pay by Square — would need an LZMA Kotlin/Java port (e.g. XZ for Java); today the format is recognised and the user can route the raw token to a banking app via Share / Copy.
- Localised field labels for the Serbian, Czech, and Indian formats (currently English).
- Boarding-pass (BCBP), AAMVA driver's-licence, GS1 Application Identifier decoders.
- Home-screen widget showing recent scans, mirroring the Geo.Android Glance widget pattern.
MIT — see LICENSE.