Skip to content

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 NotificationKey and 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 rawText in 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 CapturePipeline instead of the extractor — the pipeline is Android-free and platform-agnostic; class-token knowledge belongs next to the notification-extras walk that produced it.