Skip to content

Commit 75ebc04

Browse files
authored
Merge pull request #54 from fgubler/feature/block-calls-from-unknown-numbers
Feature/block calls from unknown numbers
2 parents 75364a1 + 6c4a106 commit 75ebc04

File tree

25 files changed

+231
-56
lines changed

25 files changed

+231
-56
lines changed

app/build.gradle

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ android {
3333
applicationId "ch.abwesend.privatecontacts"
3434
minSdk 24
3535
targetSdk 36
36-
versionCode 89
37-
versionName "5.1.2"
36+
versionCode 90
37+
versionName "5.2.0"
3838

3939
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
4040

@@ -102,9 +102,9 @@ dependencies {
102102
implementation "org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}"
103103

104104
// Android Core
105-
implementation "androidx.core:core-ktx:1.16.0"
105+
implementation "androidx.core:core-ktx:1.17.0"
106106
implementation "androidx.appcompat:appcompat:1.7.1"
107-
implementation "com.google.android.material:material:1.12.0"
107+
implementation "com.google.android.material:material:1.13.0"
108108

109109
// Android Lifecycle
110110
implementation "androidx.lifecycle:lifecycle-runtime-ktx:${androidLifecycleVersion}"
@@ -122,12 +122,12 @@ dependencies {
122122
implementation "androidx.compose.runtime:runtime:${composeVersion}"
123123
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
124124
debugImplementation "androidx.compose.ui:ui-tooling:${composeVersion}"
125-
implementation "androidx.activity:activity-compose:1.10.1"
126-
implementation "androidx.navigation:navigation-compose:2.9.0"
125+
implementation "androidx.activity:activity-compose:1.11.0"
126+
implementation "androidx.navigation:navigation-compose:2.9.6"
127127
implementation "androidx.constraintlayout:constraintlayout-compose:1.1.1"
128128

129129
// Firebase
130-
googlePlayImplementation platform("com.google.firebase:firebase-bom:33.13.0")
130+
googlePlayImplementation platform("com.google.firebase:firebase-bom:34.6.0")
131131
googlePlayImplementation 'com.google.firebase:firebase-crashlytics' // does not need a version because of firebase-bom
132132
googlePlayImplementation("com.google.firebase:firebase-analytics")
133133

@@ -160,7 +160,7 @@ dependencies {
160160
// ===== Testing =====
161161
// Unit Tests
162162
testImplementation "junit:junit:4.13.2"
163-
testImplementation "org.assertj:assertj-core:3.21.0"
163+
testImplementation "org.assertj:assertj-core:3.27.6"
164164

165165
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:${coroutinesVersion}"
166166

@@ -182,9 +182,9 @@ dependencies {
182182
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}"
183183

184184
// Instrumented Tests
185-
androidTestImplementation "org.assertj:assertj-core:3.21.0"
186-
androidTestImplementation "androidx.test.ext:junit:1.2.1"
187-
androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1"
185+
androidTestImplementation "org.assertj:assertj-core:3.27.6"
186+
androidTestImplementation "androidx.test.ext:junit:1.3.0"
187+
androidTestImplementation "androidx.test.espresso:espresso-core:3.7.0"
188188
androidTestImplementation "androidx.compose.ui:ui-test-junit4:${composeVersion}"
189189

190190
androidTestImplementation "org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}"

app/src/main/java/ch/abwesend/privatecontacts/domain/repository/IAndroidContactLoadService.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package ch.abwesend.privatecontacts.domain.repository
88

99
import ch.abwesend.privatecontacts.domain.lib.flow.ResourceFlow
10+
import ch.abwesend.privatecontacts.domain.model.contact.ContactWithPhoneNumbers
1011
import ch.abwesend.privatecontacts.domain.model.contact.IContact
1112
import ch.abwesend.privatecontacts.domain.model.contact.IContactBase
1213
import ch.abwesend.privatecontacts.domain.model.contact.IContactIdExternal
@@ -19,4 +20,6 @@ interface IAndroidContactLoadService {
1920
suspend fun resolveContact(contactId: IContactIdExternal): IContact
2021
suspend fun resolveContacts(contactIds: Set<IContactIdExternal>): List<IContact>
2122
suspend fun getAllContactGroups(): List<ContactGroup>
23+
24+
suspend fun findContactsWithPhoneNumber(phoneNumber: String): List<ContactWithPhoneNumbers>
2225
}

app/src/main/java/ch/abwesend/privatecontacts/domain/service/IncomingCallService.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package ch.abwesend.privatecontacts.domain.service
88

99
import ch.abwesend.privatecontacts.domain.lib.logging.logger
1010
import ch.abwesend.privatecontacts.domain.model.contact.ContactWithPhoneNumbers
11+
import ch.abwesend.privatecontacts.domain.repository.IAndroidContactLoadService
1112
import ch.abwesend.privatecontacts.domain.repository.IContactRepository
1213
import ch.abwesend.privatecontacts.domain.service.interfaces.TelephoneService
1314
import ch.abwesend.privatecontacts.domain.util.injectAnywhere
@@ -18,14 +19,23 @@ private const val CONSIDER_MATCHING_CONTACTS = 5 // showing more does not make m
1819
class IncomingCallService {
1920
private val contactRepository: IContactRepository by injectAnywhere()
2021
private val telephoneService: TelephoneService by injectAnywhere()
22+
private val androidContactLoadService: IAndroidContactLoadService by injectAnywhere()
2123

2224
/**
2325
* Load contacts corresponding to the given [phoneNumber].
2426
* Could be several (multiple contacts living together have the same number...)
2527
*/
26-
suspend fun findCorrespondingContacts(phoneNumber: String): List<ContactWithPhoneNumbers> {
28+
suspend fun findCorrespondingContacts(
29+
phoneNumber: String,
30+
considerPublicContacts: Boolean = false,
31+
): List<ContactWithPhoneNumbers> {
2732
val ending = phoneNumber.takeLast(CONSIDER_LAST_DIGITS)
28-
val contactCandidates = contactRepository.findContactsWithNumberEndingOn(ending)
33+
val secretContactCandidates = contactRepository.findContactsWithNumberEndingOn(ending)
34+
35+
val contactCandidates = if (considerPublicContacts) {
36+
val publicContactCandidates = androidContactLoadService.findContactsWithPhoneNumber(phoneNumber)
37+
secretContactCandidates + publicContactCandidates
38+
} else secretContactCandidates
2939

3040
logger.debug("Found ${contactCandidates.size} candidate(s) for calling phone-number")
3141

app/src/main/java/ch/abwesend/privatecontacts/domain/settings/SettingsRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ interface SettingsRepository : ISettingsState {
4343
override var observeIncomingCalls: Boolean
4444
override var requestIncomingCallPermissions: Boolean // invisible
4545
override var showIncomingCallsOnLockScreen: Boolean
46+
override var blockIncomingCallsFromUnknownNumbers: Boolean
4647

4748
// Android Contacts
4849
override var showAndroidContacts: Boolean

app/src/main/java/ch/abwesend/privatecontacts/domain/settings/SettingsState.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ interface ISettingsState {
5656
*/
5757
val showIncomingCallsOnLockScreen: Boolean
5858

59+
/**
60+
* Whether to block incoming calls from unknown numbers.
61+
* Depends on [observeIncomingCalls]
62+
*/
63+
val blockIncomingCallsFromUnknownNumbers: Boolean
64+
5965
// Android Contacts
6066
val showAndroidContacts: Boolean
6167
val showThirdPartyContactAccounts: Boolean
@@ -97,6 +103,7 @@ data class SettingsState(
97103
override val requestIncomingCallPermissions: Boolean,
98104
override val observeIncomingCalls: Boolean,
99105
override val showIncomingCallsOnLockScreen: Boolean,
106+
override val blockIncomingCallsFromUnknownNumbers: Boolean,
100107

101108
override val showAndroidContacts: Boolean,
102109
override val showThirdPartyContactAccounts: Boolean,
@@ -126,6 +133,7 @@ data class SettingsState(
126133
secondTabMode = SecondTabMode.ALL_CONTACTS,
127134
requestIncomingCallPermissions = true,
128135
observeIncomingCalls = true,
136+
blockIncomingCallsFromUnknownNumbers = false,
129137
showAndroidContacts = true,
130138
showThirdPartyContactAccounts = false,
131139
authenticationRequired = false,

app/src/main/java/ch/abwesend/privatecontacts/infrastructure/calldetection/CallerIdentificationService.kt

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,71 @@
66

77
package ch.abwesend.privatecontacts.infrastructure.calldetection
88

9+
import android.content.Context
910
import android.os.Build
10-
import android.telecom.Call
11+
import android.telecom.Call.Details
1112
import android.telecom.CallScreeningService
13+
import ch.abwesend.privatecontacts.domain.lib.logging.debugLocally
1214
import ch.abwesend.privatecontacts.domain.lib.logging.logger
15+
import ch.abwesend.privatecontacts.domain.settings.Settings
16+
import ch.abwesend.privatecontacts.domain.util.applicationScope
1317
import ch.abwesend.privatecontacts.domain.util.injectAnywhere
18+
import kotlinx.coroutines.launch
1419

1520
class CallerIdentificationService : CallScreeningService() {
1621
private val incomingCallHelper: IncomingCallHelper by injectAnywhere()
22+
private val context: Context = this
1723

18-
override fun onScreenCall(callDetails: Call.Details) {
24+
override fun onScreenCall(callDetails: Details) {
1925
logger.debug("Receiving incoming call")
20-
val response = CallResponse.Builder().build() // we don't want to interfere with anything here...
21-
respondToCall(callDetails, response)
2226

2327
if (shouldHandleCall(callDetails)) {
24-
val number: String? = callDetails.handle?.schemeSpecificPart
25-
logger.debug("Incoming call from number '$number'")
26-
number?.let { incomingCallHelper.handleIncomingCall(baseContext, it) }
27-
} else logger.debug("Ignored not-incoming call")
28+
handleIncomingCall(callDetails)
29+
} else {
30+
logger.debug("Ignored not-incoming call")
31+
}
2832
}
2933

30-
private fun shouldHandleCall(callDetails: Call.Details): Boolean {
34+
private fun shouldHandleCall(callDetails: Details): Boolean {
3135
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
32-
callDetails.callDirection == Call.Details.DIRECTION_INCOMING
36+
callDetails.callDirection == Details.DIRECTION_INCOMING
3337
} else true
3438
}
39+
40+
private fun handleIncomingCall(callDetails: Details) = applicationScope.launch {
41+
val phoneNumber: String? = callDetails.handle?.schemeSpecificPart
42+
logger.debug("Incoming call from number '$phoneNumber'")
43+
44+
val blockCallsFromUnknownNumbers = Settings.nextOrDefault().blockIncomingCallsFromUnknownNumbers
45+
val shouldBlockCall = phoneNumber != null &&
46+
blockCallsFromUnknownNumbers &&
47+
!incomingCallHelper.matchesAnyKnownContact(phoneNumber)
48+
49+
if (shouldBlockCall) {
50+
blockCall(callDetails, phoneNumber)
51+
} else {
52+
val response = CallResponse.Builder().build() // we don't want to interfere with anything here...
53+
respondToCall(callDetails, response)
54+
phoneNumber?.let { incomingCallHelper.handleIncomingCall(baseContext, it) }
55+
}
56+
}
57+
58+
private fun blockCall(callDetails: Details, phoneNumber: String?) {
59+
logger.info("Blocking incoming call from unknown number")
60+
logger.debugLocally("Blocking call with number $phoneNumber")
61+
62+
val responseBuilder = CallResponse.Builder()
63+
.setDisallowCall(true)
64+
.setRejectCall(true)
65+
.setSkipNotification(true)
66+
67+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
68+
responseBuilder.setSilenceCall(true)
69+
}
70+
71+
respondToCall(callDetails, responseBuilder.build())
72+
phoneNumber?.let {
73+
incomingCallHelper.handleBlockedCall(context, phoneNumber)
74+
}
75+
}
3576
}

app/src/main/java/ch/abwesend/privatecontacts/infrastructure/calldetection/IncomingCallHelper.kt

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package ch.abwesend.privatecontacts.infrastructure.calldetection
88

99
import android.content.Context
1010
import ch.abwesend.privatecontacts.R
11+
import ch.abwesend.privatecontacts.domain.lib.logging.debugLocally
1112
import ch.abwesend.privatecontacts.domain.lib.logging.logger
1213
import ch.abwesend.privatecontacts.domain.service.IncomingCallService
1314
import ch.abwesend.privatecontacts.domain.service.interfaces.TelephoneService
@@ -22,25 +23,32 @@ class IncomingCallHelper {
2223
private val notificationRepository: CallNotificationRepository by injectAnywhere()
2324
private val toastRepository: ToastRepository by injectAnywhere()
2425

25-
fun handleIncomingCall(
26+
suspend fun matchesAnyKnownContact(phoneNumber: String): Boolean {
27+
val matchingContacts = incomingCallService.findCorrespondingContacts(
28+
phoneNumber = phoneNumber,
29+
considerPublicContacts = true,
30+
)
31+
logger.debug("Found ${matchingContacts.size} matching contacts")
32+
return matchingContacts.isNotEmpty()
33+
}
34+
35+
suspend fun handleIncomingCall(
2636
context: Context,
2737
phoneNumber: String,
2838
) {
29-
applicationScope.launch {
30-
val correspondingContacts = incomingCallService
31-
.findCorrespondingContacts(phoneNumber)
32-
.map { it.displayName }
33-
.distinct()
39+
val correspondingContacts = incomingCallService
40+
.findCorrespondingContacts(phoneNumber)
41+
.map { it.displayName }
42+
.distinct()
3443

35-
logger.debug("Found corresponding contacts: $correspondingContacts")
36-
val formattedNumber = telephoneService.formatPhoneNumberForDisplay(phoneNumber)
37-
val notificationText = createNotificationText(context, formattedNumber, correspondingContacts)
44+
logger.debug("Found corresponding contacts: $correspondingContacts")
45+
val formattedNumber = telephoneService.formatPhoneNumberForDisplay(phoneNumber)
46+
val notificationText = createNotificationText(context, formattedNumber, correspondingContacts)
3847

39-
notificationText?.let { text ->
40-
notificationRepository.showIncomingCallNotification(context, text)
41-
toastRepository.showToastNotification(context, text)
42-
} ?: logger.debug("No notification text: don't show notification")
43-
}
48+
notificationText?.let { text ->
49+
notificationRepository.showIncomingCallNotification(context, text)
50+
toastRepository.showToastNotification(context, text)
51+
} ?: logger.debug("No notification text: don't show notification")
4452
}
4553

4654
private fun createNotificationText(
@@ -63,4 +71,17 @@ class IncomingCallHelper {
6371
callerNumber,
6472
)
6573
}
74+
75+
fun handleBlockedCall(
76+
context: Context,
77+
phoneNumber: String,
78+
) {
79+
applicationScope.launch {
80+
val formattedNumber = telephoneService.formatPhoneNumberForDisplay(phoneNumber)
81+
val notificationText = context.getString(R.string.blocked_call_notification_text, formattedNumber)
82+
83+
logger.debugLocally("Showing notification for blocked call from $formattedNumber")
84+
notificationRepository.showIncomingCallNotification(context, notificationText)
85+
}
86+
}
6687
}

app/src/main/java/ch/abwesend/privatecontacts/infrastructure/calldetection/PhoneStateReceiver.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import android.telecom.CallScreeningService
1313
import android.telephony.TelephonyManager
1414
import ch.abwesend.privatecontacts.domain.lib.logging.logger
1515
import ch.abwesend.privatecontacts.domain.settings.SettingsRepository
16+
import ch.abwesend.privatecontacts.domain.util.applicationScope
1617
import ch.abwesend.privatecontacts.domain.util.canReadCallingNumberFromPhoneState
1718
import ch.abwesend.privatecontacts.domain.util.injectAnywhere
19+
import kotlinx.coroutines.launch
1820

1921
/**
2022
* Handle an incoming call via broadcast-receiver.
@@ -58,7 +60,11 @@ class PhoneStateReceiver : BroadcastReceiver() {
5860
if (canReadCallingNumberFromPhoneState) {
5961
val incomingNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER)
6062
logger.debug("Receiving a call from $incomingNumber")
61-
incomingNumber?.let { incomingCallHelper.handleIncomingCall(context, it) }
63+
incomingNumber?.let {
64+
applicationScope.launch {
65+
incomingCallHelper.handleIncomingCall(context, it)
66+
}
67+
}
6268
}
6369
}
6470

app/src/main/java/ch/abwesend/privatecontacts/infrastructure/repository/androidcontacts/mapping/AndroidContactDataMapper.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ class AndroidContactDataMapper {
4242
getCompanies()
4343
}
4444

45+
fun getContactPhoneNumbers(contact: Contact): List<PhoneNumber> =
46+
contact.getPhoneNumbers()
47+
4548
private fun Contact.getPhoneNumbers(): List<PhoneNumber> =
4649
phones.mapIndexed { index, phone ->
4750
val contactDataId = phone.toContactDataId()

app/src/main/java/ch/abwesend/privatecontacts/infrastructure/repository/androidcontacts/mapping/AndroidContactMapper.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import ch.abwesend.privatecontacts.domain.model.contact.ContactBase
66
import ch.abwesend.privatecontacts.domain.model.contact.ContactEditable
77
import ch.abwesend.privatecontacts.domain.model.contact.ContactIdAndroid
88
import ch.abwesend.privatecontacts.domain.model.contact.ContactType
9+
import ch.abwesend.privatecontacts.domain.model.contact.ContactWithPhoneNumbers
910
import ch.abwesend.privatecontacts.domain.model.contact.IContact
1011
import ch.abwesend.privatecontacts.domain.model.contact.IContactBase
1112
import ch.abwesend.privatecontacts.domain.model.contact.IContactEditable
1213
import ch.abwesend.privatecontacts.domain.model.contactdata.Company
1314
import ch.abwesend.privatecontacts.domain.model.contactdata.ContactDataCategory
1415
import ch.abwesend.privatecontacts.domain.model.contactdata.ContactDataType
16+
import ch.abwesend.privatecontacts.domain.model.contactdata.PhoneNumberValue
1517
import ch.abwesend.privatecontacts.domain.util.injectAnywhere
1618
import com.alexstyl.contactstore.Contact
1719
import com.alexstyl.contactstore.ContactGroup
@@ -32,6 +34,18 @@ class AndroidContactMapper {
3234
else null
3335
}
3436

37+
fun toContactWithPhoneNumbers(contact: Contact, rethrowExceptions: Boolean): ContactWithPhoneNumbers? =
38+
try {
39+
val contactBase = toContactBase(contact = contact, rethrowExceptions = rethrowExceptions)
40+
val phoneNumbers = contactDataFactory.getContactPhoneNumbers(contact)
41+
.map { PhoneNumberValue(it.value) }
42+
contactBase?.let { ContactWithPhoneNumbers(contactBase, phoneNumbers) }
43+
} catch(t: Throwable) {
44+
logger.warning("Failed to map android contact with id = ${contact.contactId}", t)
45+
if (rethrowExceptions) throw t
46+
else null
47+
}
48+
3549
fun toContact(
3650
contact: Contact,
3751
groups: List<ContactGroup>,

0 commit comments

Comments
 (0)