Skip to content

Commit 1e83f33

Browse files
committed
Call notification implementation
1 parent 25dbe81 commit 1e83f33

11 files changed

Lines changed: 985 additions & 6 deletions

app/src/main/AndroidManifest.xml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
2323
<uses-permission android:name="android.permission.WAKE_LOCK" />
2424

25+
<!-- Call handling permissions -->
26+
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
27+
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
28+
<uses-permission android:name="android.permission.CALL_PHONE" />
29+
<uses-permission android:name="android.permission.READ_CALL_LOG" />
30+
2531
<application
2632
android:allowBackup="true"
2733
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -100,6 +106,23 @@
100106
android:foregroundServiceType="mediaPlayback">
101107
</service>
102108

109+
<!-- Call Monitoring Service - detects incoming/outgoing calls -->
110+
<service
111+
android:name=".service.CallMonitoringService"
112+
android:exported="false">
113+
</service>
114+
115+
<!-- Call State Receiver - broadcasts for call state changes -->
116+
<receiver
117+
android:name=".service.CallStateReceiver"
118+
android:exported="true"
119+
android:permission="android.permission.READ_PHONE_STATE">
120+
<intent-filter>
121+
<action android:name="android.intent.action.PHONE_STATE" />
122+
<action android:name="android.intent.action.NEW_OUTGOING_CALL" />
123+
</intent-filter>
124+
</receiver>
125+
103126
<!-- Quick Settings Tile Service -->
104127
<service
105128
android:name=".service.AirSyncTileService"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.sameerasw.airsync.domain.model
2+
3+
import java.util.*
4+
5+
/**
6+
* Represents the state of an active or incoming call
7+
*/
8+
data class CallState(
9+
val id: String = UUID.randomUUID().toString(),
10+
val phoneNumber: String = "",
11+
val callerName: String = "",
12+
val callType: CallType = CallType.INCOMING, // INCOMING or OUTGOING
13+
val callState: CallStatus = CallStatus.RINGING, // RINGING, ACTIVE, HELD, DISCONNECTED
14+
val appName: String = "Phone",
15+
val packageName: String = "com.google.android.dialer",
16+
val duration: Long = 0L, // Duration in milliseconds
17+
val timestamp: Long = System.currentTimeMillis()
18+
)
19+
20+
enum class CallType {
21+
INCOMING,
22+
OUTGOING
23+
}
24+
25+
enum class CallStatus {
26+
RINGING,
27+
ACTIVE,
28+
HELD,
29+
DISCONNECTED
30+
}
31+
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.sameerasw.airsync.service
2+
3+
import android.app.Service
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.IntentFilter
7+
import android.os.IBinder
8+
import android.telephony.TelephonyManager
9+
import android.util.Log
10+
import com.sameerasw.airsync.utils.CallStateManager
11+
12+
/**
13+
* Background service to monitor phone call states and send updates to the Mac app.
14+
* This service runs whenever the app is connected to detect incoming/outgoing calls.
15+
*/
16+
class CallMonitoringService : Service() {
17+
companion object {
18+
private const val TAG = "CallMonitoringService"
19+
private var callStateReceiver: CallStateReceiver? = null
20+
21+
fun startMonitoring(context: Context) {
22+
try {
23+
val intent = Intent(context, CallMonitoringService::class.java)
24+
context.startService(intent)
25+
Log.d(TAG, "Call monitoring service start requested")
26+
} catch (e: Exception) {
27+
Log.e(TAG, "Error starting call monitoring service: ${e.message}")
28+
}
29+
}
30+
31+
fun stopMonitoring(context: Context) {
32+
try {
33+
val intent = Intent(context, CallMonitoringService::class.java)
34+
context.stopService(intent)
35+
Log.d(TAG, "Call monitoring service stop requested")
36+
} catch (e: Exception) {
37+
Log.e(TAG, "Error stopping call monitoring service: ${e.message}")
38+
}
39+
}
40+
}
41+
42+
override fun onCreate() {
43+
super.onCreate()
44+
Log.d(TAG, "CallMonitoringService created")
45+
}
46+
47+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
48+
Log.d(TAG, "CallMonitoringService started")
49+
50+
try {
51+
// Register broadcast receiver for call state changes
52+
if (callStateReceiver == null) {
53+
callStateReceiver = CallStateReceiver()
54+
val intentFilter = IntentFilter().apply {
55+
addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED)
56+
addAction("android.intent.action.NEW_OUTGOING_CALL")
57+
}
58+
registerReceiver(callStateReceiver, intentFilter, Context.RECEIVER_EXPORTED)
59+
Log.d(TAG, "CallStateReceiver registered successfully")
60+
}
61+
62+
// Initialize call state monitoring
63+
CallStateManager.startMonitoring(this)
64+
Log.d(TAG, "Call state monitoring initialized")
65+
} catch (e: SecurityException) {
66+
Log.w(TAG, "SecurityException during service startup: ${e.message}")
67+
} catch (e: Exception) {
68+
Log.e(TAG, "Error in onStartCommand: ${e.message}")
69+
}
70+
71+
return START_STICKY
72+
}
73+
74+
override fun onDestroy() {
75+
super.onDestroy()
76+
try {
77+
if (callStateReceiver != null) {
78+
unregisterReceiver(callStateReceiver)
79+
callStateReceiver = null
80+
Log.d(TAG, "CallStateReceiver unregistered")
81+
}
82+
CallStateManager.stopMonitoring()
83+
Log.d(TAG, "CallMonitoringService destroyed and monitoring stopped")
84+
} catch (e: Exception) {
85+
Log.e(TAG, "Error stopping call monitoring: ${e.message}")
86+
}
87+
}
88+
89+
override fun onBind(intent: Intent?): IBinder? = null
90+
}
91+
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.sameerasw.airsync.service
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.IntentFilter
7+
import android.os.Build
8+
import android.telephony.TelephonyManager
9+
import android.util.Log
10+
import com.sameerasw.airsync.utils.CallStateManager
11+
12+
/**
13+
* Broadcast receiver to detect incoming calls and call state changes.
14+
* This catches both system calls and third-party calling app calls.
15+
*/
16+
class CallStateReceiver : BroadcastReceiver() {
17+
companion object {
18+
private const val TAG = "CallStateReceiver"
19+
20+
// Deduplication: track last state changes to avoid duplicate processing
21+
private var lastProcessedState: String? = null
22+
private var lastProcessedTime: Long = 0L
23+
private const val DEDUP_WINDOW_MS = 300L // Ignore same state within 300ms
24+
25+
fun registerReceiver(context: Context) {
26+
try {
27+
val receiver = CallStateReceiver()
28+
val intentFilter = IntentFilter().apply {
29+
addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED)
30+
addAction("com.android.internal.telephony.PHONE_STATE_CHANGED")
31+
addAction("android.intent.action.NEW_OUTGOING_CALL")
32+
}
33+
// Use RECEIVER_EXPORTED flag for Android 12+ (API 31+), with fallback for older versions
34+
@Suppress("RECEIVER_EXPORTED")
35+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
36+
context.registerReceiver(receiver, intentFilter, Context.RECEIVER_EXPORTED)
37+
} else {
38+
@Suppress("DEPRECATION")
39+
context.registerReceiver(receiver, intentFilter)
40+
}
41+
Log.d(TAG, "CallStateReceiver registered")
42+
} catch (e: Exception) {
43+
Log.e(TAG, "Error registering CallStateReceiver: ${e.message}")
44+
}
45+
}
46+
}
47+
48+
override fun onReceive(context: Context?, intent: Intent?) {
49+
if (context == null || intent == null) return
50+
51+
try {
52+
when (intent.action) {
53+
TelephonyManager.ACTION_PHONE_STATE_CHANGED -> {
54+
handlePhoneStateChanged(intent)
55+
}
56+
"android.intent.action.NEW_OUTGOING_CALL" -> {
57+
handleOutgoingCall(intent)
58+
}
59+
"com.android.internal.telephony.PHONE_STATE_CHANGED" -> {
60+
handlePhoneStateChanged(intent)
61+
}
62+
}
63+
} catch (e: Exception) {
64+
Log.e(TAG, "Error handling broadcast: ${e.message}")
65+
}
66+
}
67+
68+
private fun handlePhoneStateChanged(intent: Intent) {
69+
val state = intent.getStringExtra(TelephonyManager.EXTRA_STATE) ?: return
70+
@Suppress("DEPRECATION")
71+
val incomingNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER)
72+
73+
// Deduplication: skip if we just processed this exact state
74+
val now = System.currentTimeMillis()
75+
val stateKey = "$state:$incomingNumber"
76+
if (lastProcessedState == stateKey && (now - lastProcessedTime) < DEDUP_WINDOW_MS) {
77+
Log.d(TAG, "Skipping duplicate phone state broadcast: $state")
78+
return
79+
}
80+
lastProcessedState = stateKey
81+
lastProcessedTime = now
82+
83+
Log.d(TAG, "Phone state changed: $state, incoming number: $incomingNumber")
84+
85+
when (state) {
86+
TelephonyManager.EXTRA_STATE_RINGING -> {
87+
// Incoming call ringing with phone number
88+
if (!incomingNumber.isNullOrEmpty()) {
89+
Log.d(TAG, "Incoming call from: $incomingNumber")
90+
CallStateManager.recordIncomingCall(incomingNumber)
91+
} else {
92+
Log.d(TAG, "Ringing but no incoming number available")
93+
}
94+
}
95+
TelephonyManager.EXTRA_STATE_OFFHOOK -> {
96+
// Call is active (could be incoming or outgoing)
97+
Log.d(TAG, "Call active (OFFHOOK)")
98+
if (!incomingNumber.isNullOrEmpty()) {
99+
// If we have a number, it might be outgoing - record it as outgoing
100+
Log.d(TAG, "OFFHOOK with number: $incomingNumber - updating call")
101+
CallStateManager.recordOutgoingCall(incomingNumber)
102+
}
103+
CallStateManager.markCallActive()
104+
}
105+
TelephonyManager.EXTRA_STATE_IDLE -> {
106+
// Call ended
107+
Log.d(TAG, "Call ended (IDLE)")
108+
CallStateManager.markCallEnded()
109+
}
110+
}
111+
}
112+
113+
private fun handleOutgoingCall(intent: Intent) {
114+
val outgoingNumber = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER)
115+
Log.d(TAG, "Outgoing call detected: $outgoingNumber")
116+
117+
if (!outgoingNumber.isNullOrEmpty()) {
118+
CallStateManager.recordOutgoingCall(outgoingNumber)
119+
}
120+
}
121+
}
122+

app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,17 @@ import android.media.session.MediaController
1010
import android.media.session.MediaSessionManager
1111
import android.media.session.PlaybackState
1212
import android.service.notification.NotificationListenerService
13-
import android.service.notification.NotificationListenerService.RankingMap
1413
import android.service.notification.StatusBarNotification
1514
import android.util.Base64
1615
import android.util.Log
1716
import com.sameerasw.airsync.data.local.DataStoreManager
1817
import com.sameerasw.airsync.domain.model.MediaInfo
19-
import com.sameerasw.airsync.utils.DeviceInfoUtil
2018
import com.sameerasw.airsync.utils.JsonUtil
2119
import com.sameerasw.airsync.utils.NotificationDismissalUtil
2220
import com.sameerasw.airsync.utils.NotificationUtil
2321
import com.sameerasw.airsync.utils.SyncManager
2422
import com.sameerasw.airsync.utils.WebSocketUtil
23+
import com.sameerasw.airsync.utils.ThirdPartyCallDetector
2524
import java.io.ByteArrayOutputStream
2625
import kotlinx.coroutines.CoroutineScope
2726
import kotlinx.coroutines.Dispatchers
@@ -367,6 +366,42 @@ class MediaNotificationListener : NotificationListenerService() {
367366
sbn?.let { notification ->
368367
Log.d(TAG, "Notification posted: ${notification.packageName} - ${notification.notification?.extras?.getString(Notification.EXTRA_TITLE)}")
369368

369+
// Detect incoming calls from third-party apps (WhatsApp, Skype, Google Meet, etc.)
370+
try {
371+
val title = notification.notification?.extras?.getString(Notification.EXTRA_TITLE) ?: ""
372+
val body = notification.notification?.extras?.getString(Notification.EXTRA_TEXT) ?: ""
373+
374+
// First check if this is a dialer call (incoming or outgoing)
375+
val (contactName, callType) = ThirdPartyCallDetector.detectDialerCallNotification(
376+
notification.packageName,
377+
title,
378+
body
379+
) ?: Pair(null, null)
380+
381+
if (contactName != null && callType != null) {
382+
Log.d(TAG, "Dialer call detected: $callType - $contactName")
383+
ThirdPartyCallDetector.recordDialerCall(contactName, callType)
384+
} else {
385+
// Then check for third-party app calls
386+
val (callerName, appName) = ThirdPartyCallDetector.detectCallFromNotification(
387+
notification.packageName,
388+
title,
389+
body
390+
) ?: Pair(null, null)
391+
392+
if (callerName != null && appName != null) {
393+
Log.d(TAG, "Incoming call detected from $appName: $callerName")
394+
ThirdPartyCallDetector.recordThirdPartyIncomingCall(
395+
notification.packageName,
396+
callerName,
397+
appName
398+
)
399+
}
400+
}
401+
} catch (e: Exception) {
402+
Log.w(TAG, "Error detecting call from notification: ${e.message}")
403+
}
404+
370405
// Skip media processing if media listener is paused or globally disabled
371406
if (!isMediaListenerPaused && isNowPlayingEnabled) {
372407
// Update media info and check for changes (includes like status)

0 commit comments

Comments
 (0)