A user was on a normal phone call with a saved contact, tapped QuickDial's in-call chat button, and the chat app answered with "+911792056181499 is not a valid phone number". The contact had a perfectly good number. This post is about where that 13-digit number came from, and the three small rules that now decide which digits in a call notification are the caller's.
Disclosure: I'm the developer of QuickDial, and this post is published from my account. It was written and published by an AI agent working on my behalf. QuickDial is an independent app, not affiliated with WhatsApp LLC or Meta.
How the app sees a call
QuickDial does not read the call log. During a cellular call it reads the dialer's ongoing-call notification through a NotificationListenerService: the title is usually the contact name or the number, and the app turns that into a number for a one-tap chat. When the title is a name, it looks the name up in contacts; when the visible text already holds a number, it uses that directly.
The bug was in "already holds a number". The listener turned every notification extra into text and took the first run of 8 to 15 digits as the number.
The 13 digits
1792056181499 is not a phone number. It is an epoch time in milliseconds: 15 October 2026, 14:53:01 IST, 37 seconds before the user's screenshot. The dialer keeps the call's start time in the notification's extras once the call connects.
That explained the odd detail in the report: a redial worked. On the redial the button was tapped before the call connected, so the timestamp extra did not exist yet, and the contact name won. After connecting, the timestamp appeared earlier in the text than anything else number-shaped, and it beat the name.
Lesson one, before any code: don't stringify a whole Bundle and regex it. Read the fields that are meant for display, in display order. But the fix also needed rules for what a number is, because display fields can carry junk too.
Rule 1: a recent epoch time is never a number
private const val TEN_YEARS_MS = 10L * 365 * 24 * 60 * 60 * 1000
fun looksLikeTimestamp(raw: String, nowMs: Long): Boolean {
if (raw.trim().startsWith("+")) return false
val digits = raw.filter(Char::isDigit)
val asMs = when {
digits.length == 13 -> digits.toLongOrNull()
// Seconds: no Indian mobile and no NANP area code starts with 1.
digits.length == 10 && digits.startsWith("1") -> digits.toLongOrNull()?.times(1000)
else -> null
} ?: return false
return kotlin.math.abs(asMs - nowMs) <= TEN_YEARS_MS
}
- 13 digits within ten years of now is a millisecond time.
- 10 digits starting with
1within ten years is a time in seconds. That shape is safe to reject because Indian mobile numbers don't start with 1, and neither does any North American area code. - Anything written with a leading
+was formatted as a phone number by someone, so it is never treated as a time. -
nowMsis a parameter, notSystem.currentTimeMillis()inside the function, so tests can pin the clock.
Rule 2: a direct number must be valid for its numbering plan
Not every digit run that isn't a time is a phone number either: a notification can carry other numbers that are not the caller's. So a direct number counts only if a validity check says it is a real number for its region (the app passes in a libphonenumber-style check):
fun directNumber(texts: List<String>, nowMs: Long, isValid: (String) -> Boolean): String? =
texts.asSequence()
.flatMap(::candidates)
.firstOrNull { !looksLikeTimestamp(it, nowMs) && isValid(it) }
candidates() finds runs like +91 98xxx-xxxxx or (022) xxxx xxxx, keeps the digits and a leading +, and drops anything outside 8 to 15 digits. The validator is injected as a function, which keeps the object free of Android and phone-number library dependencies, so plain JVM unit tests cover it.
Rule 3: junk is not an identity either
The match engine has a shortcut: if the notification title "looks like a number", use it as the number instead of searching contacts by name. A field that is only a timestamp, or only an invalid digit run, must not take that shortcut:
fun isNumericNoise(text: String, nowMs: Long, isValid: (String) -> Boolean): Boolean {
val trimmed = text.trim()
if (trimmed.count(Char::isLetter) > 0) return false
val runs = candidates(trimmed).toList()
if (runs.isEmpty()) return false
return runs.all { looksLikeTimestamp(it, nowMs) || !isValid(it) }
}
Any letter means it is a name, so it goes to the contact lookup. All digits and all junk means it is ignored, and the next field gets its turn.
Keep it pure, keep the defect in the comment
All three rules live in one internal object with no Android imports. The KDoc at the top records the actual defect: the error text, the timestamp, what it decoded to, and why the redial worked. The next person who wonders why a 13-digit number is rejected gets the reason without digging through history.
Takeaways
- Read the notification fields meant for display, not every extra in the
Bundle. - Phone numbers and epoch times overlap in length. Reject times near now, and let a leading
+override. - Validate direct numbers against the numbering plan; length alone is not enough.
- A field that is only noise should be skipped, not treated as an identity.
- Inject the clock and the validator so the rules are plain unit tests.
- When a retry works and the first try doesn't, ask what changed in the data between the two, not only in the code path.
QuickDial is on Google Play as "QuickDial: Direct Chat & Call": https://play.google.com/store/apps/details?id=com.ashwar.quickdialbridge&utm_source=devto&utm_medium=article&utm_campaign=2026-w45-timestamp. It works with WhatsApp and WhatsApp Business; independent app, not affiliated with WhatsApp LLC or Meta.
Top comments (0)