@qusaieilouti99/call-manager 0.1.166 → 0.1.168

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
- // This call will be set to ACTIVE immediately by MyConnectionService
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) // Custom flag
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,19 +413,22 @@ object CallEngine {
429
413
 
430
414
  fun answerCall(callId: String) {
431
415
  Log.d(TAG, "answerCall: $callId - local party answering")
432
- coreCallAnswered(callId, isLocalAnswer = true)
416
+ val connection = telecomConnections[callId] as? MyConnection
417
+ if (connection != null) {
418
+ connection.onAnswer()
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) {
436
425
  Log.d(TAG, "coreCallAnswered: $callId, isLocalAnswer: $isLocalAnswer")
437
426
  val callInfo = activeCalls[callId]
438
427
  if (callInfo == null) {
439
- Log.w(TAG, "Cannot answer call $callId - not found in active calls")
428
+ Log.w(TAG, "Cannot process answered call $callId - not found in active calls")
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,11 @@ object CallEngine {
587
571
  }
588
572
 
589
573
  private fun setMutedInternal(callId: String, muted: Boolean) {
590
- val callInfo = activeCalls[callId]
591
- if (callInfo == null) {
592
- Log.w(TAG, "Cannot set mute state for call $callId - not found")
574
+ val connection = telecomConnections[callId] as? MyConnection ?: run {
575
+ Log.w(TAG, "Cannot set mute state for call $callId - no connection found")
593
576
  return
594
577
  }
595
- // The Connection's onCallAudioStateChanged will handle the event emission
596
- (telecomConnections[callId] as? MyConnection)?.setMuted(muted)
578
+ connection.setMuted(muted)
597
579
  }
598
580
 
599
581
  fun endCall(callId: String) {
@@ -675,7 +657,7 @@ object CallEngine {
675
657
  })
676
658
  }
677
659
 
678
- // ====== NEW TELECOM-DRIVEN AUDIO ROUTING SYSTEM ======
660
+ // ====== TELECOM-DRIVEN AUDIO ROUTING SYSTEM ======
679
661
 
680
662
  fun getAudioDevices(): AudioRoutesInfo {
681
663
  val context = requireContext()
@@ -726,35 +708,11 @@ object CallEngine {
726
708
  connection.requestAudioRouteChange(telecomRoute)
727
709
  }
728
710
 
729
- /**
730
- * Called by MyConnection when the audio route changes. This is the single
731
- * source of truth for route updates.
732
- */
733
711
  fun onTelecomAudioRouteChanged(callId: String, newRoute: String) {
734
712
  Log.d(TAG, "onTelecomAudioRouteChanged for callId $callId, new route: $newRoute")
735
713
  emitAudioRouteChanged()
736
714
  }
737
715
 
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
716
  private fun emitAudioRouteChanged() {
759
717
  val info = getAudioDevices()
760
718
  val deviceStrings = info.devices.map { it.value }
@@ -813,12 +771,11 @@ object CallEngine {
813
771
  })
814
772
 
815
773
  // Only remove metadata if there's NO existing active call with this ID
816
- val existingCall = activeCalls[callId]
817
- if (existingCall == null) {
818
- callMetadata.remove(callId)
819
- Log.d(TAG, "Removed metadata for rejected call $callId (no existing call)")
774
+ if (!activeCalls.containsKey(callId)) {
775
+ callMetadata.remove(callId)
776
+ Log.d(TAG, "Removed metadata for rejected call $callId (no existing call)")
820
777
  } else {
821
- Log.d(TAG, "Kept metadata for callId: $callId (existing call: ${existingCall.state})")
778
+ Log.d(TAG, "Kept metadata for callId: $callId (an existing call is using it)")
822
779
  }
823
780
  }
824
781
 
@@ -836,7 +793,6 @@ object CallEngine {
836
793
  enableVibration(true)
837
794
  setBypassDnd(true)
838
795
  lockscreenVisibility = Notification.VISIBILITY_PUBLIC
839
- // Sound is handled by the RingtoneManager directly for better control
840
796
  setSound(null, null)
841
797
  }
842
798
  val manager = context.getSystemService(NotificationManager::class.java)
@@ -1141,7 +1097,6 @@ object CallEngine {
1141
1097
  Log.d(TAG, "Performing cleanup")
1142
1098
  stopForegroundService()
1143
1099
  keepScreenAwake(false)
1144
- // Reset audio mode via AudioManager when all calls are truly gone
1145
1100
  audioManager?.mode = AudioManager.MODE_NORMAL
1146
1101
  }
1147
1102
 
@@ -4,10 +4,12 @@ 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
10
11
  import android.util.Log
12
+ import androidx.annotation.RequiresApi
11
13
  import java.util.concurrent.Executors
12
14
  import org.json.JSONObject
13
15
 
@@ -22,13 +24,11 @@ class MyConnection(
22
24
  const val TAG = "MyConnection"
23
25
  }
24
26
 
25
- private var lastAudioState: CallAudioState? = null
26
27
  private val telecomExecutor = Executors.newSingleThreadExecutor()
27
28
 
28
29
  init {
29
30
  connectionProperties = PROPERTY_SELF_MANAGED
30
31
  connectionCapabilities = CAPABILITY_SUPPORT_HOLD or CAPABILITY_MUTE or CAPABILITY_HOLD
31
-
32
32
  audioModeIsVoip = true
33
33
 
34
34
  if (callType == "Video") {
@@ -43,6 +43,8 @@ class MyConnection(
43
43
  Log.d(TAG, "MyConnection for callId $callId created and added to CallEngine.")
44
44
  }
45
45
 
46
+ // --- Lifecycle Callbacks ---
47
+
46
48
  override fun onSilence() {
47
49
  super.onSilence()
48
50
  Log.d(TAG, "onSilence called by system for callId: $callId. Silencing ringtone.")
@@ -50,20 +52,22 @@ class MyConnection(
50
52
  }
51
53
 
52
54
  override fun onAnswer() {
53
- Log.d(TAG, "Call answered via Telecom for callId: $callId")
54
- setActive()
55
- CallEngine.answerCall(callId)
55
+ super.onAnswer()
56
+ Log.d(TAG, "onAnswer called for callId: $callId. Setting connection to active.")
57
+ this.setActive()
56
58
  }
57
59
 
58
60
  override fun onReject() {
59
- Log.d(TAG, "Call rejected via Telecom for callId: $callId")
61
+ super.onReject()
62
+ Log.d(TAG, "onReject called for callId: $callId")
60
63
  setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
61
64
  destroy()
62
65
  CallEngine.endCall(callId)
63
66
  }
64
67
 
65
68
  override fun onDisconnect() {
66
- Log.d(TAG, "Call disconnected via Telecom for callId: $callId")
69
+ super.onDisconnect()
70
+ Log.d(TAG, "onDisconnect called for callId: $callId")
67
71
  setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
68
72
  destroy()
69
73
  CallEngine.endCall(callId)
@@ -71,35 +75,17 @@ class MyConnection(
71
75
 
72
76
  override fun onHold() {
73
77
  super.onHold()
74
- Log.d(TAG, "Call held via Telecom for callId: $callId")
78
+ Log.d(TAG, "onHold called for callId: $callId")
75
79
  CallEngine.holdCall(callId)
76
80
  }
77
81
 
78
82
  override fun onUnhold() {
79
83
  super.onUnhold()
80
- Log.d(TAG, "Call unheld via Telecom for callId: $callId")
84
+ Log.d(TAG, "onUnhold called for callId: $callId")
81
85
  CallEngine.unholdCall(callId)
82
86
  }
83
87
 
84
- override fun onCallAudioStateChanged(state: CallAudioState) {
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
- }
88
+ // --- State Change Callbacks ---
103
89
 
104
90
  override fun onStateChanged(state: Int) {
105
91
  super.onStateChanged(state)
@@ -107,12 +93,15 @@ class MyConnection(
107
93
 
108
94
  when (state) {
109
95
  STATE_ACTIVE -> {
110
- // When the call becomes active, set the initial audio route.
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)
114
- // If this was an immediate-start call, trigger the answered event.
115
- CallEngine.coreCallAnswered(callId, isLocalAnswer = false)
96
+ Log.d(TAG, "Connection is now active for callId: $callId.")
97
+ // This is the single source of truth for a call becoming active.
98
+ // We check the state in CallEngine to determine if it was an incoming
99
+ // or outgoing call that was just answered.
100
+ val callInfo = CallEngine.getActiveCalls().find { it.callId == callId }
101
+ if (callInfo != null && callInfo.state != CallState.ACTIVE) {
102
+ val isLocalAnswer = callInfo.state == CallState.INCOMING
103
+ CallEngine.coreCallAnswered(callId, isLocalAnswer = isLocalAnswer)
104
+ }
116
105
  }
117
106
  STATE_DISCONNECTED -> {
118
107
  Log.d(TAG, "Connection is now disconnected for callId: $callId")
@@ -121,23 +110,24 @@ class MyConnection(
121
110
  }
122
111
  }
123
112
 
113
+ override fun onCallAudioStateChanged(state: CallAudioState) {
114
+ super.onCallAudioStateChanged(state)
115
+ Log.d(TAG, "onCallAudioStateChanged for callId: $callId. muted=${state.isMuted}, route=${routeToString(state.route)}")
116
+
117
+ // Forward route changes to our engine/UI.
118
+ CallEngine.onTelecomAudioRouteChanged(callId, routeToString(state.route))
119
+
120
+ // Emit mute/unmute events based on state changes from the system.
121
+ val eventType = if (state.isMuted) CallEventType.CALL_MUTED else CallEventType.CALL_UNMUTED
122
+ CallEngine.emitEvent(eventType, JSONObject().put("callId", callId))
123
+ }
124
+
125
+ // --- Audio Control Methods (Called from CallEngine) ---
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
- val endpoint = availableCallEndpoints.find { it.endpointType == route }
128
- if (endpoint != null) {
129
- Log.d(TAG, "Using requestCallEndpointChange for API 34+")
130
- requestCallEndpointChange(endpoint, telecomExecutor, object : OutcomeReceiver<Void, Exception> {
131
- override fun onResult(result: Void?) {
132
- Log.d(TAG, "Successfully changed audio endpoint to ${endpoint.endpointName}")
133
- }
134
- override fun onError(error: Exception) {
135
- Log.e(TAG, "Failed to change audio endpoint", error)
136
- }
137
- })
138
- } else {
139
- Log.w(TAG, "Could not find a matching CallEndpoint for route: ${routeToString(route)}")
140
- }
130
+ requestAudioEndpointChangeInternal(route)
141
131
  } else {
142
132
  Log.d(TAG, "Using setAudioRoute (deprecated) for older API")
143
133
  @Suppress("DEPRECATION")
@@ -145,17 +135,38 @@ class MyConnection(
145
135
  }
146
136
  }
147
137
 
148
- fun isBluetoothAvailable(): Boolean {
149
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
150
- this.callAudioState?.supportedRouteMask?.and(CallAudioState.ROUTE_BLUETOETOOTH) != 0
138
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
139
+ private fun requestAudioEndpointChangeInternal(route: Int) {
140
+ val endpoint = this.callAudioState?.availableCallEndpoints?.find { it.type == route }
141
+ if (endpoint != null) {
142
+ Log.d(TAG, "Using requestCallEndpointChange for API 34+")
143
+ requestCallEndpointChange(endpoint, telecomExecutor, object : OutcomeReceiver<Void, CallEndpointException> {
144
+ override fun onResult(result: Void?) {
145
+ Log.d(TAG, "Successfully requested audio endpoint change to ${endpoint.name}")
146
+ }
147
+ override fun onError(error: CallEndpointException) {
148
+ Log.e(TAG, "Failed to change audio endpoint", error)
149
+ }
150
+ })
151
151
  } else {
152
- // Fallback for older versions
153
- this.callAudioState?.supportedRouteMask?.and(CallAudioState.ROUTE_BLUETOOTH) != 0
152
+ Log.w(TAG, "Could not find a matching CallEndpoint for route: ${routeToString(route)}")
154
153
  }
155
154
  }
156
155
 
156
+ fun setMuted(muted: Boolean) {
157
+ val currentState = this.callAudioState ?: return
158
+ if (currentState.isMuted == muted) return
159
+
160
+ // This informs the system of the change. The system will then call
161
+ // onCallAudioStateChanged back on this connection with the new state.
162
+ onCallAudioStateChanged(CallAudioState(muted, currentState.route, currentState.supportedRouteMask))
163
+ Log.d(TAG, "Informed Telecom of mute state change to: $muted")
164
+ }
165
+
166
+ // --- Helper Functions ---
167
+
157
168
  fun getCurrentRouteString(): String {
158
- return lastAudioState?.route.let { routeToString(it) }
169
+ return this.callAudioState?.route.let { routeToString(it) }
159
170
  }
160
171
 
161
172
  private fun routeToString(route: Int?): String {
@@ -167,4 +178,18 @@ class MyConnection(
167
178
  else -> "Unknown"
168
179
  }
169
180
  }
181
+
182
+ private fun stateToString(state: Int): String {
183
+ return when (state) {
184
+ STATE_INITIALIZING -> "INITIALIZING"
185
+ STATE_NEW -> "NEW"
186
+ STATE_RINGING -> "RINGING"
187
+ STATE_DIALING -> "DIALING"
188
+ STATE_ACTIVE -> "ACTIVE"
189
+ STATE_HOLDING -> "HOLDING"
190
+ STATE_DISCONNECTED -> "DISCONNECTED"
191
+ STATE_PULLING_CALL -> "PULLING_CALL"
192
+ else -> "UNKNOWN"
193
+ }
194
+ }
170
195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qusaieilouti99/call-manager",
3
- "version": "0.1.166",
3
+ "version": "0.1.168",
4
4
  "description": "Call manager",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",