Skip to content

Commit cdb845f

Browse files
committed
fix(region): read a bare number in the phone's own country
Region rules read every bare ten- or eleven-digit number as North American. Android leaves a caller ID in national form when it can't validate it for the phone's home region, so on an Italian phone a domestic number that stayed bare never matched the user's own +39 entry and was blocked as out of region, while ten digits shaped like a New York number counted as NY. - RegionCallingCodes maps each ISO region to its ITU-T calling code (245 regions, generated from libphonenumber's metadata via Python phonenumbers 9.0.39); Android exposes no public API for it. - regionCode() and isOutsideAllowedRegions() take the phone's home region. A bare number is NANP only where the home region uses +1 or is unknown (the old behavior); elsewhere it is read with the home calling code and never gets a US state or Canadian province. - RegionBlockChecker reads the home region (PhoneIdentityCanonicalizer's TTL-cached value) only for a bare number. Tests: Italian-phone cases (a bare domestic number allowed by +39 and blocked by +44, ten NY-shaped digits not read as NY), NANP-home and unknown-region cases keeping the old reading, and the table's NANP coverage. The home-region branch was mutation-checked (the mutant failed exactly the Italian-phone test). 1376 JVM tests pass; lint, ktlint and detekt are clean.
1 parent 0a40876 commit cdb845f

5 files changed

Lines changed: 114 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,10 @@ All notable changes to CallShield will be documented in this file.
104104
is Colorado, never Colombia.
105105
- Region rules no longer mistake a ten-digit number from outside North America
106106
for a North American one. A Penang landline (+60 4) counted as Vancouver's
107-
604 area code, so allowing British Columbia let it ring.
107+
604 area code, so allowing British Columbia let it ring. On a phone outside
108+
North America, a number Android couldn't put in international form is now
109+
read as a number from your own country instead of a North American one, so
110+
allowing your country's code lets it ring.
108111
- Settings has a Telemarketing ranges card for number ranges regulators set
109112
aside for sales calls. It can block Spain's 400 range (required for
110113
commercial calls from 17 October 2026), India's 140 promotional series and
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.sysadmindoc.callshield.data
2+
3+
import java.util.Locale
4+
5+
/**
6+
* ITU-T calling code of each ISO 3166 region. Android leaves a caller ID in
7+
* national form when it can't validate it for the phone's home region, and
8+
* region rules read such a number in that region's numbering, so they need
9+
* the region's code.
10+
*
11+
* Generated from libphonenumber's metadata (Python `phonenumbers` 9.0.39,
12+
* `country_code_for_region` over `SUPPORTED_REGIONS`, 245 regions).
13+
*/
14+
internal object RegionCallingCodes {
15+
private val codes: Map<String, String> =
16+
(
17+
"AC:247 AD:376 AE:971 AF:93 AG:1 AI:1 AL:355 AM:374 AO:244 AR:54 AS:1 AT:43 " +
18+
"AU:61 AW:297 AX:358 AZ:994 BA:387 BB:1 BD:880 BE:32 BF:226 BG:359 BH:973 BI:257 " +
19+
"BJ:229 BL:590 BM:1 BN:673 BO:591 BQ:599 BR:55 BS:1 BT:975 BW:267 BY:375 BZ:501 " +
20+
"CA:1 CC:61 CD:243 CF:236 CG:242 CH:41 CI:225 CK:682 CL:56 CM:237 CN:86 CO:57 " +
21+
"CR:506 CU:53 CV:238 CW:599 CX:61 CY:357 CZ:420 DE:49 DJ:253 DK:45 DM:1 DO:1 " +
22+
"DZ:213 EC:593 EE:372 EG:20 EH:212 ER:291 ES:34 ET:251 FI:358 FJ:679 FK:500 FM:691 " +
23+
"FO:298 FR:33 GA:241 GB:44 GD:1 GE:995 GF:594 GG:44 GH:233 GI:350 GL:299 GM:220 " +
24+
"GN:224 GP:590 GQ:240 GR:30 GT:502 GU:1 GW:245 GY:592 HK:852 HN:504 HR:385 HT:509 " +
25+
"HU:36 ID:62 IE:353 IL:972 IM:44 IN:91 IO:246 IQ:964 IR:98 IS:354 IT:39 JE:44 " +
26+
"JM:1 JO:962 JP:81 KE:254 KG:996 KH:855 KI:686 KM:269 KN:1 KP:850 KR:82 KW:965 " +
27+
"KY:1 KZ:7 LA:856 LB:961 LC:1 LI:423 LK:94 LR:231 LS:266 LT:370 LU:352 LV:371 " +
28+
"LY:218 MA:212 MC:377 MD:373 ME:382 MF:590 MG:261 MH:692 MK:389 ML:223 MM:95 MN:976 " +
29+
"MO:853 MP:1 MQ:596 MR:222 MS:1 MT:356 MU:230 MV:960 MW:265 MX:52 MY:60 MZ:258 " +
30+
"NA:264 NC:687 NE:227 NF:672 NG:234 NI:505 NL:31 NO:47 NP:977 NR:674 NU:683 NZ:64 " +
31+
"OM:968 PA:507 PE:51 PF:689 PG:675 PH:63 PK:92 PL:48 PM:508 PR:1 PS:970 PT:351 " +
32+
"PW:680 PY:595 QA:974 RE:262 RO:40 RS:381 RU:7 RW:250 SA:966 SB:677 SC:248 SD:249 " +
33+
"SE:46 SG:65 SH:290 SI:386 SJ:47 SK:421 SL:232 SM:378 SN:221 SO:252 SR:597 SS:211 " +
34+
"ST:239 SV:503 SX:1 SY:963 SZ:268 TA:290 TC:1 TD:235 TG:228 TH:66 TJ:992 TK:690 " +
35+
"TL:670 TM:993 TN:216 TO:676 TR:90 TT:1 TV:688 TW:886 TZ:255 UA:380 UG:256 US:1 " +
36+
"UY:598 UZ:998 VA:39 VC:1 VE:58 VG:1 VI:1 VN:84 VU:678 WF:681 WS:685 XK:383 " +
37+
"YE:967 YT:262 ZA:27 ZM:260 ZW:263"
38+
).split(' ').associate { it.substringBefore(':') to it.substringAfter(':') }
39+
40+
/** Calling code digits (no `+`) for [regionIso], or null for an unknown region. */
41+
fun forRegion(regionIso: String?): String? = regionIso?.let { codes[it.uppercase(Locale.ROOT)] }
42+
}

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ object RegionRules {
1212
const val MAX_NAME_PATTERNS = 30
1313
const val MAX_NAME_PATTERN_LENGTH = 60
1414
private const val NANP_NATIONAL_LENGTH = 10
15+
private const val NANP_CALLING_CODE = "1"
1516

1617
/** `1` plus a three-digit area code, as in `+1809`. */
1718
private const val NANP_AREA_CODE_ENTRY_LENGTH = 4
@@ -128,33 +129,55 @@ object RegionRules {
128129
/**
129130
* The NANP state, province or territory code of [number], or null. A `+`
130131
* number outside +1 is never NANP, even when it has ten digits: Penang's
131-
* +60 4 would otherwise read as Vancouver's 604.
132+
* +60 4 would otherwise read as Vancouver's 604. A bare number is NANP
133+
* only on a phone whose home region uses +1 or is unknown.
132134
*/
133-
fun regionCode(number: String): String? = if (number.startsWith("+") && !number.startsWith("+1")) null else AreaCodeLookup.getRegionCode(number)
135+
fun regionCode(
136+
number: String,
137+
homeRegionIso: String? = null,
138+
): String? =
139+
when {
140+
number.startsWith("+") && !number.startsWith("+1") -> null
141+
!number.startsWith("+") && !homeUsesNanp(homeRegionIso) -> null
142+
else -> AreaCodeLookup.getRegionCode(number)
143+
}
134144

135145
fun isOutsideAllowedRegions(
136146
number: String,
137147
allowedRegions: Set<String>,
148+
homeRegionIso: String? = null,
138149
): Boolean {
139150
val normalized = normalizeRegionCodes(allowedRegions)
140151
if (normalized.isEmpty()) return false
141-
val nanpRegion = regionCode(number)
152+
val nanpRegion = regionCode(number, homeRegionIso)
142153
if (nanpRegion != null && nanpRegion in normalized) return false
143-
val international = internationalForm(number) ?: return true
154+
val international = internationalForm(number, homeRegionIso) ?: return true
144155
return normalized.none { it.startsWith("+") && international.startsWith(it) }
145156
}
146157

147-
/** [number] as `+<digits>`, reading a bare ten- or eleven-digit number as NANP. */
148-
private fun internationalForm(number: String): String? {
158+
/**
159+
* [number] as `+<digits>`. Android leaves a number it can't validate in
160+
* national form, so a bare number is read in the phone's home region:
161+
* ten or eleven digits as NANP where that region uses +1 (or is unknown),
162+
* and with the home region's calling code anywhere else.
163+
*/
164+
private fun internationalForm(
165+
number: String,
166+
homeRegionIso: String?,
167+
): String? {
149168
if (number.startsWith("+")) return number
150169
val digits = filterAsciiDigits(number)
170+
val homeCode = RegionCallingCodes.forRegion(homeRegionIso)
171+
if (homeCode != null && homeCode != NANP_CALLING_CODE) return digits.takeIf { it.isNotEmpty() }?.let { "+$homeCode$it" }
151172
return when {
152173
digits.length == NANP_NATIONAL_LENGTH -> "+1$digits"
153174
digits.length == NANP_NATIONAL_LENGTH + 1 && digits.startsWith("1") -> "+$digits"
154175
else -> null
155176
}
156177
}
157178

179+
private fun homeUsesNanp(homeRegionIso: String?): Boolean = RegionCallingCodes.forRegion(homeRegionIso)?.let { it == NANP_CALLING_CODE } ?: true
180+
158181
fun matchesPresentedName(
159182
presentedName: String?,
160183
patterns: Set<String>,

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -701,8 +701,10 @@ internal class CallerNameBlockChecker : IChecker {
701701
}
702702
}
703703

704-
/** Blocks calls whose NANP state/province is not in the user's allowlist. */
705-
internal class RegionBlockChecker : IChecker {
704+
/** Blocks calls from outside the NANP regions and country calling codes the user allows. */
705+
internal class RegionBlockChecker(
706+
private val homeRegion: (Context) -> String? = phoneHomeRegion,
707+
) : IChecker {
706708
override val priority = CheckerPriority.REGION_BLOCK
707709
override val name = "region_block"
708710

@@ -722,6 +724,8 @@ internal class RegionBlockChecker : IChecker {
722724
ctx.appContext.getString(R.string.block_reason_out_of_region, region)
723725
}
724726
},
727+
// Only a bare (national-form) number needs the phone's home region.
728+
homeRegionIso = if (ctx.number.startsWith("+")) null else homeRegion(ctx.appContext),
725729
)
726730

727731
companion object {
@@ -731,9 +735,10 @@ internal class RegionBlockChecker : IChecker {
731735
descriptionForRegion: (String?) -> String = { region ->
732736
"Blocked — ${region ?: "international or unknown"} is outside allowed regions"
733737
},
738+
homeRegionIso: String? = null,
734739
): BlockResult? {
735-
if (!RegionRules.isOutsideAllowedRegions(number, allowedRegions)) return null
736-
val region = RegionRules.regionCode(number)
740+
if (!RegionRules.isOutsideAllowedRegions(number, allowedRegions, homeRegionIso)) return null
741+
val region = RegionRules.regionCode(number, homeRegionIso)
737742
return BlockResult.block(
738743
matchSource = "region_block",
739744
type = "out_of_region",

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,36 @@ class RegionRulesTest {
191191
assertNull(RegionRules.normalizeDialingCode("+1"))
192192
}
193193

194+
@Test
195+
fun `a bare number on a phone outside North America is read in that country`() {
196+
// Android leaves a number it can't validate in national form.
197+
assertFalse(RegionRules.isOutsideAllowedRegions("0612345678", setOf("+39"), homeRegionIso = "IT"))
198+
assertTrue(RegionRules.isOutsideAllowedRegions("0612345678", setOf("+44"), homeRegionIso = "IT"))
199+
// Ten digits shaped like a New York number are still an Italian number on an Italian phone.
200+
assertNull(RegionRules.regionCode("2125550123", homeRegionIso = "IT"))
201+
assertTrue(RegionRules.isOutsideAllowedRegions("2125550123", setOf("NY"), homeRegionIso = "IT"))
202+
assertTrue(RegionBlockChecker.decidePure("0612345678", setOf("+44"), homeRegionIso = "IT")?.shouldBlock == true)
203+
assertNull(RegionBlockChecker.decidePure("0612345678", setOf("+39"), homeRegionIso = "IT"))
204+
}
205+
206+
@Test
207+
fun `a bare number is NANP on a phone that uses +1 or has no known region`() {
208+
assertEquals("NY", RegionRules.regionCode("2125550123", homeRegionIso = "US"))
209+
assertEquals("NY", RegionRules.regionCode("2125550123", homeRegionIso = "DO"))
210+
assertEquals("NY", RegionRules.regionCode("2125550123"))
211+
assertFalse(RegionRules.isOutsideAllowedRegions("8095550123", setOf("+1809"), homeRegionIso = "DO"))
212+
assertFalse(RegionRules.isOutsideAllowedRegions("2125550123", setOf("NY"), homeRegionIso = "ZZ"))
213+
}
214+
215+
@Test
216+
fun `calling code table covers the NANP and reads regions case-insensitively`() {
217+
listOf("US", "CA", "PR", "DO", "JM").forEach { assertEquals(it, "1", RegionCallingCodes.forRegion(it)) }
218+
assertEquals("39", RegionCallingCodes.forRegion("it"))
219+
assertEquals("44", RegionCallingCodes.forRegion("GB"))
220+
assertNull(RegionCallingCodes.forRegion("ZZ"))
221+
assertNull(RegionCallingCodes.forRegion(null))
222+
}
223+
194224
@Test
195225
fun `ten digit international numbers are not read as NANP area codes`() {
196226
// Penang (+60 4) and Hamilton, New Zealand (+64 7) have ten digits after

0 commit comments

Comments
 (0)