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 digit runs ≥ 8 to ****last4 (see security) before anything is stored or hashed.
NotificationFilter Pure pre-parse gate: isExcluded (promo/OTP markers), hasTransactionSignal (any supported currency amount 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. Runs cross-source dedupe (bank preferred over a Google Wallet mirror, ADR-0020) 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); tap deep-links via autobudget://transaction/{id}. TransactionNotificationText formats title/body (pure Kotlin); NotificationGate checks the notify_on_parse 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 (digits masked)
    L->>P: onNotification(pkg, text, postTime)
    P->>P: key = NotificationKey.compute(...)
    alt session duplicate (seen in sessionSeen)
        P-->>L: return true (skipped)
    else
        alt isExcluded (promo/OTP)
            P-->>L: return true (dropped)
        else hasTransactionSignal == false
            alt isMaskedContent
                P->>DB: insert UnmatchedEntity(isMasked=true)
            end
            P-->>L: return true
        else
            P->>PE: match(pkg, text)  [rules from rules table]
            alt candidate == null
                P->>DB: insert UnmatchedEntity
            else candidate
                P->>CL: classify(merchant, amount, direction, pkg)
                CL-->>P: categoryId?
                P->>DB: insert TransactionEntity(notificationKey, isHidden=status!=COMPLETED, parseSource, rawJson?)
                Note over P,DB: unique index on notificationKey → duplicate returns -1, no-op
                Note over P: new row + notify_on_parse != "false"
                P->>TN: show(transaction, categoryName)
                TN->>NMS: post silent transaction-captured notification
            end
            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 is idempotent: the session key set and the DB unique index both swallow duplicates.

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 in sessionSeen 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)
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): Wallet mirror suppressed, or existing Wallet-only row replaced by the bank row
inserted row (not a duplicate) + notify_on_parse != "false" silent transaction-captured notification posted

The session 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 through session dedupe.

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. 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.resolveCrossSourceDuplicate treats the bank app as authoritative: a Wallet alert is suppressed when a completed bank transaction with the same amount + currency + direction exists within ±90 s, and a bank alert replaces an existing Wallet-only row (ADR-0020). Only COMPLETED candidates participate; pending/failed alerts never suppress a real charge.

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.
  • The session dedupe set is bounded (MAX_SESSION_KEYS = 512, cleared when full) and guarded by synchronized; it is a small LinkedHashSet<String>.