Skip to content

Commit

Permalink
VOIP call sync support
Browse files Browse the repository at this point in the history
  • Loading branch information
crc-32 committed Aug 1, 2024
1 parent 35dd4ee commit 6fc3480
Show file tree
Hide file tree
Showing 8 changed files with 300 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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>(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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<Uuid, StatusBarNotification>>
private lateinit var notificationChannelDao: NotificationChannelDao

Expand All @@ -55,6 +56,7 @@ class NotificationListener : NotificationListenerService() {
notificationProcessor = injectionComponent.createNotificationProcessor()
activeNotifsState = injectionComponent.createActiveNotifsState()
notificationChannelDao = injectionComponent.createNotificationChannelDao()
callNotificationProcessor = injectionComponent.createCallNotificationProcessor()

super.onCreate()
_isActive.value = true
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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?
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.rebble.cobble.shared.domain.notifications.calls

import android.service.notification.StatusBarNotification

interface CallNotificationInterpreter {
fun processCallNotification(sbn: StatusBarNotification): CallNotification?
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}

0 comments on commit 6fc3480

Please sign in to comment.