Skip to content

About

Mail-sorting app for a refugee housing complex

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Briefsortierung

An Android app for delivering mail in a refugee housing complex. Mailboxes carry room tags (1.071 = room 071 on floor 1); letters carry names. The app reads the name off the envelope with the camera, matches it against a resident list from a spreadsheet, and then walks the user through writing the room numbers onto the physical pile — replacing the manual lookup that currently takes an intern most of a morning.

Built from the Claude Design handoff (flow 1b, screens B1–B5 plus the shared search and settings screens). Kotlin, Jetpack Compose, Material 3, CameraX, and the bundled on-device ML Kit text recogniser. No network permission: the resident list never leaves the phone.

Getting it running

Open the project folder in Android Studio and press Run. First sync downloads Gradle 8.9, the Android SDK bits and the dependencies, so give it a few minutes; after that a debug build takes seconds.

The project pins AGP 8.7.3 / Kotlin 2.0.21 / Compose BOM 2024.10.01 — established versions chosen so the first sync works without argument rather than the newest ones. If your Android Studio prompts to upgrade AGP, that is safe to accept once the project has built as-is.

Requirements: JDK 17 (Android Studio bundles it), a physical phone running Android 8.0 or newer. The emulator works for everything except scanning, which needs a real camera.

Run the tests with ./gradlew test or from the test folder in Android Studio. There are 66 of them, covering the matching engine, the search, the scan stabiliser and the spreadsheet parser. They are plain JVM tests — no emulator, a couple of seconds to run.

Shipping it to the social workers

The app is distributed outside the Play Store, so it can stay offline: a tagged commit builds and signs a release APK in GitHub Actions, publishes it as a GitHub Release, and Obtainium on each phone offers the update with one tap. The letter app keeps its no-internet guarantee; the separate updater app does the network.

Development happens on a dev branch, which builds a separate Briefsortierung DEV app (installs alongside the real one) to a rolling prerelease for the test phone; promoting is a fast-forward merge to main plus a version tag.

The full playbook — creating the signing key, the four CI secrets, the dev/main branch workflow, cutting a release, and the five-minute per-phone onboarding sheet — is in DISTRIBUTION.md.

The resident list

bewohner_beispiel.xlsx (delivered alongside this project) is a working example with 43 residents and a second sheet explaining the format. Load it through Datei auswählen on the start screen to try the app immediately — it contains the awkward cases on purpose: three spellings of Müller in different rooms, two Müllers sharing room 1.071, a Mayer and a Meier, a Schmidt and a Schmitt, and two people who have moved out.

One row per person. Several residents in one room simply repeat the room value.

Column Example Notes
Raum 1.071 Used exactly as written. Format this column as Text in Excel.
Nachname Müller Required.
Vorname Max Optional but strongly recommended — with common surnames the given name is what decides the room.
Schreibweisen Mueller; Muller Optional. Semicolon-separated alternate spellings.
Status aktiv / ausgezogen 04.2026 Moved-out residents stay in the list so forwarded mail is still findable; they are shown dimmed and can be excluded in the settings.

Column order does not matter, headers are matched case- and accent-insensitively, English headers work, and a single combined Person column (Müller, Max) is accepted instead of separate name columns. CSV works too, with either semicolons or commas.

Rows the parser cannot use are never dropped silently: each one is reported with its line number on the start screen.

Why the Text formatting matters. If Excel treats the room column as numbers, 1.070 silently becomes 1.07. There is no way to tell afterwards whether that meant room 070 at a site with three-digit rooms or room 7 at a site with two-digit ones, and guessing wrong puts a letter through the wrong door. The app therefore never rewrites a room number — it detects an inconsistent column and asks you to fix the file instead.

How the matching works

This is the part that decides whether the app is useful, so it is worth describing. The whole engine is in app/src/main/java/de/briefsortierung/app/match/ and is plain Kotlin with no Android imports, which is what makes it testable on a laptop.

Finding the name on the envelope. An envelope carries far more text than the name — sender, street, postcode, franking marks, sometimes a slogan. Feeding all of it to the matcher wastes time and invites false hits: a letter delivered to Müllerweg 3 should not match a resident named Müller. So the parser first works out which lines could be a name at all, anchoring on the one thing that has a shape nothing else on the envelope shares — the postcode line. From there it walks upwards past the country and street lines to the name, and uses the bounding boxes the recogniser returns to push down text near the top of the image, which is usually the sender. Institutional senders (GmbH, Amt, Sparkasse, Jobcenter…) are pushed down further.

Folding, not canonicalising. The same person arrives as Müller, Mueller, Muller and MULLER. Rather than picking one canonical form, every name is folded into a set of keys: Müller carries both muller and mueller. An envelope reading "Mueller" then scores a perfect match instead of paying an edit-distance penalty, and the same folding applies to both sides, so it can never make a true match look worse than a false one.

Three similarity measures, best of. Edit distance alone does badly on exactly the confusions that dominate a German mailbox, so the score is the best of:

  • Jaro-Winkler, forgiving about transposed characters and rewarding a shared prefix — which is what a half-read surname gives you.
  • A weighted Damerau-Levenshtein where visually confusable substitutions are cheap. A camera that turns Müller into MuIIer or Nasrallah into Nasra11ah should not be charged as if it had invented new letters. It also models the classic two-into-one misreads (rn→m, cl→d, vv→w) that plain edit distance charges double for.
  • Cologne phonetics (Kölner Phonetik, the German counterpart to Soundex) as a floor. Meier, Mayer, Maier and Meyer are up to three edits apart yet are the same name; so are Schmidt and Schmitt, and Mohammed and Muhammad. Phonetics collapses them onto one code. A phonetic hit alone is worth about 0.88 rather than 1.0, because two genuinely different names can share a code — it has to beat everything else on the list to win.

Word order does not matter. "Max Müller", "Müller Max" and "Müller, Max" score identically, because query tokens are assigned to name parts greedily by similarity rather than by position. The surname gets first pick of the tokens, since it carries most of the discriminating power (62% of the score against 38% for the given name); an envelope with no usable given name is penalised, because a surname alone is weaker evidence. Compound surnames are compared both token-wise and joined, so Al Rashid, Al-Rashid and Alrashid all match.

Speed. A cheap character-bigram pre-filter narrows a few hundred residents to a few dozen before the expensive scoring runs. That is what makes it affordable to match on every camera frame instead of on a shutter press: roughly 1.5 ms per envelope over 43 residents and 6 ms over 860, measured on a desktop JVM, with a regression test that fails if it gets much slower.

Honest confidence. Nothing is ever shown as 100% — an OCR match is evidence, not proof, and a number that says "certain" discourages the half-second glance at the name that catches the rare wrong hit. When the runner-up is nearly as good, every percentage in the list is pulled down together: the useful signal in that situation is "check the alternatives". Matches below the configured threshold are not offered as choices at all; the app goes straight to the no-match state and points at manual search.

Families. Two residents of the same room matched only on a shared surname are one delivery, not two choices — the letter goes to that room either way. They collapse into a single "Familie Müller" row.

Manual search

For handwritten envelopes, smudged labels, and names the recogniser mangles every time. It is built around one interaction: first letters of the surname, space, first letters of the given name. mül ma finds Müller, Max. With a few hundred residents that reaches one person in four or five keystrokes, which beats typing a full name and beats scrolling by a mile.

Everything else is a fallback around that idea. Order does not matter (ma mül works). A single token searches surnames, given names and spelling variants at once. Typing digits switches to room search — 071 shows who lives in 1.071, useful for checking a mailbox. mul, muel and mül are all the same query, because both spelling conventions are folded on both sides. From four characters on, one wrong letter is tolerated, so schnidt still finds Schmidt.

Ranking is layered so the forgiving behaviour never buries the obvious answer: an exact prefix always outranks a fuzzy one, and among equals the person whose name the query covers most completely comes first — which is why ma puts Max above Maxim.

Manual search is also a mode you can stay in, not just a one-off detour. When the light is bad or a run of envelopes is handwritten, committing a letter from the search screen clears the field and confirms the room without reopening the camera, so the next name can go straight in. The back arrow is the way back to scanning.

When the phone buzzes

A live camera produces several readings a second and single frames are noisy: a hand moves, focus hunts, half a line is cut off. Firing on the first frame that clears the threshold would mean buzzing at half-read names and occasionally committing the wrong room before the envelope is even flat. So a candidate has to survive two consecutive frames with the same identity before the phone vibrates.

The opposite problem matters just as much: the envelope stays in front of the lens for a moment after the user taps Übernehmen, and without a guard the app would immediately re-detect it and ask again. Frames are keyed by an envelope fingerprint derived from the name tokens, compared loosely so that OCR wobble between two readings of the same letter still counts as the same letter. A handled letter stays quiet for six seconds — long enough to move on, short enough that deliberately re-scanning the same envelope still works.

Two vibration patterns carry two meanings: one short pulse for "I found someone", a double pulse for "I read the name but nobody fits". The user can tell without looking whether the next tap is one button or three.

Not firing on things that are not letters. The camera reads whatever is in front of it, and a keyboard or a page of prose can contain a word that matches a resident's surname well enough to clear the bar. So before a middling match is allowed to buzz, the frame has to look like a letter: it must carry at least one thing an envelope has and a random surface does not — a postcode-and-city line, a street-and-house-number line, or a leading honorific (Herrn, Frau, Familie, …). A frame with no such structure has to reach a much higher confidence (85%) before it counts, which is what stops the phantom "63% while hovering over the keyboard" (or over a chat window) trigger.

Not offering names that barely match. Separately from whether to fire, there is the question of which names to put on the sheet. A read shares a stray syllable with plenty of residents, and those coincidences score low. Anything under 45%, or more than 35 points behind the leader, is dropped rather than shown — even behind the swipe — because a "very unmatching" name in the list only invites a wrong pick. The leader itself always survives, since it had to clear the match threshold to get onto the sheet at all.

Forcing a decision. Occasionally a real envelope has no structure the parser can anchor on, or the confidence sits just under the bar and the recogniser keeps hunting for a better read. A Jetzt erkennen button on the idle bar commits the current best guess immediately, bypassing both the two-frame wait and the structure gate.

Sender above, recipient below. German mail usually prints the sender small at the top and the recipient larger below it, though nothing guarantees a sender is present at all. The parser anchors on the postcode and walks upwards to the name, discounts lines high on the envelope, and — when the OCR engine reports line heights — treats the larger print as the more likely recipient. All of these are nudges rather than gates, so a strong name match on an awkwardly laid-out envelope still wins.

More suggestions on demand. The sheet shows the best match and up to two alternatives at a glance. Swiping it up (or tapping the handle) reveals the remaining plausible candidates — those between the offer floor and the match threshold — shown dimmed. A ✕ on the sheet cancels the scan without committing anything and quiets that envelope for the cooldown, for when the recogniser locks onto the wrong letter in a hurried stack.

Privacy

The app has no INTERNET permission. That is enforced by the system rather than promised by the code: names and room numbers physically cannot leave the device. Text recognition uses the bundled ML Kit model, which ships inside the APK and needs neither Google Play Services nor a network call. Cloud backup and device-to-device transfer are switched off for the app's data.

Nothing about the mail is written to disk, either. The pile being sorted — recipient names, matched rooms, the raw OCR text — lives in memory for the session only and is gone when the app closes; there is no delivery history kept. The one thing persisted is the resident list itself (the spreadsheet you load) plus the app settings. The trade-off is deliberate: if the app is killed mid-pile, the current stack is lost, which is the price of keeping no recipient data on the device.

Project layout

app/src/main/java/de/briefsortierung/app/
  match/        the matching engine — pure Kotlin, no Android imports, fully unit-tested
                Normalizer, ColognePhonetic, Similarity, AddressParser, Matcher,
                QuickSearch, MatchStabilizer
  data/         spreadsheet reading, resident parsing, JSON persistence
  scan/         CameraX + ML Kit analyser, vibration and sound
  vm/           AppViewModel — all app state in one place
  ui/           theme tokens, shared components, one file per screen
app/src/test/   66 JVM unit tests

A few deliberate non-choices worth knowing about, since each one is a dependency that is not in the build: no Apache POI (the .xlsx reader is ~200 lines over java.util.zip and the platform XML parser, because POI on Android needs desugaring and multidex and would dwarf the app); no Room (the data is a few hundred residents and a few dozen letters, so JSON files avoid an annotation processor); no navigation library (seven screens and a linear flow, so navigation is a when over an enum in the same state the letters live in).

Where this differs from the mockups

  • Unresolved letters during labelling. The mockup's B5 copy says the unlabelled letter ends up at the bottom of the pile. The labelling queue runs in strict reverse scan order, which is what physically matches the stack, so an unresolved letter comes up in its natural position rather than at the end. Its button reads Beiseite gelegt instead of Geschrieben ✓, and the summary says those letters were set aside — which is what actually happens.
  • The idle scan screen gained a slim bar with a large Jetzt erkennen button and a full-width Manuell suchen. The mockup only shows B2 with the candidate sheet open; without this there is no way to force a decision or start manual search when nothing has matched. The stack overview is reached through the count chip at the top of the camera view.
  • File issues are surfaced on the start screen as a red card. The mockup shows a "0 Fehler" counter; this makes the non-zero case actionable.
  • Icons are the placeholder-free minimum: text glyphs and shapes rather than Material Symbols, to keep the APK free of the full icon set. Swapping in real icons is a small change.

Worth doing next

  • Try it on a real pile before changing anything. The confidence threshold defaults to 60%; the right value is whatever makes the intern stop double-checking, and one morning of real envelopes will tell you more than any amount of tuning here.
  • The Schreibweisen column earns its keep quickly. Every letter that lands in the red "kein Treffer" list is a candidate for one line in that column, and the next one like it matches straight away.
  • Room-format validation is currently a warning. If your rooms genuinely are not floor.number, the setting is informational only and nothing breaks.
  • Multiple buildings would need a column for the building and a filter in the settings; the matcher would not change.

License

Copyright (C) 2026 The Briefsortierung authors.

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License, version 3, as published by the Free Software Foundation. It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the full text in LICENSE, or https://www.gnu.org/licenses/gpl-3.0.html.

The example resident list (bewohner_beispiel.xlsx) is fictional sample data, provided only to show the expected spreadsheet format.

About

Mail-sorting app for a refugee housing complex

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages