@qusaieilouti99/call-manager 0.1.166 → 0.1.167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -34,14 +34,6 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|
|
34
34
|
import org.json.JSONArray
|
|
35
35
|
import org.json.JSONObject
|
|
36
36
|
|
|
37
|
-
/**
|
|
38
|
-
* Core call‐management engine. Manages self-managed telecom calls,
|
|
39
|
-
* audio routing, UI notifications, etc.
|
|
40
|
-
*
|
|
41
|
-
* Audio routing is now fully delegated to the Android Telecom framework,
|
|
42
|
-
* which is the correct approach for self-managed calls. This ensures
|
|
43
|
-
* consistency and proper handling of device changes (BT, headset, etc.).
|
|
44
|
-
*/
|
|
45
37
|
object CallEngine {
|
|
46
38
|
private const val TAG = "CallEngine"
|
|
47
39
|
private const val PHONE_ACCOUNT_ID = "com.qusaieilouti99.callmanager.SELF_MANAGED"
|
|
@@ -329,7 +321,6 @@ object CallEngine {
|
|
|
329
321
|
val extras = Bundle().apply {
|
|
330
322
|
putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccountHandle)
|
|
331
323
|
putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingExtras)
|
|
332
|
-
// Let Telecom decide the initial audio route based on devices and video state
|
|
333
324
|
putBoolean(TelecomManager.EXTRA_START_CALL_WITH_SPEAKERPHONE, isVideoCall)
|
|
334
325
|
}
|
|
335
326
|
|
|
@@ -348,11 +339,6 @@ object CallEngine {
|
|
|
348
339
|
updateLockScreenBypass()
|
|
349
340
|
}
|
|
350
341
|
|
|
351
|
-
/**
|
|
352
|
-
* Starts a call and immediately sets it to active.
|
|
353
|
-
* Use this for joining an already-established call.
|
|
354
|
-
* If a call with the same ID is already incoming, it answers it instead.
|
|
355
|
-
*/
|
|
356
342
|
fun startCall(
|
|
357
343
|
callId: String,
|
|
358
344
|
callType: String,
|
|
@@ -382,8 +368,7 @@ object CallEngine {
|
|
|
382
368
|
}
|
|
383
369
|
}
|
|
384
370
|
|
|
385
|
-
|
|
386
|
-
activeCalls[callId] = CallInfo(callId, callType, targetName, null, CallState.ACTIVE)
|
|
371
|
+
activeCalls[callId] = CallInfo(callId, callType, targetName, null, CallState.DIALING)
|
|
387
372
|
currentCallId = callId
|
|
388
373
|
Log.d(TAG, "Call $callId will be started as ACTIVE.")
|
|
389
374
|
|
|
@@ -399,7 +384,7 @@ object CallEngine {
|
|
|
399
384
|
putString(MyConnectionService.EXTRA_CALL_TYPE, callType)
|
|
400
385
|
putString(MyConnectionService.EXTRA_DISPLAY_NAME, targetName)
|
|
401
386
|
putBoolean(MyConnectionService.EXTRA_IS_VIDEO_CALL_BOOLEAN, callType == "Video")
|
|
402
|
-
putBoolean(MyConnectionService.EXTRA_START_IMMEDIATELY_ACTIVE, true)
|
|
387
|
+
putBoolean(MyConnectionService.EXTRA_START_IMMEDIATELY_ACTIVE, true)
|
|
403
388
|
metadata?.let { putString("metadata", it) }
|
|
404
389
|
}
|
|
405
390
|
|
|
@@ -414,7 +399,6 @@ object CallEngine {
|
|
|
414
399
|
bringAppToForeground()
|
|
415
400
|
keepScreenAwake(true)
|
|
416
401
|
updateLockScreenBypass()
|
|
417
|
-
// Event is emitted by coreCallAnswered once the connection is active
|
|
418
402
|
Log.d(TAG, "Successfully placed call to be immediately active with TelecomManager.")
|
|
419
403
|
} catch (e: Exception) {
|
|
420
404
|
Log.e(TAG, "Failed to start call: ${e.message}", e)
|
|
@@ -429,7 +413,12 @@ object CallEngine {
|
|
|
429
413
|
|
|
430
414
|
fun answerCall(callId: String) {
|
|
431
415
|
Log.d(TAG, "answerCall: $callId - local party answering")
|
|
432
|
-
|
|
416
|
+
val connection = telecomConnections[callId]
|
|
417
|
+
if (connection != null) {
|
|
418
|
+
connection.setActive()
|
|
419
|
+
} else {
|
|
420
|
+
Log.e(TAG, "Cannot answer call, no connection found for $callId")
|
|
421
|
+
}
|
|
433
422
|
}
|
|
434
423
|
|
|
435
424
|
fun coreCallAnswered(callId: String, isLocalAnswer: Boolean) {
|
|
@@ -440,8 +429,6 @@ object CallEngine {
|
|
|
440
429
|
return
|
|
441
430
|
}
|
|
442
431
|
|
|
443
|
-
// The connection state change (e.g., onAnswer) is the source of truth.
|
|
444
|
-
// We just update our internal state and UI.
|
|
445
432
|
activeCalls[callId] = callInfo.copy(state = CallState.ACTIVE)
|
|
446
433
|
currentCallId = callId
|
|
447
434
|
Log.d(TAG, "Call $callId set to ACTIVE state")
|
|
@@ -463,9 +450,6 @@ object CallEngine {
|
|
|
463
450
|
keepScreenAwake(true)
|
|
464
451
|
updateLockScreenBypass()
|
|
465
452
|
|
|
466
|
-
// Audio is now managed by the Connection. We don't need to do anything here.
|
|
467
|
-
// The initial audio route is set by MyConnection when it becomes active.
|
|
468
|
-
|
|
469
453
|
if (isLocalAnswer) {
|
|
470
454
|
emitCallAnsweredWithMetadata(callId)
|
|
471
455
|
} else {
|
|
@@ -587,13 +571,12 @@ object CallEngine {
|
|
|
587
571
|
}
|
|
588
572
|
|
|
589
573
|
private fun setMutedInternal(callId: String, muted: Boolean) {
|
|
590
|
-
val
|
|
591
|
-
if (callInfo == null) {
|
|
574
|
+
val connection = telecomConnections[callId] as? MyConnection ?: run {
|
|
592
575
|
Log.w(TAG, "Cannot set mute state for call $callId - not found")
|
|
593
576
|
return
|
|
594
577
|
}
|
|
595
|
-
//
|
|
596
|
-
|
|
578
|
+
// Delegate the action to the connection, which will inform Telecom
|
|
579
|
+
connection.setMuted(muted)
|
|
597
580
|
}
|
|
598
581
|
|
|
599
582
|
fun endCall(callId: String) {
|
|
@@ -675,7 +658,7 @@ object CallEngine {
|
|
|
675
658
|
})
|
|
676
659
|
}
|
|
677
660
|
|
|
678
|
-
// ======
|
|
661
|
+
// ====== TELECOM-DRIVEN AUDIO ROUTING SYSTEM ======
|
|
679
662
|
|
|
680
663
|
fun getAudioDevices(): AudioRoutesInfo {
|
|
681
664
|
val context = requireContext()
|
|
@@ -726,35 +709,11 @@ object CallEngine {
|
|
|
726
709
|
connection.requestAudioRouteChange(telecomRoute)
|
|
727
710
|
}
|
|
728
711
|
|
|
729
|
-
/**
|
|
730
|
-
* Called by MyConnection when the audio route changes. This is the single
|
|
731
|
-
* source of truth for route updates.
|
|
732
|
-
*/
|
|
733
712
|
fun onTelecomAudioRouteChanged(callId: String, newRoute: String) {
|
|
734
713
|
Log.d(TAG, "onTelecomAudioRouteChanged for callId $callId, new route: $newRoute")
|
|
735
714
|
emitAudioRouteChanged()
|
|
736
715
|
}
|
|
737
716
|
|
|
738
|
-
/**
|
|
739
|
-
* Called by MyConnection when it becomes active to set the initial, logical
|
|
740
|
-
* audio route.
|
|
741
|
-
*/
|
|
742
|
-
fun setInitialAudioRouteForCall(callId: String, callType: String) {
|
|
743
|
-
val am = audioManager ?: return
|
|
744
|
-
val connection = telecomConnections[callId] as? MyConnection ?: return
|
|
745
|
-
|
|
746
|
-
// Determine default route based on Android standards
|
|
747
|
-
val defaultRoute = when {
|
|
748
|
-
connection.isBluetoothAvailable() -> "Bluetooth"
|
|
749
|
-
am.isWiredHeadsetOn -> "Headset"
|
|
750
|
-
callType.equals("Video", ignoreCase = true) -> "Speaker"
|
|
751
|
-
else -> "Earpiece"
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
Log.d(TAG, "Requesting initial audio route for call $callId: $defaultRoute")
|
|
755
|
-
setAudioRoute(defaultRoute)
|
|
756
|
-
}
|
|
757
|
-
|
|
758
717
|
private fun emitAudioRouteChanged() {
|
|
759
718
|
val info = getAudioDevices()
|
|
760
719
|
val deviceStrings = info.devices.map { it.value }
|
|
@@ -813,12 +772,11 @@ object CallEngine {
|
|
|
813
772
|
})
|
|
814
773
|
|
|
815
774
|
// Only remove metadata if there's NO existing active call with this ID
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
Log.d(TAG, "Removed metadata for rejected call $callId (no existing call)")
|
|
775
|
+
if (!activeCalls.containsKey(callId)) {
|
|
776
|
+
callMetadata.remove(callId)
|
|
777
|
+
Log.d(TAG, "Removed metadata for rejected call $callId (no existing call)")
|
|
820
778
|
} else {
|
|
821
|
-
|
|
779
|
+
Log.d(TAG, "Kept metadata for callId: $callId (an existing call is using it)")
|
|
822
780
|
}
|
|
823
781
|
}
|
|
824
782
|
|
|
@@ -836,7 +794,6 @@ object CallEngine {
|
|
|
836
794
|
enableVibration(true)
|
|
837
795
|
setBypassDnd(true)
|
|
838
796
|
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
|
839
|
-
// Sound is handled by the RingtoneManager directly for better control
|
|
840
797
|
setSound(null, null)
|
|
841
798
|
}
|
|
842
799
|
val manager = context.getSystemService(NotificationManager::class.java)
|
|
@@ -1141,7 +1098,6 @@ object CallEngine {
|
|
|
1141
1098
|
Log.d(TAG, "Performing cleanup")
|
|
1142
1099
|
stopForegroundService()
|
|
1143
1100
|
keepScreenAwake(false)
|
|
1144
|
-
// Reset audio mode via AudioManager when all calls are truly gone
|
|
1145
1101
|
audioManager?.mode = AudioManager.MODE_NORMAL
|
|
1146
1102
|
}
|
|
1147
1103
|
|
|
@@ -4,6 +4,7 @@ import android.os.Build
|
|
|
4
4
|
import android.os.OutcomeReceiver
|
|
5
5
|
import android.telecom.CallAudioState
|
|
6
6
|
import android.telecom.CallEndpoint
|
|
7
|
+
import android.telecom.CallEndpointException
|
|
7
8
|
import android.telecom.Connection
|
|
8
9
|
import android.telecom.DisconnectCause
|
|
9
10
|
import android.telecom.VideoProfile
|
|
@@ -22,13 +23,11 @@ class MyConnection(
|
|
|
22
23
|
const val TAG = "MyConnection"
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
private var lastAudioState: CallAudioState? = null
|
|
26
26
|
private val telecomExecutor = Executors.newSingleThreadExecutor()
|
|
27
27
|
|
|
28
28
|
init {
|
|
29
29
|
connectionProperties = PROPERTY_SELF_MANAGED
|
|
30
30
|
connectionCapabilities = CAPABILITY_SUPPORT_HOLD or CAPABILITY_MUTE or CAPABILITY_HOLD
|
|
31
|
-
|
|
32
31
|
audioModeIsVoip = true
|
|
33
32
|
|
|
34
33
|
if (callType == "Video") {
|
|
@@ -43,6 +42,8 @@ class MyConnection(
|
|
|
43
42
|
Log.d(TAG, "MyConnection for callId $callId created and added to CallEngine.")
|
|
44
43
|
}
|
|
45
44
|
|
|
45
|
+
// --- Lifecycle Callbacks ---
|
|
46
|
+
|
|
46
47
|
override fun onSilence() {
|
|
47
48
|
super.onSilence()
|
|
48
49
|
Log.d(TAG, "onSilence called by system for callId: $callId. Silencing ringtone.")
|
|
@@ -51,7 +52,7 @@ class MyConnection(
|
|
|
51
52
|
|
|
52
53
|
override fun onAnswer() {
|
|
53
54
|
Log.d(TAG, "Call answered via Telecom for callId: $callId")
|
|
54
|
-
|
|
55
|
+
// State will be set to ACTIVE, which triggers onStateChanged
|
|
55
56
|
CallEngine.answerCall(callId)
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -81,25 +82,7 @@ class MyConnection(
|
|
|
81
82
|
CallEngine.unholdCall(callId)
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
super.onCallAudioStateChanged(state)
|
|
86
|
-
Log.d(TAG, "onCallAudioStateChanged for callId: $callId. muted=${state.isMuted}, route=${state.routeToString()}")
|
|
87
|
-
|
|
88
|
-
// Mute state change
|
|
89
|
-
if (lastAudioState?.isMuted != state.isMuted) {
|
|
90
|
-
val eventType = if (state.isMuted) CallEventType.CALL_MUTED else CallEventType.CALL_UNMUTED
|
|
91
|
-
CallEngine.emitEvent(eventType, JSONObject().put("callId", callId))
|
|
92
|
-
Log.d(TAG, "Call $callId mute state changed to: ${state.isMuted}")
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Route change
|
|
96
|
-
if (lastAudioState?.route != state.route) {
|
|
97
|
-
Log.d(TAG, "System audio route changed for callId: $callId. New route: ${state.routeToString()}")
|
|
98
|
-
CallEngine.onTelecomAudioRouteChanged(callId, state.routeToString())
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
lastAudioState = state
|
|
102
|
-
}
|
|
85
|
+
// --- State Change Callbacks ---
|
|
103
86
|
|
|
104
87
|
override fun onStateChanged(state: Int) {
|
|
105
88
|
super.onStateChanged(state)
|
|
@@ -107,12 +90,13 @@ class MyConnection(
|
|
|
107
90
|
|
|
108
91
|
when (state) {
|
|
109
92
|
STATE_ACTIVE -> {
|
|
110
|
-
|
|
111
|
-
// This is the correct place to do it.
|
|
112
|
-
Log.d(TAG, "Connection is now active for callId: $callId. Setting initial audio route.")
|
|
113
|
-
CallEngine.setInitialAudioRouteForCall(callId, callType)
|
|
93
|
+
Log.d(TAG, "Connection is now active for callId: $callId.")
|
|
114
94
|
// If this was an immediate-start call, trigger the answered event.
|
|
115
|
-
|
|
95
|
+
// Otherwise, this is handled by onAnswer -> answerCall.
|
|
96
|
+
val callInfo = CallEngine.getActiveCalls().find { it.callId == callId }
|
|
97
|
+
if (callInfo?.state != CallState.ACTIVE) {
|
|
98
|
+
CallEngine.coreCallAnswered(callId, isLocalAnswer = true)
|
|
99
|
+
}
|
|
116
100
|
}
|
|
117
101
|
STATE_DISCONNECTED -> {
|
|
118
102
|
Log.d(TAG, "Connection is now disconnected for callId: $callId")
|
|
@@ -121,17 +105,37 @@ class MyConnection(
|
|
|
121
105
|
}
|
|
122
106
|
}
|
|
123
107
|
|
|
108
|
+
override fun onCallAudioStateChanged(state: CallAudioState) {
|
|
109
|
+
super.onCallAudioStateChanged(state)
|
|
110
|
+
Log.d(TAG, "onCallAudioStateChanged for callId: $callId. muted=${state.isMuted}, route=${routeToString(state.route)}")
|
|
111
|
+
|
|
112
|
+
// This callback is the source of truth from the system.
|
|
113
|
+
// We just need to forward these state changes to our engine/UI.
|
|
114
|
+
CallEngine.onTelecomAudioRouteChanged(callId, routeToString(state.route))
|
|
115
|
+
|
|
116
|
+
// Emit mute/unmute events based on state changes from the system
|
|
117
|
+
val eventType = if (state.isMuted) CallEventType.CALL_MUTED else CallEventType.CALL_UNMUTED
|
|
118
|
+
CallEngine.emitEvent(eventType, JSONObject().put("callId", callId))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Audio Control Methods (Called from CallEngine) ---
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Requests a change in the audio route (Speaker, Earpiece, etc.).
|
|
125
|
+
* This is the ONLY method that should be used to change the audio route.
|
|
126
|
+
*/
|
|
124
127
|
fun requestAudioRouteChange(route: Int) {
|
|
125
128
|
Log.d(TAG, "Requesting audio route change to: ${routeToString(route)} for callId: $callId")
|
|
126
129
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
127
|
-
|
|
130
|
+
// For API 34+, use the modern CallEndpoint API
|
|
131
|
+
val endpoint = this.callAudioState?.availableCallEndpoints?.find { it.type == route }
|
|
128
132
|
if (endpoint != null) {
|
|
129
133
|
Log.d(TAG, "Using requestCallEndpointChange for API 34+")
|
|
130
|
-
requestCallEndpointChange(endpoint, telecomExecutor, object : OutcomeReceiver<Void,
|
|
134
|
+
requestCallEndpointChange(endpoint, telecomExecutor, object : OutcomeReceiver<Void, CallEndpointException> {
|
|
131
135
|
override fun onResult(result: Void?) {
|
|
132
|
-
Log.d(TAG, "Successfully
|
|
136
|
+
Log.d(TAG, "Successfully requested audio endpoint change to ${endpoint.name}")
|
|
133
137
|
}
|
|
134
|
-
override fun onError(error:
|
|
138
|
+
override fun onError(error: CallEndpointException) {
|
|
135
139
|
Log.e(TAG, "Failed to change audio endpoint", error)
|
|
136
140
|
}
|
|
137
141
|
})
|
|
@@ -139,23 +143,37 @@ class MyConnection(
|
|
|
139
143
|
Log.w(TAG, "Could not find a matching CallEndpoint for route: ${routeToString(route)}")
|
|
140
144
|
}
|
|
141
145
|
} else {
|
|
146
|
+
// For older APIs, use the deprecated setAudioRoute
|
|
142
147
|
Log.d(TAG, "Using setAudioRoute (deprecated) for older API")
|
|
143
148
|
@Suppress("DEPRECATION")
|
|
144
149
|
setAudioRoute(route)
|
|
145
150
|
}
|
|
146
151
|
}
|
|
147
152
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Informs the Telecom framework about a mute state change initiated by our app.
|
|
155
|
+
*/
|
|
156
|
+
fun setMuted(muted: Boolean) {
|
|
157
|
+
val currentState = this.callAudioState ?: return
|
|
158
|
+
if (currentState.isMuted == muted) return // No change needed
|
|
159
|
+
|
|
160
|
+
val newState = CallAudioState(muted, currentState.route, currentState.supportedRouteMask)
|
|
161
|
+
// This call informs the system of the change. The system will then call
|
|
162
|
+
// onCallAudioStateChanged back on this connection instance with the new state.
|
|
163
|
+
onCallAudioStateChanged(newState)
|
|
164
|
+
Log.d(TAG, "Informed Telecom of mute state change to: $muted")
|
|
155
165
|
}
|
|
156
166
|
|
|
167
|
+
|
|
168
|
+
// --- Helper Functions ---
|
|
169
|
+
|
|
157
170
|
fun getCurrentRouteString(): String {
|
|
158
|
-
return
|
|
171
|
+
return this.callAudioState?.route.let { routeToString(it) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fun isBluetoothAvailable(): Boolean {
|
|
175
|
+
val mask = this.callAudioState?.supportedRouteMask ?: 0
|
|
176
|
+
return mask and CallAudioState.ROUTE_BLUETOOTH != 0
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
private fun routeToString(route: Int?): String {
|
|
@@ -167,4 +185,18 @@ class MyConnection(
|
|
|
167
185
|
else -> "Unknown"
|
|
168
186
|
}
|
|
169
187
|
}
|
|
188
|
+
|
|
189
|
+
private fun stateToString(state: Int): String {
|
|
190
|
+
return when (state) {
|
|
191
|
+
STATE_INITIALIZING -> "INITIALIZING"
|
|
192
|
+
STATE_NEW -> "NEW"
|
|
193
|
+
STATE_RINGING -> "RINGING"
|
|
194
|
+
STATE_DIALING -> "DIALING"
|
|
195
|
+
STATE_ACTIVE -> "ACTIVE"
|
|
196
|
+
STATE_HOLDING -> "HOLDING"
|
|
197
|
+
STATE_DISCONNECTED -> "DISCONNECTED"
|
|
198
|
+
STATE_PULLING_CALL -> "PULLING_CALL"
|
|
199
|
+
else -> "UNKNOWN"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
170
202
|
}
|