project · android · on-device inference
Local Expenses — expense tracking that stops asking you to type
It reads the bank SMS you already get, works out which ones are real, splits bills without ever losing a paisa, and moves everything to your next phone over a QR-paired local channel. No server, no account required.
Every expense app dies the same death: it is a data-entry chore wearing a chart. You use it enthusiastically for nine days, miss a week, and then the numbers are fiction so you stop opening it.
But the data already exists. Every card swipe and UPI payment generates an SMS or a notification within seconds. The problem was never capture — it was that reading those messages reliably is genuinely hard, and until recently the only way to do it well was to ship them to somebody's server.
Two engines behind one interface
Transaction extraction runs on the phone. The orchestrators never talk to a model directly — they talk to a single LlmEngine interface with two implementations behind it:
| Engine | Runtime | Availability |
|---|---|---|
gemininano | Gemini Nano via ML Kit GenAI Prompt API (AICore) | Capable devices only (Pixel 9/10 class). Default where usable. |
gihub | gi-hub bound service — Gemma via LiteRT, Qwen via llama.cpp | The widest-supported fallback. |
The preference is persisted, but getActiveEngine() resolves it to something that actually works — if Gemini Nano is selected on a device where the model was never downloaded, or on the wrong platform entirely, it transparently falls back to gi-hub. Callers never special-case it.
Making that swap invisible forced a useful discipline. The two runtimes do not have the same capabilities: llama.cpp enforces GBNF grammars hard, and the Prompt API has no grammar support at all. So the grammar is treated strictly as an optimisation, and JSON extraction and repair at the orchestrator is the real safety net — validated against the user's actual category catalogue. Never trust the raw output shape, on either engine.
The classifier that earns its keep
The single highest-value model call in the app is also the smallest. Indian bank SMS traffic is maybe one part real transaction to four parts marketing: cashback offers, pre-approved loans, reward-point expiry, "you have won" spam — much of it formatted to look exactly like a debit alert, because that is what gets opened.
A naive regex extractor turns all of it into expenses, and the user gets a ledger full of imaginary spending. So once a message has been extracted into a candidate transaction, it goes to a classifier that labels it transaction or promotion, constrained by a grammar so tiny the model can only emit one of two objects:
{"label":"transaction"}
{"label":"promotion"}
Hard-enforced on llama.cpp, best-effort on Gemma — and parsed defensively regardless. This is the shape I keep coming back to with small models: do not ask them to produce your data structure, ask them to make one decision you can validate in a line of code.
Splitting money without losing any
The bill-splitting feature looks like a product feature and is really an arithmetic problem. Split ₹1,000 three ways in floating point and you get three amounts that do not add up to ₹1,000, and the discrepancy compounds every time somebody settles up.
So all exact arithmetic happens in integer minor units — paise — and converts back to rupees only at the boundary. Splitting can never create or destroy a paisa. Four split modes share the pipeline:
equal— per-member value ignoredexact— absolute amounts, validated to sum correctlypercent— percentagesshares— arbitrary weights
The module has no Firestore, SQLite or React Native dependency at all, which means the money logic is unit-testable in complete isolation — and it is tested, because this is the one part of the app where being subtly wrong is worse than crashing. Inconsistent input raises a SplitValidationError rather than quietly rounding, and settlement reduces the balance graph to a minimal set of transfers so nobody pays three people when one payment would do.
Moving data between phones with no server in the middle
An offline-first app with no account still has to answer "I got a new phone." The transfer runs over Google Nearby Connections, and the transport imposed most of the design: it exposes only a text channel, so the payload is gzipped, base64-encoded, split into 12 KB chunks and reassembled on the far side, with the sender pushing a fixed number of chunks before waiting for an acknowledgement so a slow receiver cannot be overrun.
Pairing is the interesting half. Nearby Connections encrypts the channel, but encryption is not authorisation — without more, any nearby device could ask to push or pull data. So the receiver displays a QR containing two random values: a session, used as the Nearby advertising name so the sender connects to the right device, and a secret the sender must present after connecting, proving it actually scanned this receiver's screen. Out-of-band pairing, in other words, using the one channel an attacker across the room does not have: line of sight.
The protocol was ported unchanged on the wire from Activity Hub, which needed exactly the same thing. That reuse was the first sign these two apps were really one platform with two front ends.
The rest of it
- Shared lists and people — Splitwise-style groups with per-person balances and settlement suggestions.
- Recurring transactions — rent, subscriptions and EMIs materialised on schedule instead of re-entered.
- Budgets and dashboard — category budgets with charts over the local database.
- Catalog — user-owned categories and payees, which double as the validation vocabulary the model's output is checked against.
- AI chat — plain-English questions answered by querying the local database, same planner-then-query shape as Activity Hub.
- Google Sheets sync — for the spreadsheet habit nobody gives up, plus Google sign-in for shared groups.
What I would tell someone starting this
The interesting engineering was not the model. It was the boundaries around it: a validation catalogue so hallucinated categories cannot enter the database, integer arithmetic so splitting is provably conservative, a two-value QR handshake so a local transport becomes an authorised one, and an engine interface that lets the best available runtime win per device without any caller knowing.
The model is the least reliable component in the system. Everything above is the scaffolding that makes it safe to depend on anyway.