Skip to content

Commit 72d7085

Browse files
committed
fix: gate NANP heuristics by numbering plan
Existing tests omitted non-NANP suffix collisions, allowing international callers to gain North American risk signals. The new regression tests cover these collisions and preserve NANP scores.
1 parent a47ec35 commit 72d7085

8 files changed

Lines changed: 175 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to CallShield will be documented in this file.
44

55
## Unreleased
66

7+
- North American toll-free, VoIP, neighbor-spoof, hot-campaign and repeat-call
8+
checks now ignore international numbers whose last ten digits resemble a
9+
North American number. Other international risk checks still run.
710
- Region Rules now recognizes all 453 active geographic area codes in a pinned
811
NANP registry snapshot, including new 465, 483 and 942. Unknown codes no
912
longer trigger an out-of-region block. The 324 and 826 city labels are fixed.

‎README.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<p align="center">
1313
<a href="https://github.com/SysAdminDoc/CallShield/releases/latest"><img src="https://img.shields.io/github/v/release/SysAdminDoc/CallShield?style=flat-square&color=a6e3a1" alt="Release"></a>
1414
<img src="https://img.shields.io/badge/Spam%20Numbers-51%2C357-f38ba8?style=flat-square" alt="51,357 Numbers">
15-
<img src="https://img.shields.io/badge/Tests-1547-94e2d5?style=flat-square" alt="1547 Tests">
15+
<img src="https://img.shields.io/badge/Tests-1550-94e2d5?style=flat-square" alt="1550 Tests">
1616
<img src="https://img.shields.io/badge/Android-10%2B-89b4fa?style=flat-square" alt="Android 10+">
1717
<img src="https://img.shields.io/badge/License-MIT-cba6f7?style=flat-square" alt="MIT License">
1818
<img src="https://img.shields.io/badge/API%20Keys-None-fab387?style=flat-square" alt="No required API keys">
@@ -125,7 +125,7 @@ All detection layers implement a shared `IChecker` interface and run in priority
125125
| 4300 | **Region Rules** | Block | Opt-in offline blocking outside the US/Canadian regions and country calling codes you allow |
126126
| 4000 | **Quiet Hours** | Block | Block all non-contact calls during configurable hours (calls only) |
127127
| 3500 | **Frequency Auto-Block** | Block | Numbers that call 3+ times in 7 days get auto-blocked |
128-
| 3000 | **Heuristic Engine** | Block | VoIP ranges, neighbor spoofing, rapid-fire detection, 30+ rules |
128+
| 3000 | **Heuristic Engine** | Block | North American VoIP ranges, neighbor spoofing, toll-free and rapid-fire checks; international risk checks also run |
129129
| 2500 | **Campaign Burst** | Block | NPA-NXX prefix clustering detects coordinated spam waves |
130130
| 2250 | **Caller Name Rules** | Block | Carrier-presented names can match bounded, user-defined `*`/`?` patterns after every allow layer (requires the device to provide a name during screening) |
131131
| 2000 | **ML Spam Scorer** | Block | 20-feature on-device gradient-boosted tree model |
@@ -493,12 +493,12 @@ RELEASE_KEY_PASSWORD=...
493493
## Testing
494494

495495
```bash
496-
./gradlew testDebugUnitTest # 1547 tests
496+
./gradlew testDebugUnitTest # 1550 tests
497497
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
498498
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
499499
```
500500

501-
The suite is **1547 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
501+
The suite is **1550 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
502502

503503
Two GitHub workflows run without building the app. **Validation** runs the Worker and
504504
pipeline suites on every push except report-only ones (`run-pipeline-tests.ps1 -CorrectnessOnly`),
@@ -535,7 +535,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
535535
| Community API | Cloudflare Workers |
536536
| URL Safety | Local spam-domain data; optional URLhaus (abuse.ch) |
537537
| Verification | Local Gradle, lint, and release-artifact checks |
538-
| Tests | 1547 JVM unit tests (JUnit) |
538+
| Tests | 1550 JVM unit tests (JUnit) |
539539
| Strings | 1583 string resources and 38 plural groups (translation-ready) |
540540
| Accessibility | 100+ content descriptions, 48dp touch targets |
541541
| Min SDK | 29 (Android 10) |
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.sysadmindoc.callshield.data
2+
3+
/** Numbering plan for the NANP-only detection rules. */
4+
enum class NumberingPlan {
5+
NANP,
6+
OTHER,
7+
UNREADABLE,
8+
;
9+
10+
companion object {
11+
fun from(number: String): NumberingPlan {
12+
val compact = number.filterNot { it == ' ' || it == '-' || it == '(' || it == ')' || it == '.' }
13+
val international = compact.startsWith('+')
14+
val digits = if (international) compact.drop(1) else compact
15+
if (digits.isEmpty() || digits.any { it !in '0'..'9' }) return UNREADABLE
16+
if (international) {
17+
if (digits.length !in 2..15 || digits.startsWith('0')) return UNREADABLE
18+
return if (digits.startsWith('1')) {
19+
if (digits.length == 11) NANP else UNREADABLE
20+
} else {
21+
OTHER
22+
}
23+
}
24+
return if (digits.length == 10 || (digits.length == 11 && digits.startsWith('1'))) {
25+
NANP
26+
} else {
27+
UNREADABLE
28+
}
29+
}
30+
}
31+
}

‎app/src/main/java/com/sysadmindoc/callshield/data/SpamHeuristics.kt‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,11 @@ class SpamHeuristics
8585
fun isNeighborSpoof(
8686
context: Context,
8787
incomingNumber: String,
88+
numberingPlan: NumberingPlan = NumberingPlan.from(incomingNumber),
8889
): Boolean {
90+
if (numberingPlan != NumberingPlan.NANP) return false
8991
val userNumber = getUserPhoneNumber(context) ?: return false
92+
if (NumberingPlan.from(userNumber) != NumberingPlan.NANP) return false
9093
val userDigits = filterAsciiDigitsLast(userNumber, 10)
9194
val inDigits = filterAsciiDigitsLast(incomingNumber, 10)
9295

@@ -101,7 +104,11 @@ class SpamHeuristics
101104
// Toll-free numbers are heavily abused by robocallers
102105
private val tollFreePrefixes = setOf("800", "888", "877", "866", "855", "844", "833")
103106

104-
fun isTollFree(number: String): Boolean {
107+
fun isTollFree(
108+
number: String,
109+
numberingPlan: NumberingPlan = NumberingPlan.from(number),
110+
): Boolean {
111+
if (numberingPlan != NumberingPlan.NANP) return false
105112
val digits = filterAsciiDigitsLast(number, 10)
106113
if (digits.length < 10) return false
107114
return digits.substring(0, 3) in tollFreePrefixes
@@ -322,7 +329,11 @@ class SpamHeuristics
322329
"437373",
323330
)
324331

325-
fun isHighSpamVoipRange(number: String): Boolean {
332+
fun isHighSpamVoipRange(
333+
number: String,
334+
numberingPlan: NumberingPlan = NumberingPlan.from(number),
335+
): Boolean {
336+
if (numberingPlan != NumberingPlan.NANP) return false
326337
val digits = filterAsciiDigitsLast(number, 10)
327338
if (digits.length < 10) return false
328339
return digits.substring(0, 6) in highSpamVoipNpanxx
@@ -341,7 +352,11 @@ class SpamHeuristics
341352

342353
fun hasHotRanges(): Boolean = hotCampaignRanges.isNotEmpty()
343354

344-
fun isHotCampaignRange(number: String): Boolean {
355+
fun isHotCampaignRange(
356+
number: String,
357+
numberingPlan: NumberingPlan = NumberingPlan.from(number),
358+
): Boolean {
359+
if (numberingPlan != NumberingPlan.NANP) return false
345360
if (hotCampaignRanges.isEmpty()) return false
346361
val digits = filterAsciiDigitsLast(number, 10)
347362
if (digits.length < 6) return false
@@ -370,12 +385,15 @@ class SpamHeuristics
370385
number: String,
371386
windowMs: Long = 3600_000,
372387
threshold: Int = 3,
388+
numberingPlan: NumberingPlan = NumberingPlan.from(number),
373389
): Boolean {
390+
if (numberingPlan != NumberingPlan.NANP) return false
374391
val now = System.currentTimeMillis()
375392
val normalized = filterAsciiDigitsLast(number, 10)
376393
val count =
377394
recentNumbers.count { (num, time) ->
378-
filterAsciiDigitsLast(num, 10) == normalized && (now - time) < windowMs
395+
NumberingPlan.from(num) == NumberingPlan.NANP &&
396+
filterAsciiDigitsLast(num, 10) == normalized && (now - time) < windowMs
379397
}
380398
return count >= threshold
381399
}
@@ -393,6 +411,7 @@ class SpamHeuristics
393411
fun analyze(
394412
context: Context,
395413
number: String,
414+
numberingPlan: NumberingPlan = NumberingPlan.from(number),
396415
smsBody: String? = null,
397416
recentBlockedNumbers: List<Pair<String, Long>> = emptyList(),
398417
enableNeighborSpoof: Boolean = true,
@@ -418,31 +437,31 @@ class SpamHeuristics
418437
}
419438

420439
// High-spam VoIP range
421-
if (isHighSpamVoipRange(number)) {
440+
if (isHighSpamVoipRange(number, numberingPlan)) {
422441
score += 30
423442
reasons.add("voip_spam_range")
424443
}
425444

426445
// Active campaign range — 3+ distinct numbers from this NPA-NXX in last 24h
427-
if (isHotCampaignRange(number)) {
446+
if (isHotCampaignRange(number, numberingPlan)) {
428447
score += 35
429448
reasons.add("hot_campaign_range")
430449
}
431450

432451
// Toll-free (mild signal — many legit businesses use these)
433-
if (isTollFree(number)) {
452+
if (isTollFree(number, numberingPlan)) {
434453
score += 10
435454
reasons.add("toll_free")
436455
}
437456

438457
// Neighbor spoofing (gated by the Settings toggle)
439-
if (enableNeighborSpoof && isNeighborSpoof(context, number)) {
458+
if (enableNeighborSpoof && isNeighborSpoof(context, number, numberingPlan)) {
440459
score += 50
441460
reasons.add("neighbor_spoof")
442461
}
443462

444463
// Rapid-fire calling
445-
if (isRapidFire(recentBlockedNumbers, number)) {
464+
if (isRapidFire(recentBlockedNumbers, number, numberingPlan = numberingPlan)) {
446465
score += 40
447466
reasons.add("rapid_fire")
448467
}
@@ -531,6 +550,13 @@ class SpamHeuristics
531550
smsBody: String? = null,
532551
recentBlockedNumbers: List<Pair<String, Long>> = emptyList(),
533552
senderProvenance: SenderProvenance? = null,
534-
): HeuristicResult = shared.analyze(context, number, smsBody, recentBlockedNumbers, senderProvenance = senderProvenance)
553+
): HeuristicResult =
554+
shared.analyze(
555+
context,
556+
number,
557+
smsBody = smsBody,
558+
recentBlockedNumbers = recentBlockedNumbers,
559+
senderProvenance = senderProvenance,
560+
)
535561
}
536562
}

‎app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,7 @@ internal class HeuristicChecker(
934934
spamHeuristics.analyze(
935935
context = appContext,
936936
number = ctx.number,
937+
numberingPlan = ctx.numberingPlan,
937938
smsBody = sms,
938939
recentBlockedNumbers = recentBlocked.map { it.number to it.timestamp },
939940
enableNeighborSpoof = ctx.prefs[SpamRepository.KEY_NEIGHBOR_SPOOF] ?: true,

‎app/src/main/java/com/sysadmindoc/callshield/data/checker/IChecker.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.sysadmindoc.callshield.data.checker
33
import android.content.Context
44
import android.util.Log
55
import androidx.datastore.preferences.core.Preferences
6+
import com.sysadmindoc.callshield.data.NumberingPlan
67
import com.sysadmindoc.callshield.data.SenderProvenance
78
import com.sysadmindoc.callshield.data.SpamRepository
89
import com.sysadmindoc.callshield.data.repository.SpamRepositoryImpl
@@ -140,6 +141,9 @@ data class CheckContext(
140141
*/
141142
val alternateForms: List<String> = emptyList(),
142143
) {
144+
/** Shared plan classification for NANP-only checkers in this evaluation. */
145+
val numberingPlan: NumberingPlan = NumberingPlan.from(number)
146+
143147
/**
144148
* Forms to try when matching stored numbers exactly, canonical first.
145149
* Allow and block lookups both use it, so a number saved in any form
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.sysadmindoc.callshield.data
2+
3+
import android.content.Context
4+
import android.telephony.TelephonyManager
5+
import androidx.datastore.preferences.core.emptyPreferences
6+
import androidx.test.core.app.ApplicationProvider
7+
import com.sysadmindoc.callshield.data.checker.CheckContext
8+
import org.junit.After
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertFalse
11+
import org.junit.Assert.assertTrue
12+
import org.junit.Test
13+
import org.junit.runner.RunWith
14+
import org.robolectric.RobolectricTestRunner
15+
import org.robolectric.Shadows
16+
17+
@RunWith(RobolectricTestRunner::class)
18+
class NanpHeuristicGateTest {
19+
private val context: Context = ApplicationProvider.getApplicationContext()
20+
private val heuristics = SpamHeuristics()
21+
22+
@After
23+
fun resetHotRanges() = heuristics.updateHotRanges(emptyList())
24+
25+
@Test
26+
fun `context classifies international national and unreadable numbers once`() {
27+
for (number in listOf("+493012340101", "+658005551234", "+642025551234")) {
28+
assertEquals(NumberingPlan.OTHER, checkContext(number).numberingPlan)
29+
}
30+
assertEquals(NumberingPlan.NANP, checkContext("+13012340101").numberingPlan)
31+
assertEquals(NumberingPlan.NANP, checkContext("(301) 234-0101").numberingPlan)
32+
assertEquals(NumberingPlan.UNREADABLE, checkContext("++13012340101").numberingPlan)
33+
assertEquals(NumberingPlan.UNREADABLE, checkContext("+1301234").numberingPlan)
34+
}
35+
36+
@Test
37+
fun `all five NANP rules ignore international suffix collisions`() {
38+
setOwnNumber("3012349999")
39+
heuristics.updateHotRanges(listOf("301234"))
40+
val now = System.currentTimeMillis()
41+
val berlin = "+493012340101"
42+
val singapore = "+658005551234"
43+
val newZealand = "+642025551234"
44+
val history = List(3) { berlin to now - 1000L }
45+
46+
assertFalse(heuristics.isNeighborSpoof(context, berlin))
47+
assertFalse(heuristics.isHotCampaignRange(berlin))
48+
assertFalse(heuristics.isTollFree(singapore))
49+
assertFalse(heuristics.isHighSpamVoipRange(newZealand))
50+
assertFalse(heuristics.isRapidFire(history, berlin))
51+
assertFalse(heuristics.isRapidFire(history, "+13012340101"))
52+
53+
for (number in listOf(berlin, singapore, newZealand)) {
54+
val result =
55+
heuristics.analyze(
56+
context,
57+
number,
58+
numberingPlan = checkContext(number).numberingPlan,
59+
recentBlockedNumbers = history,
60+
)
61+
assertEquals(number, 0, result.score)
62+
assertTrue(number, result.reasons.isEmpty())
63+
}
64+
}
65+
66+
@Test
67+
fun `NANP fixture retains neighbor hot range and rapid fire signals`() {
68+
setOwnNumber("3012349999")
69+
heuristics.updateHotRanges(listOf("301234"))
70+
val number = "+13012340101"
71+
val now = System.currentTimeMillis()
72+
val history = List(3) { number to now - 1000L }
73+
val result =
74+
heuristics.analyze(
75+
context,
76+
number,
77+
numberingPlan = checkContext(number).numberingPlan,
78+
recentBlockedNumbers = history,
79+
)
80+
81+
assertEquals(100, result.score)
82+
assertEquals(listOf("hot_campaign_range", "neighbor_spoof", "rapid_fire"), result.reasons)
83+
assertTrue(heuristics.isTollFree("+18005551234"))
84+
assertTrue(heuristics.isHighSpamVoipRange("+12025551234"))
85+
}
86+
87+
private fun checkContext(number: String) = CheckContext(appContext = context, number = number, realtimeCall = true, prefs = emptyPreferences())
88+
89+
private fun setOwnNumber(number: String) {
90+
val telephony = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
91+
Shadows.shadowOf(telephony).setLine1Number(number)
92+
}
93+
}

‎app/src/test/java/com/sysadmindoc/callshield/data/PhoneNumberFuzzTest.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,13 @@ class PhoneNumberFuzzTest {
5656
}
5757

5858
// SpamHeuristics — public methods that take a phone number
59+
NumberingPlan.from(input)
5960
SpamHeuristics.isTollFree(input)
6061
SpamHeuristics.isInternationalPremium(input)
6162
SpamHeuristics.isHighSpamVoipRange(input)
6263
SpamHeuristics.isInvalidFormat(input)
6364
SpamHeuristics.isHotCampaignRange(input)
65+
SpamHeuristics.isRapidFire(listOf(input to System.currentTimeMillis()), input)
6466

6567
// SpamHeuristics.isWangiriCountryCode (public)
6668
SpamHeuristics.isWangiriCountryCode(input)

0 commit comments

Comments
 (0)