Ingestion Pipeline
Scope: owns the capture path — listener, extraction, filtering, pipeline sequence, routing, replay.
The capture path turns a posted notification into a transaction (or a review-queue entry). It runs entirely off the main thread.
Components
| Component | Role |
|---|---|
TransactionNotificationListener |
System-bound NotificationListenerService. Dispatches to a SupervisorJob + Dispatchers.IO scope. Replays the last 48h of active notifications on connect. Never crashes the system service. |
NotificationExtractor |
Flattens every text fragment in the notification extras (title, text, big text, inbox lines, MessagingStyle messages) into one string; masks account/card numbers to ****last4, including separated groups and non-ASCII digits, without rewriting amounts (see security) before anything is stored or hashed. Only called after the track gate. |
NotificationFilter |
Pure pre-parse gate: isExcluded (promo/OTP markers), hasTransactionSignal (any catalog currency amount — Currency.amountAlternation — or money verb), isMaskedContent (OS placeholder text). |
CapturePipeline |
The core orchestrator behind one interface: onNotification(pkg, text, time, location?). Runs the match → classify → dedupe → insert → notify flow on text the listener has already extracted; the listener owns extraction and 48h replay. The routing itself lives in the Android-free CaptureDelivery seam (SessionKeys owns the session ledger), so the failure/retry contract is unit-tested on the JVM; the class is the Android/Hilt adapter. Runs cross-source dedupe (bank preferred over a Google Wallet mirror; merged atomically in place, ADR-0020/ADR-0074) and stores a captured-location snapshot when one is supplied (ADR-0021; the listener supplies a cached last-known fix when capture is enabled, ADR-0026). |
ParserEngine |
Pure Kotlin matcher over per-package rule sets (see parser.md). |
CategoryClassifier |
Seam over classification (see classification.md). |
TransactionNotifier |
Posts the silent "transaction captured" notification (transaction_alerts channel, IMPORTANCE_LOW), opt-in via notify_on_parse, with VISIBILITY_PRIVATE and a generic public version so the lock screen never shows amount/merchant (issue #304); tap deep-links via autobudget://transaction/{id} through the NotificationTapIntents seam (issue #326). TransactionNotificationText formats title/body/public text (pure Kotlin); NotificationGate checks the opt-in setting. Requires the POST_NOTIFICATIONS runtime permission (Android 13+), requested during onboarding and when toggling "Notify on capture" on; skips with a Log.w when denied. PostNotificationPermission decides the toggle→request logic (pure Kotlin). |
Sequence
sequenceDiagram
autonumber
actor B as Bank app
participant NMS as NotificationManagerService
participant L as TransactionNotificationListener
participant EX as NotificationExtractor
participant P as CapturePipeline
participant F as NotificationFilter
participant PE as ParserEngine
participant CL as CategoryClassifier
participant DB as Room + SQLCipher
participant TN as TransactionNotifier
B->>NMS: post notification
NMS->>L: onNotificationPosted(sbn)
L->>EX: extractText(sbn)
EX-->>L: sanitized text (digit-masked, class-token stripped)
L->>P: onNotification(pkg, text, postTime)
P->>P: key = NotificationKey.compute(...)
alt session duplicate (key already owned this session)
P-->>L: return true (skipped)
else
Note over P: reserve(key)
alt isExcluded (promo/OTP)
P-->>L: return true (dropped)
else hasTransactionSignal == false
alt isMaskedContent
P->>DB: insert UnmatchedEntity(isMasked=true, notificationKey)
end
P-->>L: return true
else
P->>PE: match(pkg, text) [rules from rules table]
alt candidate == null
P->>DB: insert UnmatchedEntity(notificationKey)
Note over P,DB: unique index on notificationKey → replay returns -1, consumed
else candidate
P->>CL: classify(merchant, amount, direction, pkg)
CL-->>P: categoryId?
P->>DB: recordCapture(TransactionEntity(notificationKey, isHidden=status!=COMPLETED, parseSource, rawJson?))
Note over P,DB: one Room transaction (ADR-0074): Inserted | Merged into the Wallet mirror in place | Suppressed
Note over P: only Inserted + notify_on_parse == "true"
P->>TN: show(transaction, categoryName)
TN->>NMS: post silent transaction-captured notification
end
Note over P: complete(key) on success; release(key) on throw/cancel
P-->>L: return true
end
end
Listener reconnect & replay
On onListenerConnected() the listener replays notifications posted in the
last 48h (REPLAY_WINDOW_MILLIS) via activeNotifications, feeding each
through onNotificationPosted. This covers the gap when the listener was
unbound or the process died. Replay filters on tracked_packages before
touching any notification text, and a posted notification's text is extracted
only after the same track gate (issue #305). Replay is idempotent — see
Duplicate suppression.
The listener never holds a wake lock and only wakes on posted notifications or on bind.
Routing decisions
| Condition | Result |
|---|---|
package not tracked (tracked_packages CSV) |
dropped, false |
| blank text | dropped, false |
| key already owned this session (in flight or completed) | dropped (session dedupe) |
isExcluded (promo/OTP/verification/… markers) |
dropped |
| no transaction signal (no amount, no money verb) | dropped, unless masked → unmatched (isMasked=true) |
no candidate from ParserEngine |
unmatched queue (isMasked flag propagated) |
| an enabled stored rule fails pattern validation | skipped during rule loading; the bank's other valid rules still match (issue #233) |
unmatched capture whose notificationKey is already queued |
consumed (insert returns -1) — replay is idempotent (ADR-0073) |
| candidate | classified when a merchant is present, inserted as transaction (isHidden = status != COMPLETED) |
| completed candidate that cross-matches an existing transaction from a different source within ±90 s | cross-source dedupe (ADR-0020/ADR-0074), atomically: Wallet mirror of a bank row suppressed, or the bank capture merged into the existing Wallet-only row in place (id, children and user edits kept) |
newly inserted row (Inserted only) + notify_on_parse == "true" |
silent transaction-captured notification posted (a merge or suppression never re-notifies, ADR-0074) |
Hide/unhide from the Activity list is a column-only
UPDATE transactions SET isHidden = :hidden WHERE id = :id; it never writes
the rest of the row, so a stale list snapshot cannot revert a cross-source merge
or restore an old notificationKey (issue #291).
Duplicate suppression
Two layers make capture idempotent, so a rebind, restart or 48 h replay never double-inserts:
- Session key ledger — a bounded in-memory ledger (
SessionKeys,MAX_SESSION_KEYS = 512, the oldest key evicted when full) skips a notification already in flight or processed in this session. Its key is computed from the raw sanitized text before the exclusion/signal gates, so a repeated masked or signal-less alert can't re-queue. A delivery reserves the key, then either completes it on a successful path (intentional filter match, durable duplicate, committed write) — keeping it so later replays stay deduped — or releases it when it throws or is cancelled, so a later delivery of the same notification is processed again instead of being mistaken for a duplicate (ADR-0005). - DB unique index —
transactions.notificationKeyandunmatched.notificationKeyare unique (the latter nullable, so v9-era legacy rows coexist, ADR-0073), so a duplicate insert returns-1and is a no-op. This is the durable guarantee that survives process death; the session set is only an optimization.
The key formula lives in parser.md.
Captured location
When capture-location is enabled (opt-in; off by default), the listener
attaches a device fix to each notification before the pipeline runs. The
fix is requested only for a package that is in the normalized
tracked_packages set — the listener checks membership (the pure
TrackedPackages predicate, reusing SettingsRepository normalization so a legacy
name still matches its renamed successor, ADR-0008) before asking
LocationProvider, so an untracked notification never triggers a location
request. CaptureDelivery keeps the authoritative tracked guard as defence in
depth; the user's tracked set is re-read per notification, never cached
(ADR-0004). With
the "Allow all the time" grant (ADR-0034), LocationProvider.currentLocation()
reuses a cached fix at most 2 min old and otherwise requests a live one-shot
fix (framework requestSingleUpdate, 10 s timeout), falling back to the
freshest cached fix (GPS, network, passive; ≤24 h old) when the request times
out. A background caller holding only while-in-use location permission is
app-op-blocked by the framework — every read silently returns null — which is
why capture requires the background grant. The pipeline stores the fix on the
transaction or unmatched row when present (ADR-0021, ADR-0026, ADR-0034).
Cross-source dedupe
Google Wallet card charges also arrive as a notification from the issuing bank
app. TransactionRepository.recordCapture treats the bank app as authoritative
and reconciles in one Room transaction (TransactionDao.insertOrMergeCrossSource,
ADR-0020/ADR-0074), so either arrival order — including simultaneous delivery —
produces exactly one row:
- a Wallet alert is suppressed when a completed bank transaction with the same amount + currency + direction exists within ±90 s;
- a bank alert merges into an existing Wallet-only row in place, keeping its
row id (so cascaded
transaction_items/attachmentssurvive) — bank-owned fields overwrite, user-owned fields (note,categoryId,accountId, the captured location andisReviewed) are kept; - a duplicate
notificationKeyis suppressed (insertreturns-1).
Only COMPLETED candidates participate; pending/failed alerts never suppress a
real charge. CapturePipeline posts the captured notification only for a
newly inserted row — a merge or suppression never re-notifies.
Review queue diagnostics
The review queue's raw-notification clipboard action (per-item long-press and
the "copy all for developer" export) exists only in debug builds
(BuildConfig.DEBUG, issue #306). Release builds expose no path that copies
stored notification text to the system clipboard.
Threading & failure handling
- All work is
suspendand runs onDispatchers.IO; writes are never on the main thread. - Every failure boundary is logged and swallowed (
Log.w) so the system service or the receiver never crashes: text extraction, parsing, classification, per-notification replay.CancellationExceptionis rethrown at every seam, never logged as a failure or converted into a null category (issue #320). - The session dedupe ledger is bounded (
MAX_SESSION_KEYS = 512, oldest key evicted) and guarded bysynchronized; it is a smallLinkedHashSet<String>of the keys owned this session. A throw from the rule read or a store write, or coroutine cancellation, releases the key; a committed write completes it. A post-commit feedback-notification failure does not release the key and never repeats the ledger insert.