From 6fc34808fb7c310e8117d3c573961fb32bf774f0 Mon Sep 17 00:00:00 2001 From: crc-32 Date: Thu, 1 Aug 2024 16:22:40 +0100 Subject: [PATCH] VOIP call sync support --- .../io/rebble/cobble/di/AppComponent.kt | 2 + .../CallNotificationProcessor.kt | 188 ++++++++++++++++++ .../notifications/NotificationListener.kt | 16 +- .../io/rebble/cobble/service/InCallService.kt | 2 +- .../notifications/calls/CallNotification.kt | 19 ++ .../calls/CallNotificationInterpreter.kt | 7 + .../DiscordCallNotificationInterpreter.kt | 28 +++ .../WhatsAppCallNotificationInterpreter.kt | 41 ++++ 8 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/kotlin/io/rebble/cobble/notifications/CallNotificationProcessor.kt create mode 100644 android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/CallNotification.kt create mode 100644 android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/CallNotificationInterpreter.kt create mode 100644 android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/DiscordCallNotificationInterpreter.kt create mode 100644 android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/WhatsAppCallNotificationInterpreter.kt diff --git a/android/app/src/main/kotlin/io/rebble/cobble/di/AppComponent.kt b/android/app/src/main/kotlin/io/rebble/cobble/di/AppComponent.kt index 267850ec..a691d5e0 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/di/AppComponent.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/di/AppComponent.kt @@ -12,6 +12,7 @@ import io.rebble.cobble.datasources.FlutterPreferences import io.rebble.cobble.datasources.PairedStorage import io.rebble.cobble.datasources.WatchMetadataStore import io.rebble.cobble.errors.GlobalExceptionHandler +import io.rebble.cobble.notifications.CallNotificationProcessor import io.rebble.cobble.notifications.NotificationProcessor import io.rebble.cobble.service.ServiceLifecycleControl import io.rebble.cobble.shared.database.dao.NotificationChannelDao @@ -43,6 +44,7 @@ interface AppComponent { fun createWatchMetadataStore(): WatchMetadataStore fun createPairedStorage(): PairedStorage fun createNotificationProcessor(): NotificationProcessor + fun createCallNotificationProcessor(): CallNotificationProcessor fun createFlutterPreferences(): FlutterPreferences fun initServiceLifecycleControl(): ServiceLifecycleControl fun initNotificationChannels(): NotificationChannelManager diff --git a/android/app/src/main/kotlin/io/rebble/cobble/notifications/CallNotificationProcessor.kt b/android/app/src/main/kotlin/io/rebble/cobble/notifications/CallNotificationProcessor.kt new file mode 100644 index 00000000..d14e0ba1 --- /dev/null +++ b/android/app/src/main/kotlin/io/rebble/cobble/notifications/CallNotificationProcessor.kt @@ -0,0 +1,188 @@ +package io.rebble.cobble.notifications + +import android.service.notification.StatusBarNotification +import io.rebble.cobble.errors.GlobalExceptionHandler +import io.rebble.cobble.shared.Logging +import io.rebble.cobble.shared.datastore.KMPPrefs +import io.rebble.cobble.shared.domain.notifications.calls.CallNotification +import io.rebble.cobble.shared.domain.notifications.calls.CallNotificationType +import io.rebble.cobble.shared.domain.notifications.calls.DiscordCallNotificationInterpreter +import io.rebble.cobble.shared.domain.notifications.calls.WhatsAppCallNotificationInterpreter +import io.rebble.cobble.shared.domain.state.ConnectionState +import io.rebble.libpebblecommon.packets.PhoneControl +import io.rebble.libpebblecommon.services.PhoneControlService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.random.Random +import io.rebble.cobble.bluetooth.ConnectionLooper + +class CallNotificationProcessor @Inject constructor( + exceptionHandler: GlobalExceptionHandler, + private val prefs: KMPPrefs, + private val phoneControl: PhoneControlService, + private val connectionLooper: ConnectionLooper +) { + val coroutineScope = CoroutineScope( + SupervisorJob() + exceptionHandler + ) + + open class CallState(val cookie: UInt?) { + object IDLE : CallState(null) + class RINGING(val notification: CallNotification, cookie: UInt?) : CallState(cookie) { + override fun toString(): String { + return "RINGING(cookie=$cookie, notification=$notification)" + } + } + class ONGOING(val notification: CallNotification, cookie: UInt?) : CallState(cookie) { + override fun toString(): String { + return "ONGOING(cookie=$cookie, notification=$notification)" + } + } + } + + private val callState = MutableStateFlow(CallState.IDLE) + + init { + var previousState = callState.value + // Debounce to avoid condition where ring notif is dismissed and then ongoing notif appears + callState.debounce(1000).onEach { state -> + val sensitiveLogging = prefs.sensitiveDataLoggingEnabled.first() + if (sensitiveLogging) { + Logging.d("Call state changed to $state from $previousState") + } else { + Logging.d("Call state changed to ${state::class.simpleName} from ${previousState::class.simpleName}") + } + if (state is CallState.RINGING && previousState is CallState.IDLE) { + state.cookie?.let { + phoneControl.send( + PhoneControl.IncomingCall( + it, + state.notification.contactHandle ?: "Unknown", + state.notification.contactName ?: "" + ) + ) + } + } else if (state is CallState.IDLE && (previousState is CallState.ONGOING || previousState is CallState.RINGING)) { + previousState.cookie?.let { + phoneControl.send(PhoneControl.End(it)) + } + } + previousState = state + }.launchIn(coroutineScope) + + phoneControl.receivedMessages.receiveAsFlow().onEach { + if (connectionLooper.connectionState.value !is ConnectionState.Connected) { + Logging.w("Ignoring phone control message because watch is not connected") + return@onEach + } + when (it) { + is PhoneControl.Answer -> { + synchronized(this@CallNotificationProcessor) { + val state = callState.value as? CallState.RINGING ?: return@onEach + if (it.cookie.get() == state.cookie) { + Logging.d("Answering call") + state.notification.answer?.send() ?: run { + callState.value = CallState.IDLE + return@synchronized + } + callState.value = CallState.ONGOING(state.notification, state.cookie) + } + } + } + + is PhoneControl.Hangup -> { + synchronized(this@CallNotificationProcessor) { + when (val state = callState.value) { + is CallState.RINGING -> { + if (it.cookie.get() == state.cookie) { + Logging.d("Rejecting ringing call") + state.notification.decline?.send() + callState.value = CallState.IDLE + } + } + is CallState.ONGOING -> { + if (it.cookie.get() == state.cookie) { + Logging.d("Disconnecting call") + state.notification.hangUp?.send() + callState.value = CallState.IDLE + } + } + } + } + } + + else -> { + Logging.w("Unhandled phone control message: $it") + } + } + }.launchIn(coroutineScope) + + } + + companion object { + private val callPackages = mapOf( + "com.whatsapp" to WhatsAppCallNotificationInterpreter(), + "com.discord" to DiscordCallNotificationInterpreter(), + ) + } + + fun processCallNotification(sbn: StatusBarNotification) { + val interpreter = callPackages[sbn.packageName] ?: run { + Logging.d("Call notification from ${sbn.packageName} does not have an interpreter") + return + } + coroutineScope.launch { + val sensitiveLogging = prefs.sensitiveDataLoggingEnabled.first() + if (sensitiveLogging) { + Logging.d("Processing call notification from ${sbn.packageName} with actions: ${sbn.notification.actions.joinToString { it.title.toString() }}") + Logging.d("Call Notification: ${sbn.notification}") + Logging.d("Extras: ${sbn.notification.extras}") + Logging.d("Actions: ${ + sbn.notification.actions.joinToString(", ") { + buildString { + append("(") + append("Action: ${it.title}") + append(", Extras: ${it.extras.keySet().joinToString { key -> "$key: ${it.extras[key]}" }}") + append(", SemanticAction: ${it.semanticAction}") + append(")") + } + } + }") + } else { + Logging.d("Processing call notification from ${sbn.packageName}") + } + + val callNotification = interpreter.processCallNotification(sbn) ?: run { + Logging.d("Call notification from ${sbn.packageName} was not recognized") + return@launch + } + val nwCookie = Random.nextInt().toUInt() and 0xCAu.inv() + synchronized(this@CallNotificationProcessor) { + if (callState.value is CallState.IDLE && callNotification.type == CallNotificationType.RINGING) { + // Random number that does not end with 0xCA (magic number for phone call) + callState.value = CallState.RINGING(callNotification, nwCookie) + } else if (callState.value !is CallState.ONGOING && callNotification.type == CallNotificationType.ONGOING) { + callState.value = CallState.ONGOING(callNotification, (callState.value as? CallState.RINGING)?.cookie ?: nwCookie) + } + } + } + } + + fun processCallNotificationDismissal(sbn: StatusBarNotification) { + val interpreter = callPackages[sbn.packageName] ?: return + synchronized(this@CallNotificationProcessor) { + val state = callState.value + val callNotification = interpreter.processCallNotification(sbn) ?: return + callNotification.answer?.intentSender + if ( + (state is CallState.RINGING && state.notification.packageName == sbn.packageName) || + (state is CallState.ONGOING && state.notification.packageName == sbn.packageName) + ) { + callState.value = CallState.IDLE + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/notifications/NotificationListener.kt b/android/app/src/main/kotlin/io/rebble/cobble/notifications/NotificationListener.kt index 74ff8bb1..9ff33d47 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/notifications/NotificationListener.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/notifications/NotificationListener.kt @@ -31,6 +31,7 @@ class NotificationListener : NotificationListenerService() { private lateinit var connectionLooper: ConnectionLooper private lateinit var flutterPreferences: FlutterPreferences private lateinit var notificationProcessor: NotificationProcessor + private lateinit var callNotificationProcessor: CallNotificationProcessor private lateinit var activeNotifsState: MutableStateFlow> private lateinit var notificationChannelDao: NotificationChannelDao @@ -55,6 +56,7 @@ class NotificationListener : NotificationListenerService() { notificationProcessor = injectionComponent.createNotificationProcessor() activeNotifsState = injectionComponent.createActiveNotifsState() notificationChannelDao = injectionComponent.createNotificationChannelDao() + callNotificationProcessor = injectionComponent.createCallNotificationProcessor() super.onCreate() _isActive.value = true @@ -127,6 +129,12 @@ class NotificationListener : NotificationListenerService() { } catch (e: Exception) { Timber.w(e, "Failed to get notif channels from ${sbn.packageName}") } + + if (NotificationCompat.getCategory(sbn.notification) == Notification.CATEGORY_CALL) { + callNotificationProcessor.processCallNotification(sbn) + return@launch + } + if (NotificationCompat.getLocalOnly(sbn.notification)) return@launch // ignore local notifications TODO: respect user preference if (sbn.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) return@launch // ignore ongoing notifications if (mutedPackages.contains(sbn.packageName)) return@launch // ignore muted packages @@ -153,8 +161,12 @@ class NotificationListener : NotificationListenerService() { override fun onNotificationRemoved(sbn: StatusBarNotification) { if (isListening) { Timber.d("Notification removed: ${sbn.packageName}") - coroutineScope.launch { - notificationProcessor.processDismissed(sbn) + if (NotificationCompat.getCategory(sbn.notification) == Notification.CATEGORY_CALL) { + callNotificationProcessor.processCallNotificationDismissal(sbn) + } else { + coroutineScope.launch { + notificationProcessor.processDismissed(sbn) + } } } } diff --git a/android/app/src/main/kotlin/io/rebble/cobble/service/InCallService.kt b/android/app/src/main/kotlin/io/rebble/cobble/service/InCallService.kt index fb0fb1d8..604e67aa 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/service/InCallService.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/service/InCallService.kt @@ -105,7 +105,7 @@ class InCallService : InCallService() { } lastCall = call } - val cookie = Random.nextInt().toUInt() + val cookie = Random.nextInt().toUInt() or 0xCAu // Magic number for phone call to differentiate from third-party calls synchronized(this@InCallService) { lastCookie = cookie } diff --git a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/CallNotification.kt b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/CallNotification.kt new file mode 100644 index 00000000..39cf3ef9 --- /dev/null +++ b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/CallNotification.kt @@ -0,0 +1,19 @@ +package io.rebble.cobble.shared.domain.notifications.calls + +import android.app.PendingIntent + + +enum class CallNotificationType { + RINGING, + ONGOING +} + +data class CallNotification( + val packageName: String, + val answer: PendingIntent?, + val decline: PendingIntent?, + val hangUp: PendingIntent?, + val type: CallNotificationType, + val contactHandle: String?, + val contactName: String? +) diff --git a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/CallNotificationInterpreter.kt b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/CallNotificationInterpreter.kt new file mode 100644 index 00000000..5c78b2c6 --- /dev/null +++ b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/CallNotificationInterpreter.kt @@ -0,0 +1,7 @@ +package io.rebble.cobble.shared.domain.notifications.calls + +import android.service.notification.StatusBarNotification + +interface CallNotificationInterpreter { + fun processCallNotification(sbn: StatusBarNotification): CallNotification? +} \ No newline at end of file diff --git a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/DiscordCallNotificationInterpreter.kt b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/DiscordCallNotificationInterpreter.kt new file mode 100644 index 00000000..bd562465 --- /dev/null +++ b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/DiscordCallNotificationInterpreter.kt @@ -0,0 +1,28 @@ +package io.rebble.cobble.shared.domain.notifications.calls + +import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationCompat + +class DiscordCallNotificationInterpreter: CallNotificationInterpreter { + override fun processCallNotification(sbn: StatusBarNotification): CallNotification? { + val joinCallAction = sbn.notification.actions.firstOrNull { it.title.toString().contains("Join Call") } + val declineAction = sbn.notification.actions.firstOrNull { it.title.toString().contains("Decline") } + + if (joinCallAction != null && declineAction != null) { + val contactName = NotificationCompat.getContentText(sbn.notification)?.trim()?.split(" ")?.firstOrNull()?.let { + "Discord\n$it" + } + return CallNotification( + sbn.packageName, + joinCallAction.actionIntent, + declineAction.actionIntent, + null, + CallNotificationType.RINGING, + "Discord", + contactName ?: "Discord Call" + ) + } else { + return null + } + } +} \ No newline at end of file diff --git a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/WhatsAppCallNotificationInterpreter.kt b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/WhatsAppCallNotificationInterpreter.kt new file mode 100644 index 00000000..3b04f6ca --- /dev/null +++ b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/domain/notifications/calls/WhatsAppCallNotificationInterpreter.kt @@ -0,0 +1,41 @@ +package io.rebble.cobble.shared.domain.notifications.calls + +import android.service.notification.StatusBarNotification + +class WhatsAppCallNotificationInterpreter: CallNotificationInterpreter { + override fun processCallNotification(sbn: StatusBarNotification): CallNotification? { + val acceptAction = sbn.notification.actions.firstOrNull { it.title.toString().contains("Answer", true) } + val declineAction = sbn.notification.actions.firstOrNull { it.title.toString().contains("Decline", true) } + val hangUpAction = sbn.notification.actions.firstOrNull { it.title.toString().contains("Hang up", true) } + + if (acceptAction != null && declineAction != null) { + val contactName = sbn.notification.extras.getCharSequence("android.title")?.toString()?.let { + "WhatsApp\n$it" + } + return CallNotification( + sbn.packageName, + acceptAction.actionIntent, + declineAction.actionIntent, + null, + CallNotificationType.RINGING, + "WhatsApp", + contactName ?: "WhatsApp Call" + ) + } else if (hangUpAction != null) { + val contactName = sbn.notification.extras.getCharSequence("android.title")?.toString()?.let { + "WhatsApp\n$it" + } + return CallNotification( + sbn.packageName, + null, + null, + hangUpAction.actionIntent, + CallNotificationType.ONGOING, + "WhatsApp", + contactName ?: "WhatsApp Call" + ) + } else { + return null + } + } +} \ No newline at end of file