diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b2e8a0de..69abff481 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,6 +89,7 @@ android { sourceSets { getByName("main").java.directories.add("src/main/kotlin") + getByName("test").java.directories.add("src/test/kotlin") } compileOptions { @@ -146,4 +147,5 @@ dependencies { implementation(libs.libphonenumber) implementation(libs.geocoder) detektPlugins(libs.compose.detekt) + testImplementation(libs.junit) } diff --git a/app/src/main/kotlin/org/fossify/phone/helpers/BrazilianPhoneNumberMatcher.kt b/app/src/main/kotlin/org/fossify/phone/helpers/BrazilianPhoneNumberMatcher.kt new file mode 100644 index 000000000..723b7b208 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/phone/helpers/BrazilianPhoneNumberMatcher.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/org/fossify/phone/services/SimpleCallScreeningService.kt b/app/src/main/kotlin/org/fossify/phone/services/SimpleCallScreeningService.kt index 53a681fc7..533ae19d5 100644 --- a/app/src/main/kotlin/org/fossify/phone/services/SimpleCallScreeningService.kt +++ b/app/src/main/kotlin/org/fossify/phone/services/SimpleCallScreeningService.kt @@ -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() { @@ -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 -> { @@ -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 { + val cursor = getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true) + return MyContactsContentProvider.getSimpleContacts(this, cursor) + .flatMap { it.phoneNumbers } + .map { it.value } + } + + private fun getSystemContactNumbers(): List { + if (!hasPermission(PERMISSION_READ_CONTACTS)) { + return emptyList() + } + + val numbers = ArrayList() + 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) diff --git a/app/src/test/kotlin/org/fossify/phone/helpers/BrazilianPhoneNumberMatcherTest.kt b/app/src/test/kotlin/org/fossify/phone/helpers/BrazilianPhoneNumberMatcherTest.kt new file mode 100644 index 000000000..96f174332 --- /dev/null +++ b/app/src/test/kotlin/org/fossify/phone/helpers/BrazilianPhoneNumberMatcherTest.kt @@ -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", "")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ceaf4ef6..b74ac0577 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" }