Pawlet:把银行短信变成本地记账看板的端侧 Android 应用
ahmedsadman/pawlet
Pawlet 是一款端侧 Android 应用,将银行短信转化为个人财务看板,涵盖交易、账单、余额与消费趋势,数据只存在手机本地,无账号也无云端副本。短信先经本地发送方匹配器过滤,再由内置的 BERT 家族端侧模型(TFLite/LiteRT)分类并提取金额,仅在模型不确信或涉及外币时才回退到 OpenRouter 免费模型的单次 LLM 调用。目前仅支持孟加拉国银行,后续计划扩展更多地区。
Pawlet is an on-device Android app that turns your bank SMS into a personal-finance dashboard — transactions, bills, balances, spending trends. Your data lives only on the phone; there is no account and no cloud copy. Most messages are read by a bundled on-device model. Messages it can't confidently handle fall back to a large language model (OpenRouter's free models): through Pawlet's server for Play Store installs, or directly with your own OpenRouter key otherwise. With neither, they never leave the phone.
Note: Pawlet currently targets Bangladeshi banks only, with plans to expand to more regions in the future.
Documentation
mobile-app/docs/— technical write-ups on how the app behavesserver/docs/— technical write-ups on how the server behavesmodel-training/— how the on-device model is trained, exported, and quantized.
What it does
The phone receives bank SMS all the time — debits, credits, credit-card statements, OTPs, promotions. Pawlet watches those messages, works out which ones describe real money movements, pulls the numbers out of them, and keeps a running picture of your finances. All of the storage, matching, and aggregation happens locally in an on-device database.
How it works (high level)
The pipeline is a queue. Every incoming message is saved first, then processed one step at a time so nothing is lost if the phone is offline or the model is rate-limited.
-
Capture — an incoming SMS is persisted immediately into a local queue. Capture and processing are separate, so a message is never dropped just because processing failed.
-
Sender gate (local, free) — before spending any LLM call, Pawlet checks whether the message could plausibly be from one of your banks. You add banks from a curated catalog; each bank carries a set of internal matcher strings. A message passes the gate if one of those matchers appears in the sender, or if the message body contains one of your registered credit-card numbers. Anything that fails the gate is marked ignored and never reaches the model. This is what keeps promotions, OTPs, and personal texts from wasting API calls.
-
Understand (on-device first, LLM only if needed) — a message that passes the gate is first run through a small on-device model (a BERT-family encoder bundled with the app and run via TFLite/LiteRT). In one pass it both classifies the message (transaction, bill, or neither) and extracts its numbers (amount, balance, and for bills the amount due and statement period). When the model is confident, that is the answer — no network call is made at all, which is the common case for everyday debits, credits, and statements. Only when the model is not confident (or the message is in a different currency, which the on-device model doesn't convert) does Pawlet fall back to a single fused LLM call that classifies, extracts, and converts currency in one round-trip. Either way the step is atomic: the message only leaves the queue on a valid, fully-parsed response. In History, messages handled by the LLM carry a small "LLM" marker; on-device ones show nothing.
-
Assign the bank deterministically — the model is not asked which bank the message is from. Because the sender/card matchers are exact, Pawlet assigns the bank itself: a credit-card number in the body wins; otherwise the single bank whose matcher hit the sender. An ambiguous sender is left unlinked rather than guessed. This removes a whole class of bugs where the model returned a near-miss bank name that failed to link.
-
Write — the result is written to the local database as a transaction or a bill. Bills are strict: one is only recorded when the message body actually contains a matching credit-card number. Deposit-account balances are updated from the message when the currency matches and the message is newer than the last known balance.
-
Link related records — separately, Pawlet looks for relationships between records it already has: a debit on one account paired with a credit on another (a transfer), and a credit-card payment matched to the bill it paid. These run opportunistically over a bounded recent window, which is what powers the transfer icons and the paid/due status on bills.
The finance screens read straight from the local database, reproducing the same aggregations as the original app (trends over rolling windows, monthly summaries, balances, spending breakdowns).
Reliability and battery
- Retry with backoff — free models get rate-limited, so a failed message is requeued with a capped exponential backoff and retried later; only a genuinely fatal error (bad key) fails it outright. Everything is designed so processing can stop and resume at any point.
- Adaptive background wakeups — instead of waking on a fixed timer, Pawlet only schedules a background catch-up when there is actually pending work (offline backlog or a retry waiting on its backoff), and cancels it when the queue drains. An idle app does no background work.
- Incoming messages are handled immediately while the app is alive; the background scheduling only covers the case where the phone is sitting untouched with a backlog.
Design choices worth knowing
- Categories are fixed to transaction and bill — there is no free-form category list.
- The LLM is behind a swappable interface — the OpenRouter provider is one implementation; the rest of the app depends on the interface, not the vendor.
- Money is stored as text and computed with decimal arithmetic to avoid floating-point drift.
- Banks are chosen from a catalog, not typed freehand; matchers live internally so a non-technical user just picks their bank. Credit cards additionally need the card's first and last four digits.
- Security: a PIN plus optional biometric lock gates the whole app, re-locking on screen-off.
Building
The Flutter app lives in mobile-app/ — run the commands below from there (cd mobile-app).
The app ships with no OpenRouter API key. A key in a client binary is extractable, which is why it was removed. Play Store installs classify through Pawlet's server instead, which holds the key and accepts only genuine installs verified with Google Play Integrity. Other installs have LLM parsing off until the user adds their own OpenRouter key under Settings → Privacy, where it is validated and stored in the Android keystore. Without a key, the on-device model is the only classifier, and messages it cannot parse are marked failed in Messages, where they can be retried later.
No --dart-define values are required to build. Two optional ones exist:
| Define | Effect |
|---|---|
PAWLET_API_BASE |
Pawlet's server. Lets a BYOK install fetch the current prompt bundle. |
PLAY_CLOUD_PROJECT_NUMBER |
With PAWLET_API_BASE, enables proxy mode for Play installs. Only release builds set it. |
Without them the app uses its built-in prompt and never contacts Pawlet's server.
Local development — build and run directly:
flutter run --debug
Release — build as usual:
flutter build appbundle --release
Debug builds get applicationIdSuffix = ".debug", i.e. package com.pastabyte.pawlet.debug,
labelled "Pawlet Debug". They install alongside a release build (com.pastabyte.pawlet) and keep
separate data.
Debug SMS injector
Physical (non-rooted) devices can't have a fake SMS_RECEIVED delivered over adb, which makes
it hard to exercise the pipeline without waiting for a real bank message. Debug builds therefore
ship a debug-only injector that feeds a fake message straight into the same code path a real
incoming SMS would take.
It is compiled in only when kDebugMode / BuildConfig.DEBUG is true, so it is never present
in a release build. Debug builds also use a .debug application-id suffix and the label
"Pawlet Debug", so they install alongside a release build.
How it works: a debug-only broadcast receiver listens for the action
com.pastabyte.pawlet.INJECT_SMS, reads sender and content string extras, and forwards them
over the pawlet/debug method channel to the Dart side, which pushes them into the normal
"incoming raw SMS" entry point. From there they queue, gate, get classified, and get written
exactly like a real message.
Using it
- Build and install a debug build on the device, and make sure the app is in the foreground (the channel handler is installed when the UI starts).
- For the injected message to do anything past the gate, the matching bank must already be
added in-app (e.g. add "EBL" before sending an
EBLmessage). The on-device model handles the message on its own; to exercise the LLM fallback as well, add an OpenRouter key under Settings → Privacy first. - Broadcast a fake message with adb:
adb -s <device> shell "am broadcast \ -a com.pastabyte.pawlet.INJECT_SMS \ -p com.pastabyte.pawlet.debug \ --es sender 'EBL' \ --es content 'Cash withdrawal of BDT 5000.00 from ATM. Available Balance: BDT 152300.00'"
Quoting gotcha: wrap the whole am broadcast ... command in one set of double quotes and use
single quotes around any extra value that contains spaces (like a 'City Bank' sender). Without
the inner quotes the remote shell splits the value on the space and the extra is lost, so the
receiver silently drops the message.
Inspecting the result
There is no sqlite CLI on a stock device, so pull the database and read it on the host:
adb -s <device> exec-out run-as com.pastabyte.pawlet.debug cat databases/pawlet.db > /tmp/pawlet.db python3 - <<'PY' import sqlite3 db = sqlite3.connect('/tmp/pawlet.db') for row in db.execute("select id, sender, status, category from sms_records order by id"): print(row) PY
Processing is asynchronous (it makes a network call), so give it a few seconds after injecting before pulling the database.
License
Pawlet is split into three parts under different licenses:
| Part | Path | License | Terms |
|---|---|---|---|
| App / code | mobile-app/ |
AGPL v3 | Use/modify/distribute, including commercially. Must keep it open source — anyone who distributes or runs it as a network service must release their source under AGPL too. |
| Server | server/ |
AGPL v3 | Same terms as the app. Anyone who runs a modified version as a network service must offer its source to that service's users under AGPL. |
| Dataset & models | model-training/ |
CC BY-SA 4.0 | Share/adapt, including commercially, with credit. Derivatives must use the same license. |
Building on Pawlet requires crediting the author (Ahmed Sadman Muhib) and this project.
来源:GitHub · github.com