Skip to content

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:

  1. 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).
  2. DB unique index — transactions.notificationKey and unmatched.notificationKey are unique (the latter nullable, so v9-era legacy rows coexist, ADR-0073), so a duplicate insert returns -1 and 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/attachments survive) — bank-owned fields overwrite, user-owned fields (note, categoryId, accountId, the captured location and isReviewed) are kept;
  • a duplicate notificationKey is suppressed (insert returns -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 suspend and runs on Dispatchers.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. CancellationException is 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 by synchronized; it is a small LinkedHashSet<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.