From c75e0acd5c59f2259e4bd8daeec5c6d1866f127b Mon Sep 17 00:00:00 2001 From: Alon Date: Fri, 19 Jun 2026 00:09:14 +0300 Subject: [PATCH] Respect phone DND for incoming calls --- .../calls/AndroidPhoneReceiver.kt | 19 +- .../calls/CallDoNotDisturbFilter.kt | 182 ++++++++++++++++++ .../calls/InCallServiceCallCoordinator.kt | 25 +++ .../calls/LibPebbleInCallService.kt | 60 +++++- .../di/LibPebbleModule.android.kt | 9 + .../LibPebbleNotificationListener.kt | 33 +++- .../calls/CallDoNotDisturbFilterTest.kt | 160 +++++++++++++++ .../pebble/ui/WatchSettingsScreen.kt | 2 +- 8 files changed, 478 insertions(+), 12 deletions(-) create mode 100644 libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/CallDoNotDisturbFilter.kt create mode 100644 libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/InCallServiceCallCoordinator.kt create mode 100644 libpebble3/src/androidUnitTest/kotlin/io/rebble/libpebblecommon/calls/CallDoNotDisturbFilterTest.kt diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/AndroidPhoneReceiver.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/AndroidPhoneReceiver.kt index ac9b4cb1..acfbf4d5 100644 --- a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/AndroidPhoneReceiver.kt +++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/AndroidPhoneReceiver.kt @@ -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 @@ -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") @@ -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 } @@ -140,7 +144,7 @@ class AndroidPhoneReceiver( } } - private fun handleRingingWithDelay(currentCall: MutableStateFlow, number: String?) { + private suspend fun handleRingingWithDelay(currentCall: MutableStateFlow, number: String?) { if (inCallServiceAvailable()) { // InCallService is available — give it a moment to claim the call. // If it doesn't (VoIP call), we handle it. @@ -148,7 +152,7 @@ class AndroidPhoneReceiver( 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 } @@ -160,16 +164,21 @@ class AndroidPhoneReceiver( } } - private fun handleRinging(currentCall: MutableStateFlow, number: String?) { + private suspend fun handleRinging(currentCall: MutableStateFlow, 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 diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/CallDoNotDisturbFilter.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/CallDoNotDisturbFilter.kt new file mode 100644 index 00000000..7969ecc2 --- /dev/null +++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/CallDoNotDisturbFilter.kt @@ -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): 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 +} diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/InCallServiceCallCoordinator.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/InCallServiceCallCoordinator.kt new file mode 100644 index 00000000..61b85098 --- /dev/null +++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/InCallServiceCallCoordinator.kt @@ -0,0 +1,25 @@ +package io.rebble.libpebblecommon.calls + +import android.telecom.Call + +class InCallServiceCallCoordinator { + private val activeCalls = mutableSetOf() + + @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() +} diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/LibPebbleInCallService.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/LibPebbleInCallService.kt index 6799aa97..28ea7d6d 100644 --- a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/LibPebbleInCallService.kt +++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/calls/LibPebbleInCallService.kt @@ -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 @@ -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() + private val pendingDndChecks = mutableSetOf() override fun onCreate() { super.onCreate() @@ -56,6 +64,7 @@ class LibPebbleInCallService : InCallService(), LibPebbleKoinComponent { override fun onDestroy() { logger.d { "onDestroy()" } libPebble.currentCall.value = null + inCallServiceCallCoordinator.clearAll() super.onDestroy() } @@ -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) } } @@ -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? { @@ -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( diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.android.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.android.kt index 96c18a1b..4c6e4042 100644 --- a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.android.kt +++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.android.kt @@ -1,10 +1,13 @@ package io.rebble.libpebblecommon.di import android.app.Application +import android.app.NotificationManager import io.rebble.libpebblecommon.calendar.AndroidCalendarActionHandler import io.rebble.libpebblecommon.calendar.AndroidSystemCalendar import io.rebble.libpebblecommon.calendar.PlatformCalendarActionHandler import io.rebble.libpebblecommon.calendar.SystemCalendar +import io.rebble.libpebblecommon.calls.CallDoNotDisturbFilter +import io.rebble.libpebblecommon.calls.InCallServiceCallCoordinator import io.rebble.libpebblecommon.calls.LegacyPhoneReceiver import io.rebble.libpebblecommon.calls.NotificationCallDetector import io.rebble.libpebblecommon.calls.SystemCallLog @@ -73,8 +76,14 @@ actual val platformModule: Module = module { singleOf(::AndroidSystemContacts) bind SystemContacts::class singleOf(::AndroidPhoneReceiver) bind LegacyPhoneReceiver::class singleOf(::NotificationCallDetector) + singleOf(::InCallServiceCallCoordinator) + single { + val notificationManager = get() + CallDoNotDisturbFilter(get(), get()) { notificationManager.currentInterruptionFilter } + } single { get().context } single { get().context as Application } + single { get().getSystemService(NotificationManager::class.java) } single { NotificationHandler(setOf(get()), get(), get(), get(), get(), get(), get(), get(), get()) } singleOf(::BasicNotificationProcessor) single { get().contentResolver } diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/LibPebbleNotificationListener.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/LibPebbleNotificationListener.kt index a327c46d..ecd0fd6a 100644 --- a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/LibPebbleNotificationListener.kt +++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/LibPebbleNotificationListener.kt @@ -19,6 +19,7 @@ import io.rebble.libpebblecommon.connection.Watches import io.rebble.libpebblecommon.database.entity.ChannelGroup import io.rebble.libpebblecommon.database.entity.ChannelItem import io.rebble.libpebblecommon.database.entity.MuteState +import io.rebble.libpebblecommon.calls.CallDoNotDisturbFilter import io.rebble.libpebblecommon.calls.NotificationCallDetector import io.rebble.libpebblecommon.di.LibPebbleKoinComponent import io.rebble.libpebblecommon.io.rebble.libpebblecommon.notification.AndroidPebbleNotificationListenerConnection @@ -51,6 +52,7 @@ class LibPebbleNotificationListener : NotificationListenerService(), LibPebbleKo private val notificationHandler: NotificationHandler by inject() private val notificationCallDetector: NotificationCallDetector by inject() + private val callDoNotDisturbFilter: CallDoNotDisturbFilter by inject() private val connection: AndroidPebbleNotificationListenerConnection by inject() private val configHolder: NotificationConfigFlow by inject() @@ -167,6 +169,7 @@ class LibPebbleNotificationListener : NotificationListenerService(), LibPebbleKo // *every* notification. So - the handler must be resilient to this. override fun onNotificationPosted(sbn: StatusBarNotification) { if (sbn.notification.category == Notification.CATEGORY_CALL) { + callDoNotDisturbFilter.recordCallNotification(sbn, notificationMatchesInterruptionFilter(sbn)) notificationCallDetector.handleCallNotificationPosted(sbn) return } @@ -190,12 +193,29 @@ class LibPebbleNotificationListener : NotificationListenerService(), LibPebbleKo reason: Int ) { if (sbn.notification.category == Notification.CATEGORY_CALL) { + callDoNotDisturbFilter.clearCallNotification(sbn) notificationCallDetector.handleCallNotificationRemoved(sbn) return } notificationHandler.handleNotificationRemoved(sbn) } + override fun onNotificationRankingUpdate(rankingMap: RankingMap) { + val ranking = Ranking() + try { + getActiveNotifications() + ?.asSequence() + ?.filter { it.notification.category == Notification.CATEGORY_CALL } + ?.forEach { sbn -> + if (rankingMap.getRanking(sbn.key, ranking)) { + callDoNotDisturbFilter.updateCallRanking(sbn.key, ranking.matchesInterruptionFilter()) + } + } + } catch (e: SecurityException) { + logger.e("error getting active call notifications", e) + } + } + private fun controlListenerHints() = notificationListenerScope.launch { val anyWatchConnected = watches.watches .map { watchList -> watchList.any { it is ConnectedPebbleDevice } } @@ -225,10 +245,17 @@ class LibPebbleNotificationListener : NotificationListenerService(), LibPebbleKo } fun isNotificationFilteredByDoNotDisturb(statusBarNotification: StatusBarNotification): Boolean { - val rankingMap = getCurrentRanking() ?: return false + return notificationMatchesInterruptionFilter(statusBarNotification) == false + } + + private fun notificationMatchesInterruptionFilter(statusBarNotification: StatusBarNotification): Boolean? { + val rankingMap = getCurrentRanking() ?: return null val ranking = Ranking() - return rankingMap.getRanking(statusBarNotification.getKey(), ranking) && - !ranking.matchesInterruptionFilter() + return if (rankingMap.getRanking(statusBarNotification.getKey(), ranking)) { + ranking.matchesInterruptionFilter() + } else { + null + } } } diff --git a/libpebble3/src/androidUnitTest/kotlin/io/rebble/libpebblecommon/calls/CallDoNotDisturbFilterTest.kt b/libpebble3/src/androidUnitTest/kotlin/io/rebble/libpebblecommon/calls/CallDoNotDisturbFilterTest.kt new file mode 100644 index 00000000..45aec9e0 --- /dev/null +++ b/libpebble3/src/androidUnitTest/kotlin/io/rebble/libpebblecommon/calls/CallDoNotDisturbFilterTest.kt @@ -0,0 +1,160 @@ +package io.rebble.libpebblecommon.calls + +import android.app.NotificationManager +import io.rebble.libpebblecommon.NotificationConfig +import io.rebble.libpebblecommon.asFlow +import io.rebble.libpebblecommon.util.PrivateLogger +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CallDoNotDisturbFilterTest { + private var interruptionFilter = NotificationManager.INTERRUPTION_FILTER_ALL + + private fun filter( + config: NotificationConfig = NotificationConfig(respectDoNotDisturb = true), + ) = CallDoNotDisturbFilter( + notificationConfig = config.asFlow(), + privateLogger = PrivateLogger(config.asFlow()), + currentInterruptionFilter = { interruptionFilter }, + ) + + @Test + fun `setting off never suppresses calls`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY + val filter = filter(NotificationConfig(respectDoNotDisturb = false)) + + assertFalse(filter.shouldSuppressIncomingCall("Alice", "+15551234567")) + } + + @Test + fun `dnd off never suppresses calls`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_ALL + val filter = filter() + + assertFalse(filter.shouldSuppressIncomingCall("Alice", "+15551234567")) + } + + @Test + fun `dnd on suppresses when ranking says blocked`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY + val filter = filter() + filter.recordCallRanking( + key = "key", + packageName = "com.android.dialer", + title = "Alice", + text = "+15551234567", + matchesInterruptionFilter = false, + ) + + assertTrue(filter.shouldSuppressIncomingCall("Alice", "+15551234567")) + } + + @Test + fun `dnd on allows when ranking says allowed`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY + val filter = filter() + filter.recordCallRanking( + key = "key", + packageName = "com.android.dialer", + title = "Alice", + text = "+15551234567", + matchesInterruptionFilter = true, + ) + + assertFalse(filter.shouldSuppressIncomingCall("Alice", "+15551234567")) + } + + @Test + fun `dnd on allows when ranking arrives during wait`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY + val filter = filter() + filter.recordCallRanking( + key = "key", + packageName = "com.android.dialer", + title = "Alice", + text = "+15551234567", + matchesInterruptionFilter = null, + ) + + val decision = async { + filter.shouldSuppressIncomingCall("Alice", "+15551234567") + } + advanceTimeBy(100) + filter.updateCallRanking("key", matchesInterruptionFilter = true) + + assertFalse(decision.await()) + } + + @Test + fun `dnd on suppresses when ranking is missing`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY + val filter = filter() + + assertTrue(filter.shouldSuppressIncomingCall("Alice", "+15551234567")) + } + + @Test + fun `ranking is consumed after call decision`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY + val filter = filter() + filter.recordCallRanking( + key = "key", + packageName = "com.android.dialer", + title = "Alice", + text = "+15551234567", + matchesInterruptionFilter = true, + ) + + assertFalse(filter.shouldSuppressIncomingCall("Alice", "+15551234567")) + assertTrue(filter.shouldSuppressIncomingCall("Bob", "+15557654321")) + } + + @Test + fun `dnd on ignores fresh ranking from a different call`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY + val filter = filter() + filter.recordCallRanking( + key = "previous-key", + packageName = "com.android.dialer", + title = "Alice", + text = "+15551234567", + matchesInterruptionFilter = true, + ) + + assertTrue(filter.shouldSuppressIncomingCall("Bob", "+15557654321")) + } + + @Test + fun `dnd on allows when ranking phone number is formatted differently`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY + val filter = filter() + filter.recordCallRanking( + key = "key", + packageName = "com.android.dialer", + title = "Alice", + text = "(555) 123-4567", + matchesInterruptionFilter = true, + ) + + assertFalse(filter.shouldSuppressIncomingCall("Alice", "+15551234567")) + } + + @Test + fun `dnd on allows when ranking caller text includes a label`() = runTest { + interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY + val filter = filter() + filter.recordCallRanking( + key = "key", + packageName = "com.android.dialer", + title = "Alice Mobile", + text = "Incoming call", + matchesInterruptionFilter = true, + ) + + assertFalse(filter.shouldSuppressIncomingCall("Alice", "+15551234567")) + } +} diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt index eeb6220f..7beaab0b 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt @@ -704,7 +704,7 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD ), basicSettingsToggleItem( title = "Respect Phone Do Not Disturb", - description = "Notifications won't be sent to watch if phone is in Do Not Disturb mode (unless configured for that app/person in phone settings)", + description = "Notifications and calls won't be sent to watch if phone is in Do Not Disturb mode (unless allowed by phone settings)", topLevelType = TopLevelType.Phone, section = Section.Notifications, checked = libPebbleConfig.notificationConfig.respectDoNotDisturb,