Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import android.telephony.TelephonyManager
import android.telephony.TelephonyManager.ACTION_PHONE_STATE_CHANGED
import co.touchlab.kermit.Logger
import io.rebble.libpebblecommon.calls.Call
import io.rebble.libpebblecommon.calls.CallDoNotDisturbFilter
import io.rebble.libpebblecommon.calls.InCallServiceCallCoordinator
import io.rebble.libpebblecommon.calls.LegacyPhoneReceiver
import io.rebble.libpebblecommon.calls.LibPebbleInCallService.Companion.resolveNameFromContacts
import io.rebble.libpebblecommon.calls.NotificationCallDetector
Expand Down Expand Up @@ -40,6 +42,8 @@ class AndroidPhoneReceiver(
private val libPebbleCoroutineScope: LibPebbleCoroutineScope,
private val context: Context,
private val callDetector: NotificationCallDetector,
private val callDoNotDisturbFilter: CallDoNotDisturbFilter,
private val inCallServiceCallCoordinator: InCallServiceCallCoordinator,
private val privateLogger: PrivateLogger,
) : LegacyPhoneReceiver {
private val logger = Logger.withTag("AndroidPhoneReceiver")
Expand Down Expand Up @@ -93,7 +97,7 @@ class AndroidPhoneReceiver(
nullCallJob = libPebbleCoroutineScope.launch {
delay(0.5.seconds)
logger.v { "No second RINGING with number received, handling with null number" }
if (currentCall.value != null) {
if (currentCall.value != null || inCallServiceCallCoordinator.isHandlingCall()) {
logger.v { "InCallService already handling this call, skipping" }
return@launch
}
Expand Down Expand Up @@ -140,15 +144,15 @@ class AndroidPhoneReceiver(
}
}

private fun handleRingingWithDelay(currentCall: MutableStateFlow<Call?>, number: String?) {
private suspend fun handleRingingWithDelay(currentCall: MutableStateFlow<Call?>, number: String?) {
if (inCallServiceAvailable()) {
// InCallService is available — give it a moment to claim the call.
// If it doesn't (VoIP call), we handle it.
cancelRingingDelayJob()
ringingDelayJob = libPebbleCoroutineScope.launch {
logger.v { "scheduling ringingDelayJob" }
delay(500.milliseconds)
if (currentCall.value != null) {
if (currentCall.value != null || inCallServiceCallCoordinator.isHandlingCall()) {
logger.v { "InCallService already handling this call, skipping" }
return@launch
}
Expand All @@ -160,16 +164,21 @@ class AndroidPhoneReceiver(
}
}

private fun handleRinging(currentCall: MutableStateFlow<Call?>, number: String?) {
private suspend fun handleRinging(currentCall: MutableStateFlow<Call?>, number: String?) {
logger.v { "handle ringing" }
val cookie = Random.nextUInt()
receiverCookie = cookie

// Prefer contact info from the CATEGORY_CALL notification
val contactName = callDetector.contactName
?: context.contentResolver.resolveNameFromContacts(number)
val contactNumber = callDetector.contactNumber ?: number ?: "Unknown number"

if (callDoNotDisturbFilter.shouldSuppressIncomingCall(contactName, contactNumber)) {
return
}

receiverCookie = cookie

val answerAction = callDetector.answerAction
val declineAction = callDetector.declineAction

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package io.rebble.libpebblecommon.calls

import android.app.Notification
import android.app.NotificationManager
import android.service.notification.StatusBarNotification
import co.touchlab.kermit.Logger
import io.rebble.libpebblecommon.NotificationConfigFlow
import io.rebble.libpebblecommon.util.PrivateLogger
import io.rebble.libpebblecommon.util.obfuscate
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource

class CallDoNotDisturbFilter(
private val notificationConfig: NotificationConfigFlow,
private val privateLogger: PrivateLogger,
private val currentInterruptionFilter: () -> Int,
) {
companion object {
private val logger = Logger.withTag("CallDoNotDisturbFilter")
private val RANKING_WAIT = 500.milliseconds
private val RANKING_TTL = 5_000.milliseconds
private const val PHONE_MATCH_MIN_DIGITS = 7
private const val CALLER_NAME_MATCH_MIN_CHARS = 3
}

private data class CallRanking(
val key: String,
val packageName: String,
val title: String?,
val text: String?,
val matchesInterruptionFilter: Boolean?,
val recordedAt: TimeSource.Monotonic.ValueTimeMark,
)

private var latestCallRanking: CallRanking? = null

fun recordCallNotification(
sbn: StatusBarNotification,
matchesInterruptionFilter: Boolean?,
) {
val notification = sbn.notification
recordCallRanking(
key = sbn.key,
packageName = sbn.packageName,
title = notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString(),
text = notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString(),
matchesInterruptionFilter = matchesInterruptionFilter,
)
}

internal fun recordCallRanking(
key: String,
packageName: String,
title: String? = null,
text: String? = null,
matchesInterruptionFilter: Boolean?,
) {
latestCallRanking = CallRanking(
key = key,
packageName = packageName,
title = title,
text = text,
matchesInterruptionFilter = matchesInterruptionFilter,
recordedAt = TimeSource.Monotonic.markNow(),
)
}

fun updateCallRanking(
key: String,
matchesInterruptionFilter: Boolean,
) {
val ranking = latestCallRanking?.takeIfFresh() ?: return
if (ranking.key != key) return
latestCallRanking = ranking.copy(
matchesInterruptionFilter = matchesInterruptionFilter,
recordedAt = TimeSource.Monotonic.markNow(),
)
}

fun clearCallNotification(sbn: StatusBarNotification) {
if (latestCallRanking?.key == sbn.key) {
latestCallRanking = null
}
}

fun clearRecordedCallRanking() {
latestCallRanking = null
}

suspend fun shouldSuppressIncomingCall(
contactName: String?,
contactNumber: String,
): Boolean {
if (!notificationConfig.value.respectDoNotDisturb) return false
if (!isDoNotDisturbActive()) return false

waitForRecentRanking(contactName, contactNumber)

val ranking = latestCallRanking
?.takeIfFresh()
?.takeIf { it.matchesCall(contactName, contactNumber) }
val shouldSuppress = ranking?.matchesInterruptionFilter != true
if (shouldSuppress) {
logger.d {
"Suppressing call during DND: ${contactName.obfuscate(privateLogger)} / " +
contactNumber.obfuscate(privateLogger) +
" (ranking=${ranking?.packageName ?: "missing"})"
}
}
if (ranking != null) {
latestCallRanking = null
}
return shouldSuppress
}

private suspend fun waitForRecentRanking(contactName: String?, contactNumber: String) {
waitUntil(RANKING_WAIT) {
latestCallRanking
?.takeIfFresh()
?.takeIf { it.matchesCall(contactName, contactNumber) }
?.matchesInterruptionFilter != null || !isDoNotDisturbActive()
}
}

private suspend fun waitUntil(timeout: Duration, condition: () -> Boolean) {
withTimeoutOrNull(timeout) {
while (!condition()) {
delay(50.milliseconds)
}
}
}

private fun CallRanking.takeIfFresh(): CallRanking? =
takeIf { it.recordedAt.elapsedNow() <= RANKING_TTL }

private fun CallRanking.matchesCall(contactName: String?, contactNumber: String): Boolean {
val callNumber = contactNumber.normalizedPhoneDigits()
val notificationNumbers = listOfNotNull(title, text).map { it.normalizedPhoneDigits() }
if (callNumber.matchesAnyPhoneNumber(notificationNumbers)) return true

val callNames = listOfNotNull(contactName?.normalizedCallerName(), contactNumber.normalizedCallerName())
.filter { it.isMeaningfulCallerName() }
val notificationNames = listOfNotNull(title?.normalizedCallerName(), text?.normalizedCallerName())
.filter { it.isMeaningfulCallerName() }
return notificationNames.any { notificationName ->
callNames.any { callName ->
notificationName == callName ||
notificationName.contains(callName) ||
callName.contains(notificationName)
}
}
}

private fun String.normalizedPhoneDigits(): String =
filter { it.isDigit() }

private fun String.matchesAnyPhoneNumber(others: List<String>): Boolean {
if (length < PHONE_MATCH_MIN_DIGITS) return false
return others.any { other ->
other.length >= PHONE_MATCH_MIN_DIGITS &&
(endsWith(other) || other.endsWith(this))
}
}

private fun String.normalizedCallerName(): String =
lowercase()
.replace(Regex("[^\\p{L}\\p{N}]+"), " ")
.trim()
.replace(Regex("\\s+"), " ")

private fun String.isMeaningfulCallerName(): Boolean =
length >= CALLER_NAME_MATCH_MIN_CHARS &&
this != "unknown" &&
this != "unknown number" &&
this != "private number"

private fun isDoNotDisturbActive(): Boolean =
currentInterruptionFilter() != NotificationManager.INTERRUPTION_FILTER_ALL
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.rebble.libpebblecommon.calls

import android.telecom.Call

class InCallServiceCallCoordinator {
private val activeCalls = mutableSetOf<Int>()

@Synchronized
fun markHandling(call: Call) {
activeCalls += System.identityHashCode(call)
}

@Synchronized
fun clear(call: Call) {
activeCalls -= System.identityHashCode(call)
}

@Synchronized
fun clearAll() {
activeCalls.clear()
}

@Synchronized
fun isHandlingCall(): Boolean = activeCalls.isNotEmpty()
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import android.telecom.VideoProfile
import co.touchlab.kermit.Logger
import io.rebble.libpebblecommon.connection.LibPebble
import io.rebble.libpebblecommon.di.LibPebbleKoinComponent
import io.rebble.libpebblecommon.di.LibPebbleCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.core.component.inject
import kotlin.random.Random
import kotlin.random.nextUInt
Expand Down Expand Up @@ -46,6 +49,11 @@ class LibPebbleInCallService : InCallService(), LibPebbleKoinComponent {
}

private val libPebble: LibPebble by inject()
private val callDoNotDisturbFilter: CallDoNotDisturbFilter by inject()
private val libPebbleCoroutineScope: LibPebbleCoroutineScope by inject()
private val inCallServiceCallCoordinator: InCallServiceCallCoordinator by inject()
private val suppressedCalls = mutableSetOf<Call>()
private val pendingDndChecks = mutableSetOf<Call>()

override fun onCreate() {
super.onCreate()
Expand All @@ -56,6 +64,7 @@ class LibPebbleInCallService : InCallService(), LibPebbleKoinComponent {
override fun onDestroy() {
logger.d { "onDestroy()" }
libPebble.currentCall.value = null
inCallServiceCallCoordinator.clearAll()
super.onDestroy()
}

Expand All @@ -70,7 +79,7 @@ class LibPebbleInCallService : InCallService(), LibPebbleKoinComponent {
override fun onStateChanged(call: Call?, state: Int) {
call ?: return
logger.d { "Call state changed: ${call.state} (arg state: $state)" }
libPebble.currentCall.value = createLibPebbleCall(call, cookie)
publishCall(call, cookie)
}
}

Expand All @@ -86,12 +95,17 @@ class LibPebbleInCallService : InCallService(), LibPebbleKoinComponent {
}
logger.d { "New call in state: ${call.state}" }
call.registerCallback(callback)
libPebble.currentCall.value = createLibPebbleCall(call, cookie)
publishCall(call, cookie)
}

override fun onCallRemoved(call: Call?) {
call ?: return
libPebble.currentCall.value = null
libPebbleCoroutineScope.launch(Dispatchers.Main.immediate) {
suppressedCalls.remove(call)
pendingDndChecks.remove(call)
inCallServiceCallCoordinator.clear(call)
libPebble.currentCall.value = null
}
}

private fun Call.resolveContactName(): String? {
Expand All @@ -106,6 +120,46 @@ class LibPebbleInCallService : InCallService(), LibPebbleKoinComponent {
return this.details.handle?.schemeSpecificPart ?: "Unknown"
}

private fun publishCall(call: Call, cookie: UInt) {
libPebbleCoroutineScope.launch(Dispatchers.Main.immediate) {
if (call in suppressedCalls || call in pendingDndChecks) return@launch
if (call.state == Call.STATE_RINGING) {
inCallServiceCallCoordinator.markHandling(call)
pendingDndChecks += call
val pebbleCall = createLibPebbleCall(call, cookie)
as? io.rebble.libpebblecommon.calls.Call.RingingCall
?: run {
pendingDndChecks.remove(call)
inCallServiceCallCoordinator.clear(call)
return@launch
}
val shouldSuppress = callDoNotDisturbFilter.shouldSuppressIncomingCall(
contactName = pebbleCall.contactName,
contactNumber = pebbleCall.contactNumber,
)
if (call !in pendingDndChecks) return@launch
if (shouldSuppress) {
suppressedCalls += call
pendingDndChecks.remove(call)
return@launch
}
pendingDndChecks.remove(call)
val currentPebbleCall = createLibPebbleCall(call, cookie) ?: run {
inCallServiceCallCoordinator.clear(call)
return@launch
}
libPebble.currentCall.value = currentPebbleCall
return@launch
}
inCallServiceCallCoordinator.markHandling(call)
val pebbleCall = createLibPebbleCall(call, cookie) ?: run {
inCallServiceCallCoordinator.clear(call)
return@launch
}
libPebble.currentCall.value = pebbleCall
}
}

private fun createLibPebbleCall(call: Call, cookie: UInt): io.rebble.libpebblecommon.calls.Call? =
when (call.state) {
Call.STATE_RINGING -> io.rebble.libpebblecommon.calls.Call.RingingCall(
Expand Down
Loading
Loading