- Kotlin 93.8%
- AIDL 5.4%
- Shell 0.8%
A line's unitPriceOre is now nullable. A price-less line prints as just its name (with a "N × " prefix when quantity > 1) and is excluded from the total; the "I ALT" total line is omitted entirely when no line is priced. Priced and plain lines can be mixed — the total then reflects only the priced ones. Quantity now defaults to 1 when blank, and a line only needs a name to count. The editor hides the total for an unpriced list and marks the price field optional; the receipts list shows an item count instead of "0,00 kr" for such lists. So you can name a store "Groceries" and just fill in what to buy. The nullable field defaults to null, so existing saved receipts (which always had a price) deserialize unchanged. Bumps version to 0.1.4 (versionCode 5). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtdyPTKt54Zs3H1u1vMsMC |
||
|---|---|---|
| .woodpecker | ||
| app | ||
| gradle | ||
| .gitignore | ||
| build.gradle.kts | ||
| build.sh | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| logo.png | ||
| README.md | ||
| settings.gradle.kts | ||
Labellars
A small offline Android app for a Sunmi handheld with a built-in thermal
receipt printer. Define reusable store profiles (name + header + footer),
compose receipts as antal × vare @ enhedspris lines with an auto-summed
total in Danish / DKK, and print them — either to the Sunmi built-in printer
or a paired Bluetooth ESC/POS printer. Everything can be exported/imported as
a single JSON file, so you can make (obviously fake, just-for-fun) receipts and
reuse them later.
Built as a sibling of trafikapp: same toolchain, folder-per-feature MVVM,
kotlinx.serialization, dynamic-color Material 3, Danish formatting, Docker-only
builds and Woodpecker CI.
Screens
- Kvitteringer — saved receipts: create, open/edit, duplicate, print, delete.
- Butikker — store profiles: name + multi-line header (address, CVR, …) and footer ("Tak for besøget", …).
- Kvittering-editor — pick a store, add product lines (antal / vare / pris), live total + a paper-accurate preview, optional custom date/time and note, then Gem or Gem & print.
- Indstillinger — choose the printer backend (Sunmi / Bluetooth), pick a paired Bluetooth device, run a test print, and export/import all data.
Building & running
Everything runs in Docker, so no local Android toolchain is needed:
./build.sh # unit tests + debug APK (default)
./build.sh test # just the unit tests
./build.sh assembleDebug # just the APK -> app/build/outputs/apk/debug/
./build.sh assembleRelease # R8-minified release APK -> app/build/outputs/apk/release/
Unit tests are plain JVM (JUnit over the pure receipt renderer, money formatting
and export/import) — no emulator. assembleRelease works locally unsigned
(produces app-release-unsigned.apk); signed APKs are only built in CI on tags.
Install the debug APK with adb install app/build/outputs/apk/debug/app-debug.apk.
The receipt printer
The app talks to a printer through a ReceiptPrinter abstraction
(app/src/main/kotlin/dk/jaduer/labellars/printer/) with two backends, chosen in
Indstillinger:
- Sunmi (built-in) — binds the on-device Sunmi inner-printer AIDL service
(
woyou.aidlservice.jiuiv5) and drives it with typed text/alignment calls. The AIDL interface is vendored underapp/src/main/aidl/woyou/aidlservice/jiuiv5/.⚠️ AIDL binder transaction codes depend on method declaration order, so that file must match the interface the device's printer service actually exposes. It reproduces Sunmi's long-stable public interface; if printing ever misbehaves on new firmware, drop in the official
IWoyouService.aidlfrom Sunmi's current SDK (same path) — it needs no code changes for the subset used. - Bluetooth (ESC/POS) — connects an RFCOMM (SPP) socket to a paired printer
and streams ESC/POS bytes (
EscPos.kt). Requires theBLUETOOTH_CONNECT/BLUETOOTH_SCANruntime permissions (requested from Settings). Danish glyphs (æøåÆØÅ) use the WPC1252 code page.
Layout is rendered once, backend-agnostically, by ReceiptRenderer into a list
of ReceiptElements (the same list feeds both backends and the on-screen
preview). Default paper is 58 mm / 32 columns.
Data & backup
Stores and receipts are persisted as JSON files in the app's private storage
(stores.json / receipts.json) via a small JsonStore helper — no database.
Money is stored as integer øre so sums are exact. Each saved receipt keeps a
snapshot of its store, so it stays printable even after the store is edited or
deleted.
Indstillinger → Sikkerhedskopi exports an ExportBundle (all stores +
receipts) to a JSON file via the Storage Access Framework, and imports one back
(merging by id). The exported format is exactly the on-disk format.
Installing
Signed APKs are attached to Forgejo releases at https://code.jaduer.dk on every
version tag; install directly or via Obtainium (auto-detects Forgejo/Gitea
releases). No Play Store.
Release / signing (maintainers)
Generate the keystore once:
keytool -genkeypair -v -keystore labellars-release.jks -alias labellars -keyalg RSA -keysize 4096 -validity 10000
Then set these repo secrets in the Woodpecker UI (repo → Secrets):
| Secret | Value |
|---|---|
labellars_keystore |
base64 -w0 labellars-release.jks |
labellars_keystore_password |
keystore password |
labellars_key_alias |
labellars |
labellars_key_password |
signing key password |
gitea_token |
Forgejo token with release write access |
Push to main runs the unit tests only; pushing a tag builds a signed
labellars-<tag>.apk and attaches it to the matching Forgejo release
(.woodpecker/build.yaml). The release signing config in app/build.gradle.kts
is only attached when LABELLARS_KEYSTORE is set, so local release builds stay
unsigned rather than failing.
Stack
Native Kotlin + Jetpack Compose (Material 3), minSdk 33, targetSdk/compileSdk 36.
Compose BOM, Navigation, Lifecycle/ViewModel, DataStore (settings),
kotlinx.serialization + kotlinx.coroutines. JUnit for the plain-JVM tests. No
networking, no Room, no DI framework.