DEV Community

Cover image for Turning bank notifications into expenses on Android, without a bank login
Rao Asad
Rao Asad

Posted on AI-assisted

Turning bank notifications into expenses on Android, without a bank login

Every time I pay for something, my bank sends a notification: "You spent Rs. 1,250 at …". The expense tracker on the same phone has no idea it happened, and later I'm expected to type it in by hand.

That gap is the reason Onetap exists. You pick which banking or wallet apps it should watch, and when one of them posts a payment notification, it becomes an expense entry. There's no bank login and no bank API. It only reads notifications you already get, from apps you chose.

It sounds like a weekend feature. It took a lot longer, mostly because of what has to happen before any text leaves the phone. Here is how it works and what I got wrong along the way.

1. Listen to everything, read almost nothing

The entry point is a NotificationListenerService. Once the user grants notification access, Android hands it every notification on the phone: chats, emails, OTPs, all of it.

So the very first thing the handler does is look at the package name, and nothing else:

fun onPosted(context: Context, sbn: StatusBarNotification) {
    val packageName = sbn.packageName ?: return
    if (packageName == context.packageName) return
    scope.launch {
        // Only apps the user explicitly added. Nothing else about any
        // other notification is ever read.
        if (!bankApps.contains(packageName)) return@launch
        capture(sbn)
    }
}
Enter fullscreen mode Exit fullscreen mode

A WhatsApp message is dropped before its text is ever touched. I considered auto-detecting finance apps instead, but an explicit list is easier to explain and easier to trust.

Then a few cheap filters: group summaries repeat their children, and ongoing, foreground-service and progress notifications are status, not events. All of those are skipped.

2. One-time codes are never stored or sent

Banking apps send OTPs through the same channel as payment alerts, and an OTP is the most sensitive thing a phone receives. Those are dropped on the device, before anything is saved.

The naive check ("does it contain 'OTP'?") is wrong in both directions:

  • Lots of real payment alerts end with "Never share your OTP with anyone." A keyword check throws those away.
  • "Rs 5000" is a 4-digit number, but it's an amount, not a code.

So a message counts as a one-time code only when both are true: it names a code (OTP, verification code, MPIN…) outside a safety reminder, and it contains a bare 4–8 digit number that isn't preceded by a currency mark.

fun isOneTimeCode(text: String): Boolean {
    val lower = text.lowercase(Locale.ROOT)
    val withoutReminders = lower.replace(REMINDER, " ")   // "never share your otp…"
    if (!CODE_WORD.containsMatchIn(withoutReminders)) return false
    return BARE_NUMBER.findAll(lower).any { number ->
        val before = lower.substring(maxOf(0, number.range.first - 6), number.range.first)
        !CURRENCY_BEFORE.containsMatchIn(before)          // "rs 5000" is money
    }
}
Enter fullscreen mode Exit fullscreen mode

It's all pure functions, so every real notification that ever confused it became a unit test.

3. Mask account numbers before anything leaves

The text of a payment alert does need to be read by a language model: bank formats vary far too much for regexes alone. But the model doesn't need your card number. Card, account, IBAN and phone numbers are masked on the phone first, keeping the last four digits:

Before: Rs 2,000 sent from a/c 01234567890123 to 0300 1234567
After:  Rs 2,000 sent from a/c ****0123 to 0300 1234567
Enter fullscreen mode Exit fullscreen mode

Wait, the phone number survived. That's the interesting part: the rules have to mask long identifiers without masking amounts, dates or times. What I settled on:

  • 9 or more digits written together ‚Üí masked
  • 12 or more digits in groups of 3+ separated by spaces or dashes (4111 1111 1111 1234) ‚Üí masked
  • IBAN-shaped strings ‚Üí masked
  • 1,250,000 (commas) and 15-09-2026 14:32 (short groups) ‚Üí left alone

A local 11-digit phone number written as 0300 1234567 is two groups, so it isn't caught by the grouped rule. Written as 03001234567 it is. That's a known gap I'd rather tell you about than hide. Every rule is a test case, and each new bank format adds more.

4. Don't let the model guess what it can't know

The model gets the masked text plus the user's categories, and returns structured JSON: amount, merchant, category, date. That part works well.

What went wrong was currency. A real alert read "You spent Rs. 5,930.18" and was recorded as $62.00 in a USD list. The model had been asked which currency "Rs" was, and it said INR. The correct answer was Pakistani rupees: $21.40.

"Rs" is written by Pakistan, India, Sri Lanka and Nepal. The model was reading the same two characters I was, with no extra information. Its answer looked like knowledge and was really a coin flip. I'd even written a function that refused to trust it, and then the very next line fell back to the model's answer anyway.

The lesson I took: a model's answer to an ambiguous question isn't evidence. Tie-breakers have to come from something that actually knows. For a rupee, that's the list's own currency, then the SIM's country (not the phone's language: my phone runs English on a Pakistani SIM). And if nothing knows, ask the user rather than guess.

For now I've gone further and switched conversion off: Onetap records the amount the alert states, in the list's currency, and you keep a rupee bank on a rupee list. The cleaner fix, which I haven't built yet, is binding each bank app to its own list, so conversion is never needed in the first place.

5. Never lose a payment

A notification shows up once. If the app drops it, that payment is gone from the record and the user won't know. So:

  • Write to disk first. Each kept notification goes into an encrypted inbox before any network call.
  • Retry with WorkManager. No network in the metro? The entry waits, and a worker tries again when connectivity returns, however long that takes.
  • De-duplicate with a fingerprint. Banks sometimes post the same alert twice, and retries can overlap. A SHA-256 of the package, the normalised words and the post time means the same payment is never recorded twice, even if the process dies halfway through.
  • Catch up on reconnect. While the listener isn't bound, nothing is heard. When it reconnects, it reads the bank notifications still sitting in the shade from the last three days, skipping ones it has already handled.

6. The phone that kills your listener

On my Xiaomi (HyperOS), the first real test failed silently. From logcat:

  • Swiping the app away from recents kills the process, listener included.
  • With "Autostart" off, the system refuses to rebind the notification listener, so nothing is heard until the user opens the app again.
  • After an app update, the listener can stay unbound even though access is still granted. Calling requestRebind() and toggling the listener component's enabled state brings it back.

Onetap now asks for Autostart only on Xiaomi, Redmi and POCO phones, inside the bank-automation setup, with an explanation of why. With it on, a real wallet payment of Rs 1 was recorded 1.2 seconds after its notification arrived.

Testing it without spending money

You don't want to make a real payment for every test. Debug builds also accept notifications posted from the shell:

adb shell cmd notification post -S bigtext -t "HBL" test-1 \
  "PKR 1,250.00 debited from a/c 01234567890123 at FOODPANDA on 06-10-2026"
Enter fullscreen mode Exit fullscreen mode

These come from com.android.shell, which the debug build treats as a bank app (never in release). That gave me a repeatable way to run real-looking bank text, OTPs and odd formats through the whole pipeline on a real phone.

What I'd tell anyone building this

  • Filter by package before reading content. It's the difference between "reads my bank alerts" and "reads my messages".
  • Decide what must never leave the device, and enforce it in pure, tested functions.
  • Treat model output as a suggestion, not a fact, wherever the input is ambiguous.
  • Assume Android will kill you, and write things down before doing anything slow.

Onetap is on Google Play with a 3-day free trial if you want to try it with your own bank. If yours sends a format it misreads, I'd really like to see it (with the numbers removed).

Top comments (0)