Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ android {

sourceSets {
getByName("main").java.directories.add("src/main/kotlin")
getByName("test").java.directories.add("src/test/kotlin")
}

compileOptions {
Expand Down Expand Up @@ -146,4 +147,5 @@ dependencies {
implementation(libs.libphonenumber)
implementation(libs.geocoder)
detektPlugins(libs.compose.detekt)
testImplementation(libs.junit)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package org.fossify.phone.helpers

/**
* Brazilian carriers deliver caller ID in inconsistent formats for the very same line, e.g.
* "+55XX912345678", "0XXXX912345678" (with a long-distance operator/carrier code such as 015 for
* Vivo), "XX912345678" or just "912345678". Country code and operator code are never part of the
* number itself, so contact matching must ignore them and compare only DDD (area code) + subscriber
* number.
*
* Mobile subscriber numbers always have 9 digits and start with "9" (the "ninth digit" rule added
* in 2016), while landlines have 8 digits, so that leading digit is what disambiguates where the
* DDD ends when parsing a run of digits that has no explicit separators.
*/
object BrazilianPhoneNumberMatcher {
private const val MOBILE_NUMBER_LENGTH = 9
private const val LANDLINE_NUMBER_LENGTH = 8
private const val DDD_LENGTH = 2
private const val MOBILE_PREFIX = '9'

private data class ParsedNumber(val ddd: String?, val number: String)

private fun parse(rawNumber: String): ParsedNumber? {
val digits = rawNumber.filter { it.isDigit() }
if (digits.length < LANDLINE_NUMBER_LENGTH) {
return null
}

val isMobile = digits.length >= MOBILE_NUMBER_LENGTH &&
digits[digits.length - MOBILE_NUMBER_LENGTH] == MOBILE_PREFIX

val numberLength = if (isMobile) MOBILE_NUMBER_LENGTH else LANDLINE_NUMBER_LENGTH
val number = digits.takeLast(numberLength)
val remainder = digits.dropLast(numberLength)
val ddd = if (remainder.length >= DDD_LENGTH) remainder.takeLast(DDD_LENGTH) else null

return ParsedNumber(ddd, number)
}

/**
* Compares the most specific parts both numbers have in common. The operator/carrier code is
* never considered, since it's not a stable part of the number.
*
* Old contacts saved before the mandatory 9th digit was rolled out may still have an 8-digit
* mobile number, while an incoming call is reported in the current 9-digit format (or vice
* versa). When the parsed lengths differ this way, only the last 8 digits are compared.
*/
fun matches(a: String, b: String): Boolean {
val parsedA = parse(a) ?: return false
val parsedB = parse(b) ?: return false

val numbersMatch = if (parsedA.number.length == parsedB.number.length) {
parsedA.number == parsedB.number
} else {
parsedA.number.takeLast(LANDLINE_NUMBER_LENGTH) == parsedB.number.takeLast(LANDLINE_NUMBER_LENGTH)
}

if (!numbersMatch) {
return false
}

return if (parsedA.ddd != null && parsedB.ddd != null) {
parsedA.ddd == parsedB.ddd
} else {
true
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package org.fossify.phone.services

import android.provider.ContactsContract
import android.telecom.Call
import android.telecom.CallScreeningService
import org.fossify.commons.extensions.baseConfig
import org.fossify.commons.extensions.getMyContactsCursor
import org.fossify.commons.extensions.hasPermission
import org.fossify.commons.extensions.isNumberBlocked
import org.fossify.commons.helpers.ContactLookupResult
import org.fossify.commons.helpers.MyContactsContentProvider
import org.fossify.commons.helpers.PERMISSION_READ_CONTACTS
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.phone.helpers.BrazilianPhoneNumberMatcher

class SimpleCallScreeningService : CallScreeningService() {

Expand All @@ -20,7 +25,16 @@ class SimpleCallScreeningService : CallScreeningService() {
number != null && baseConfig.blockUnknownNumbers -> {
val privateCursor = getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true)
val result = SimpleContactsHelper(this).existsSync(number, privateCursor)
respondToCall(callDetails, isBlocked = result == ContactLookupResult.NotFound)
// Brazilian carriers deliver the same number with varying country/operator code
// prefixes (e.g. "+55XX912345678", "0XXXX912345678", "XX912345678"), which the
// exact/system lookups above may fail to match. Fall back to a DDD+number aware
// comparison before treating the caller as unknown.
val isBlocked = when (result) {
ContactLookupResult.Found -> false
ContactLookupResult.Undetermined -> false
ContactLookupResult.NotFound -> !isKnownContactNumber(number)
}
respondToCall(callDetails, isBlocked = isBlocked)
}

number == null && baseConfig.blockHiddenNumbers -> {
Expand All @@ -33,6 +47,42 @@ class SimpleCallScreeningService : CallScreeningService() {
}
}

private fun isKnownContactNumber(number: String): Boolean {
val contactNumbers = getPrivateContactNumbers() + getSystemContactNumbers()
return contactNumbers.any { BrazilianPhoneNumberMatcher.matches(number, it) }
}

private fun getPrivateContactNumbers(): List<String> {
val cursor = getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true)
return MyContactsContentProvider.getSimpleContacts(this, cursor)
.flatMap { it.phoneNumbers }
.map { it.value }
}

private fun getSystemContactNumbers(): List<String> {
if (!hasPermission(PERMISSION_READ_CONTACTS)) {
return emptyList()
}

val numbers = ArrayList<String>()
try {
contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
null,
null,
null
)?.use { cursor ->
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (cursor.moveToNext()) {
cursor.getString(numberIndex)?.let { numbers.add(it) }
}
}
} catch (ignored: Exception) {
}
return numbers
}

private fun respondToCall(callDetails: Call.Details, isBlocked: Boolean) {
val response = CallResponse.Builder()
.setDisallowCall(isBlocked)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package org.fossify.phone.helpers

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class BrazilianPhoneNumberMatcherTest {

@Test
fun `matches when comparing full E164 number to bare DDD plus number`() {
assertTrue(BrazilianPhoneNumberMatcher.matches("+5511912345678", "11912345678"))
}

@Test
fun `matches when carrier long-distance operator code is inserted before the DDD`() {
// e.g. 015 selects the Vivo carrier for a long-distance call
assertTrue(BrazilianPhoneNumberMatcher.matches("+5511912345678", "01511912345678"))
}

@Test
fun `matches when caller id omits both country code and operator code`() {
assertTrue(BrazilianPhoneNumberMatcher.matches("+5511912345678", "11912345678"))
}

@Test
fun `matches when only the subscriber number is available, without DDD`() {
assertTrue(BrazilianPhoneNumberMatcher.matches("+5511912345678", "912345678"))
}

@Test
fun `matches when a national trunk prefix zero is present`() {
assertTrue(BrazilianPhoneNumberMatcher.matches("011912345678", "+5511912345678"))
}

@Test
fun `matches ignoring punctuation and whitespace formatting`() {
assertTrue(BrazilianPhoneNumberMatcher.matches("(11) 91234-5678", "+55 11 91234 5678"))
}

@Test
fun `does not match when the DDD differs`() {
assertFalse(BrazilianPhoneNumberMatcher.matches("11912345678", "21912345678"))
}

@Test
fun `does not match when the subscriber number differs`() {
assertFalse(BrazilianPhoneNumberMatcher.matches("11912345678", "11987654321"))
}

@Test
fun `matches old 8-digit mobile format against new 9-digit format with same DDD`() {
// contact saved before the mandatory 9th digit rollout
val oldFormatContact = "1198765432"
val newFormatCallerId = "+5511998765432"
assertTrue(BrazilianPhoneNumberMatcher.matches(oldFormatContact, newFormatCallerId))
}

@Test
fun `matches new 9-digit format against old 8-digit format regardless of argument order`() {
val oldFormatContact = "1198765432"
val newFormatCallerId = "+5511998765432"
assertTrue(BrazilianPhoneNumberMatcher.matches(newFormatCallerId, oldFormatContact))
}

@Test
fun `does not match old and new format numbers when the last 8 digits differ`() {
val oldFormatContact = "1198765432"
val differentNewFormatNumber = "+5511998765431"
assertFalse(BrazilianPhoneNumberMatcher.matches(oldFormatContact, differentNewFormatNumber))
}

@Test
fun `does not match old and new format numbers when the DDD differs`() {
val oldFormatContact = "1198765432"
val newFormatDifferentDdd = "+5521998765432"
assertFalse(BrazilianPhoneNumberMatcher.matches(oldFormatContact, newFormatDifferentDdd))
}

@Test
fun `matches landline numbers by DDD plus 8-digit subscriber number`() {
assertTrue(BrazilianPhoneNumberMatcher.matches("+551123456789", "1123456789"))
}

@Test
fun `matches when one side has no DDD and only the subscriber number can be compared`() {
// only the last 8-9 digits are known for one of the two numbers
assertTrue(BrazilianPhoneNumberMatcher.matches("98765432", "1198765432"))
}

@Test
fun `does not match unrelated numbers`() {
assertFalse(BrazilianPhoneNumberMatcher.matches("11912345678", "21987654321"))
}

@Test
fun `does not match when input is too short to be a valid number`() {
assertFalse(BrazilianPhoneNumberMatcher.matches("1234567", "11912345678"))
}

@Test
fun `does not match blank or empty input`() {
assertFalse(BrazilianPhoneNumberMatcher.matches("", "11912345678"))
assertFalse(BrazilianPhoneNumberMatcher.matches("11912345678", ""))
}
}
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ eventbus = "3.3.1"
commons = "6.1.6"
#Gradle
gradlePlugins-agp = "9.4.0"
#Testing
junit = "4.13.2"
#Other
indicatorFastScroll = "c7873f7168"
autofitTextView = "0.2.1"
Expand All @@ -32,6 +34,8 @@ eventbus = { module = "org.greenrobot:eventbus", version.ref = "eventbus" }
#Kotlin
geocoder = { module = "com.googlecode.libphonenumber:geocoder", version.ref = "geocoderVersion" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
#Testing
junit = { module = "junit:junit", version.ref = "junit" }
#Other
indicator-fast-scroll = { module = "org.fossify:IndicatorFastScroll", version.ref = "indicatorFastScroll" }
autofit-text-view = { module = "me.grantland:autofittextview", version.ref = "autofitTextView" }
Expand Down
Loading