ADR-0038: Notification sanitization drops leaked framework class-name tokens
Context
NotificationExtractor.extractText joins every text-bearing extra of a posted
notification into one string (title, text, big text, ticker, sub text, inbox
lines, messaging messages). Some apps — Wise (com.transferwise.android) in
particular — leak a framework class reference into one of those extras, so the
joined text reads:
Card payment android.app.Notification$BigTextStyle 32 PLN spent at Żabka.
That used 34.22 MYR. androidx.core.app.NotificationCompat$BigTextStyle
The token is an Android/AndroidX internal detail, never user data. It was
sanitized only for long digit runs, so it was stored verbatim in
transactions.rawText / the unmatched queue, hashed into NotificationKey,
fed to the classifier, and shown to per-bank regex rules. Rules had to keep
tolerating an arbitrary amount of junk (Card payment ... spent at), coupling
parsing to a framework implementation detail.
Decision
NotificationExtractor.sanitize drops leaked framework class-name tokens
(\b(?:android|androidx)(?:\.[A-Za-z0-9_]+)+(?:\$[A-Za-z0-9_$]*)?) and
collapses runs of whitespace, both before the existing account-number
masking. The sanitized string is what gets stored, keyed, and matched; the
token never reaches the DB, the dedupe hash, the classifier, or the rules UI.
Account masking is separator- and script-aware (ADR amendment 2026-10-31,
issues #303/#312): contiguous runs of at least eight digits, and card-style
groups of four separated by space/dash/dot, are masked to ****last4 in any
Unicode digit script. A digit run that is part of an amount (a decimal
fraction follows it, or a currency token precedes it) is left untouched, so
RM12345678.00 keeps its value while 4111 1111 1111 4321 is masked.
Parser rules remain tolerant of the same tokens ($anyCurrencyAmountEither\
s+spent\s+at\s+$merchantName, plus the Unicode/PLN matching from ADR-0009's
currency tables), because text already stored — including user captures
imported back through the shared capture-dump format — cannot be rewritten.
Consequences
- New captures store clean
rawText; merchants, search, and category keyword matching no longer see framework noise. - Already-stored rows and imported exports keep the junk, so the parser rules are tolerant by design, not by accident.
- A notification re-posted across the update boundary can sanitize to different
text than its pre-update row, producing a different
NotificationKeyand a possible duplicate. The window is bounded by the 48 h listener replay and affects only junk-carrying formats; accepted over a data migration. - Whitespace is now collapsed and trimmed, changing stored text for captures that had doubled spaces. Same bounded duplicate caveat.
Rejected alternatives
- Keep tolerating the junk only in parser rules — leaves the token in the DB, dedupe key, classifier input, and UI forever.
- Rewrite stored
rawTextin a migration — touches encrypted user rows for a display-only improvement; a Room migration to fix a substring is not worth the risk. - Strip the token only inside
NotificationKey.normalize— fixes dedupe but not storage, classification, or the rules UI. - Stripping in
CapturePipelineinstead of the extractor — the pipeline is Android-free and platform-agnostic; class-token knowledge belongs next to the notification-extras walk that produced it.