@qusaieilouti99/call-manager 0.1.178 → 0.1.180

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.
@@ -9,7 +9,6 @@ import android.content.ComponentName
9
9
  import android.content.Context
10
10
  import android.content.Intent
11
11
  import android.graphics.Color
12
- import android.media.AudioAttributes
13
12
  import android.media.AudioManager
14
13
  import android.media.MediaPlayer
15
14
  import android.media.RingtoneManager
@@ -22,7 +21,6 @@ import android.os.PowerManager
22
21
  import android.os.ParcelUuid
23
22
  import android.os.VibrationEffect
24
23
  import android.os.Vibrator
25
- import android.telecom.CallAudioState
26
24
  import android.telecom.CallEndpoint
27
25
  import android.telecom.Connection
28
26
  import android.telecom.DisconnectCause
@@ -40,7 +38,7 @@ import android.app.KeyguardManager
40
38
  import java.util.UUID
41
39
 
42
40
  /**
43
- * Core callmanagement engine. Manages self-managed telecom calls,
41
+ * Core call-management engine. Manages self-managed telecom calls,
44
42
  * audio routing, UI notifications, etc.
45
43
  *
46
44
  * Audio routing now primarily leverages Android Telecom's CallEndpoint API (API 34+),
@@ -88,7 +86,8 @@ object CallEngine {
88
86
  private var eventHandler: ((CallEventType, String) -> Unit)? = null
89
87
  private val cachedEvents = mutableListOf<Pair<CallEventType, String>>()
90
88
 
91
- // Audio routing state for CallEndpoint API (API 34+)
89
+ // Audio routing state for CallEndpoint API (API 34+).
90
+ // These variables track the system's reported audio state via Telecom.
92
91
  private var currentActiveCallEndpoint: CallEndpoint? = null
93
92
  private var availableCallEndpoints: List<CallEndpoint> = emptyList()
94
93
  private var wasManuallySetAudioRoute: Boolean = false
@@ -98,6 +97,11 @@ object CallEngine {
98
97
  fun onLockScreenBypassChanged(shouldBypass: Boolean)
99
98
  }
100
99
 
100
+ /**
101
+ * Initializes the CallEngine with the application context.
102
+ * This should be called once in your Application's onCreate method.
103
+ * Subsequent calls will be ignored.
104
+ */
101
105
  fun initialize(context: Context) {
102
106
  synchronized(initializationLock) {
103
107
  if (isInitialized.compareAndSet(false, true)) {
@@ -105,7 +109,7 @@ object CallEngine {
105
109
  audioManager = appContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
106
110
  Log.d(TAG, "CallEngine initialized successfully")
107
111
  if (isCallActive()) {
108
- startForegroundService()
112
+ startForegroundService() // Start foreground service if calls are already active (e.g., app resumed)
109
113
  }
110
114
  }
111
115
  }
@@ -113,6 +117,9 @@ object CallEngine {
113
117
 
114
118
  fun isInitialized(): Boolean = isInitialized.get()
115
119
 
120
+ /**
121
+ * Returns the application context. Throws IllegalStateException if not initialized.
122
+ */
116
123
  private fun requireContext(): Context {
117
124
  return appContext ?: throw IllegalStateException(
118
125
  "CallEngine not initialized. Call initialize() in Application.onCreate()"
@@ -121,6 +128,10 @@ object CallEngine {
121
128
 
122
129
  fun getContext(): Context? = appContext
123
130
 
131
+ /**
132
+ * Sets the event handler for emitting native call events to the JavaScript layer.
133
+ * If there are cached events, they will be emitted immediately.
134
+ */
124
135
  fun setEventHandler(handler: ((CallEventType, String) -> Unit)?) {
125
136
  Log.d(TAG, "setEventHandler called. Handler present: ${handler != null}")
126
137
  eventHandler = handler
@@ -133,6 +144,10 @@ object CallEngine {
133
144
  }
134
145
  }
135
146
 
147
+ /**
148
+ * Emits a call event with a JSON payload to the registered event handler.
149
+ * If no handler is registered, the event is cached.
150
+ */
136
151
  fun emitEvent(type: CallEventType, data: JSONObject) {
137
152
  Log.d(TAG, "Emitting event: $type")
138
153
  val dataString = data.toString()
@@ -144,6 +159,32 @@ object CallEngine {
144
159
  }
145
160
  }
146
161
 
162
+ /**
163
+ * Helper function to emit call events that include call metadata.
164
+ */
165
+ private fun emitCallEventWithMetadata(eventType: CallEventType, callId: String) {
166
+ val callInfo = activeCalls[callId] ?: return
167
+ val metadata = callMetadata[callId]
168
+
169
+ emitEvent(eventType, JSONObject().apply {
170
+ put("callId", callId)
171
+ put("callType", callInfo.callType)
172
+ put("displayName", callInfo.displayName)
173
+ callInfo.pictureUrl?.let { put("pictureUrl", it) }
174
+ metadata?.let {
175
+ try {
176
+ put("metadata", JSONObject(it))
177
+ } catch (e: Exception) {
178
+ put("metadata", it)
179
+ }
180
+ }
181
+ })
182
+ }
183
+
184
+ /**
185
+ * Checks if the device and Android version (API 31+) support CallStyle notifications.
186
+ * This is typically based on manufacturer and brand heuristics for better compatibility.
187
+ */
147
188
  private fun supportsCallStyleNotifications(): Boolean {
148
189
  // CallStyle notifications are available from Android S (API 31)
149
190
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
@@ -168,6 +209,10 @@ object CallEngine {
168
209
  return isSupported
169
210
  }
170
211
 
212
+ /**
213
+ * Stops the currently playing incoming call ringtone.
214
+ * Called by [MyConnection.onSilence] when Telecom requests it.
215
+ */
171
216
  fun silenceIncomingCall() {
172
217
  Log.d(TAG, "Silencing incoming call ringtone via Connection.onSilence()")
173
218
  stopRingtone()
@@ -181,6 +226,10 @@ object CallEngine {
181
226
  lockScreenBypassCallbacks.remove(callback)
182
227
  }
183
228
 
229
+ /**
230
+ * Updates the lock screen bypass state and notifies registered callbacks.
231
+ * This helps manage fullscreen incoming call UI behavior.
232
+ */
184
233
  private fun updateLockScreenBypass() {
185
234
  val shouldBypass = isCallActive()
186
235
  if (lockScreenBypassActive != shouldBypass) {
@@ -198,11 +247,17 @@ object CallEngine {
198
247
 
199
248
  fun isLockScreenBypassActive(): Boolean = lockScreenBypassActive
200
249
 
250
+ /**
251
+ * Adds a Telecom [Connection] object to internal map for tracking.
252
+ */
201
253
  fun addTelecomConnection(callId: String, connection: Connection) {
202
254
  telecomConnections[callId] = connection
203
255
  Log.d(TAG, "Added Telecom Connection for callId: $callId")
204
256
  }
205
257
 
258
+ /**
259
+ * Removes a Telecom [Connection] object from internal map.
260
+ */
206
261
  fun removeTelecomConnection(callId: String) {
207
262
  telecomConnections.remove(callId)
208
263
  Log.d(TAG, "Removed Telecom Connection for callId: $callId")
@@ -210,11 +265,17 @@ object CallEngine {
210
265
 
211
266
  fun getTelecomConnection(callId: String): Connection? = telecomConnections[callId]
212
267
 
268
+ /**
269
+ * Sets whether the app can handle multiple concurrent calls.
270
+ */
213
271
  fun setCanMakeMultipleCalls(allow: Boolean) {
214
272
  canMakeMultipleCalls = allow
215
273
  Log.d(TAG, "canMakeMultipleCalls set to: $allow")
216
274
  }
217
275
 
276
+ /**
277
+ * Returns the current state of all active calls as a JSON string.
278
+ */
218
279
  fun getCurrentCallState(): String {
219
280
  val calls = getActiveCalls()
220
281
  val jsonArray = JSONArray()
@@ -224,6 +285,10 @@ object CallEngine {
224
285
  return jsonArray.toString()
225
286
  }
226
287
 
288
+ /**
289
+ * Reports a new incoming call to the Android Telecom framework.
290
+ * This initiates the incoming call UI (notification/activity overlay) and ringing.
291
+ */
227
292
  fun reportIncomingCall(
228
293
  context: Context,
229
294
  callId: String,
@@ -255,7 +320,7 @@ object CallEngine {
255
320
  return
256
321
  }
257
322
 
258
- val isVideoCall = callType == "Video"
323
+ // If multiple calls are not allowed and there's an active call, hold it.
259
324
  if (!canMakeMultipleCalls && activeCalls.isNotEmpty()) {
260
325
  activeCalls.values.forEach {
261
326
  if (it.state == CallState.ACTIVE) {
@@ -264,24 +329,24 @@ object CallEngine {
264
329
  }
265
330
  }
266
331
 
267
- activeCalls[callId] =
268
- CallInfo(callId, callType, displayName, pictureUrl, CallState.INCOMING)
332
+ activeCalls[callId] = CallInfo(callId, callType, displayName, pictureUrl, CallState.INCOMING)
269
333
  currentCallId = callId
270
334
  Log.d(TAG, "Call $callId added to activeCalls. State: INCOMING")
271
335
 
272
336
  showIncomingCallUI(callId, displayName, callType, pictureUrl)
273
337
  registerPhoneAccount()
274
338
 
275
- val telecomManager =
276
- requireContext().getSystemService(Context.TELECOM_SERVICE) as TelecomManager
339
+ val telecomManager = requireContext().getSystemService(Context.TELECOM_SERVICE) as TelecomManager
277
340
  val phoneAccountHandle = getPhoneAccountHandle()
341
+ val isVideoCall = callType == "Video"
342
+
278
343
  val extras = Bundle().apply {
279
344
  putString(MyConnectionService.EXTRA_CALL_ID, callId)
280
345
  putString(MyConnectionService.EXTRA_CALL_TYPE, callType)
281
346
  putString(MyConnectionService.EXTRA_DISPLAY_NAME, displayName)
282
347
  putBoolean(MyConnectionService.EXTRA_IS_VIDEO_CALL_BOOLEAN, isVideoCall)
283
348
  pictureUrl?.let { putString(MyConnectionService.EXTRA_PICTURE_URL, it) }
284
- // TelecomManager.EXTRA_INCOMING_VIDEO_STATE is used here to hint the video state to Telecom
349
+ // Hint the video state to Telecom.
285
350
  putInt(TelecomManager.EXTRA_INCOMING_VIDEO_STATE, if (isVideoCall) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY)
286
351
  }
287
352
 
@@ -297,6 +362,10 @@ object CallEngine {
297
362
  updateLockScreenBypass()
298
363
  }
299
364
 
365
+ /**
366
+ * Initiates a new outgoing call via the Android Telecom framework.
367
+ * This will lead to [MyConnectionService.onCreateOutgoingConnection].
368
+ */
300
369
  fun startOutgoingCall(
301
370
  callId: String,
302
371
  callType: String,
@@ -316,6 +385,7 @@ object CallEngine {
316
385
  return
317
386
  }
318
387
 
388
+ // If multiple calls are not allowed and there's an active call, hold it.
319
389
  val isVideoCall = callType == "Video"
320
390
  if (!canMakeMultipleCalls && activeCalls.isNotEmpty()) {
321
391
  activeCalls.values.forEach {
@@ -329,10 +399,9 @@ object CallEngine {
329
399
  currentCallId = callId
330
400
  Log.d(TAG, "Call $callId added to activeCalls. State: DIALING")
331
401
 
332
- registerPhoneAccount()
402
+ registerPhoneAccount() // Register phone account before placing call
333
403
 
334
- val telecomManager =
335
- context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
404
+ val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
336
405
  val phoneAccountHandle = getPhoneAccountHandle()
337
406
  val addressUri = Uri.fromParts(PhoneAccount.SCHEME_TEL, targetName, null)
338
407
 
@@ -366,6 +435,11 @@ object CallEngine {
366
435
  updateLockScreenBypass()
367
436
  }
368
437
 
438
+ /**
439
+ * Handles two scenarios:
440
+ * 1. If `callId` corresponds to an existing INCOMING call, it answers that call.
441
+ * 2. Otherwise, it initiates a new outgoing call and immediately transitions it to ACTIVE.
442
+ */
369
443
  fun startCall(
370
444
  callId: String,
371
445
  callType: String,
@@ -379,84 +453,95 @@ object CallEngine {
379
453
  if (existingCallInfo != null && existingCallInfo.state == CallState.INCOMING) {
380
454
  // Scenario 1: Call with this ID is already incoming, answer it.
381
455
  Log.d(TAG, "Call $callId is incoming, answering it directly via startCall.")
382
- answerCall(callId)
383
- } else {
384
- // Scenario 2: Call is new or not incoming, treat as new outgoing call that is immediately active.
385
- Log.d(TAG, "Call $callId is new or not incoming. Initiating as outgoing and immediately active.")
386
- if (!canMakeMultipleCalls && activeCalls.isNotEmpty()) { // Only check if multiple calls are not allowed
387
- if (!validateOutgoingCallRequest()) {
388
- Log.w(TAG, "Rejecting startCall as outgoing - incoming/active call exists")
389
- emitEvent(CallEventType.CALL_REJECTED, JSONObject().apply {
390
- put("callId", callId)
391
- put("reason", "Cannot start new active call while incoming or active call exists")
392
- })
393
- return
394
- }
456
+ answerCall(callId) // Call answerCall, which will internally call coreCallAnswered
457
+ return // Important: Exit here to prevent initiating a new outgoing call.
458
+ }
459
+
460
+ // Scenario 2: Call is new or not incoming. Treat as a new outgoing call that should be
461
+ // immediately active. This involves placing a Telecom call and then marking it answered.
462
+ Log.d(TAG, "Call $callId is new or not incoming. Initiating as outgoing and immediately active.")
463
+ if (!canMakeMultipleCalls && activeCalls.isNotEmpty()) {
464
+ if (!validateOutgoingCallRequest()) {
465
+ Log.w(TAG, "Rejecting startCall as new outgoing - incoming/active call exists and multi-call is not allowed")
466
+ emitEvent(CallEventType.CALL_REJECTED, JSONObject().apply {
467
+ put("callId", callId)
468
+ put("reason", "Cannot start new active call while incoming or active call exists")
469
+ })
470
+ return
395
471
  }
472
+ }
396
473
 
397
- val isVideoCall = callType == "Video"
398
- if (!canMakeMultipleCalls && activeCalls.isNotEmpty()) {
399
- activeCalls.values.forEach {
400
- if (it.state == CallState.ACTIVE) {
401
- holdCallInternal(it.callId, heldBySystem = false)
402
- }
474
+ val isVideoCall = callType == "Video"
475
+ if (!canMakeMultipleCalls && activeCalls.isNotEmpty()) {
476
+ activeCalls.values.forEach {
477
+ if (it.state == CallState.ACTIVE) {
478
+ holdCallInternal(it.callId, heldBySystem = false)
403
479
  }
404
480
  }
481
+ }
405
482
 
406
- activeCalls[callId] = CallInfo(callId, callType, targetName, null, CallState.DIALING) // Temporarily DIALING for Telecom
407
- currentCallId = callId
408
- Log.d(TAG, "Call $callId added to activeCalls. Initial state: DIALING (for Telecom)")
483
+ // Temporarily set state to DIALING for Telecom to process it as an outgoing call.
484
+ activeCalls[callId] = CallInfo(callId, callType, targetName, null, CallState.DIALING)
485
+ currentCallId = callId
486
+ Log.d(TAG, "Call $callId added to activeCalls. Initial state: DIALING (for Telecom)")
409
487
 
488
+ registerPhoneAccount()
410
489
 
411
- registerPhoneAccount()
490
+ val telecomManager = requireContext().getSystemService(Context.TELECOM_SERVICE) as TelecomManager
491
+ val phoneAccountHandle = getPhoneAccountHandle()
492
+ val addressUri = Uri.fromParts(PhoneAccount.SCHEME_TEL, targetName, null)
412
493
 
413
- val telecomManager = requireContext().getSystemService(Context.TELECOM_SERVICE) as TelecomManager
414
- val phoneAccountHandle = getPhoneAccountHandle()
415
- val addressUri = Uri.fromParts(PhoneAccount.SCHEME_TEL, targetName, null)
494
+ val outgoingExtrasForConnectionService = Bundle().apply {
495
+ putString(MyConnectionService.EXTRA_CALL_ID, callId)
496
+ putString(MyConnectionService.EXTRA_CALL_TYPE, callType)
497
+ putString(MyConnectionService.EXTRA_DISPLAY_NAME, targetName)
498
+ putBoolean(MyConnectionService.EXTRA_IS_VIDEO_CALL_BOOLEAN, isVideoCall)
499
+ metadata?.let { putString("metadata", it) }
500
+ }
416
501
 
417
- val outgoingExtrasForConnectionService = Bundle().apply {
418
- putString(MyConnectionService.EXTRA_CALL_ID, callId)
419
- putString(MyConnectionService.EXTRA_CALL_TYPE, callType)
420
- putString(MyConnectionService.EXTRA_DISPLAY_NAME, targetName)
421
- putBoolean(MyConnectionService.EXTRA_IS_VIDEO_CALL_BOOLEAN, isVideoCall)
422
- metadata?.let { putString("metadata", it) }
423
- }
502
+ val placeCallExtras = Bundle().apply {
503
+ putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccountHandle)
504
+ putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingExtrasForConnectionService)
505
+ putBoolean(TelecomManager.EXTRA_START_CALL_WITH_SPEAKERPHONE, isVideoCall) // Hint for video calls
506
+ putInt(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, if (isVideoCall) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY)
507
+ }
424
508
 
425
- val placeCallExtras = Bundle().apply {
426
- putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccountHandle)
427
- putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingExtrasForConnectionService)
428
- putBoolean(TelecomManager.EXTRA_START_CALL_WITH_SPEAKERPHONE, isVideoCall) // Hint for video calls
429
- putInt(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, if (isVideoCall) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY)
430
- }
509
+ try {
510
+ telecomManager.placeCall(addressUri, placeCallExtras)
511
+ startForegroundService()
512
+ bringAppToForeground()
513
+ keepScreenAwake(true)
514
+ Log.d(TAG, "Successfully reported outgoing call (to be immediately active) to TelecomManager for $callId")
431
515
 
432
- try {
433
- telecomManager.placeCall(addressUri, placeCallExtras)
434
- startForegroundService()
435
- bringAppToForeground()
436
- keepScreenAwake(true)
437
- Log.d(TAG, "Successfully reported outgoing call (to be immediately active) to TelecomManager for $callId")
438
-
439
- // Immediately mark as answered for "startCall" behavior
440
- coreCallAnswered(callId, isLocalAnswer = false) // isLocalAnswer = false as it's not a direct answer action from the user on an incoming call.
441
- } catch (e: Exception) {
442
- Log.e(TAG, "Failed to start call as active: ${e.message}", e)
443
- endCallInternal(callId)
444
- }
516
+ // Immediately mark as answered for "startCall" behavior, simulating an answered outgoing call.
517
+ coreCallAnswered(callId, isLocalAnswer = false)
518
+ } catch (e: Exception) {
519
+ Log.e(TAG, "Failed to start call as active: ${e.message}", e)
520
+ endCallInternal(callId)
445
521
  }
446
-
447
522
  updateLockScreenBypass()
448
523
  }
449
524
 
525
+ /**
526
+ * Called from JavaScript layer when the remote party answers an outgoing call.
527
+ */
450
528
  fun callAnsweredFromJS(callId: String) {
451
529
  Log.d(TAG, "callAnsweredFromJS: $callId - remote party answered")
452
530
  coreCallAnswered(callId, isLocalAnswer = false)
453
531
  }
454
532
 
533
+ /**
534
+ * Called when the local user answers an incoming call.
535
+ */
455
536
  fun answerCall(callId: String) {
456
537
  Log.d(TAG, "answerCall: $callId - local party answering")
457
538
  coreCallAnswered(callId, isLocalAnswer = true)
458
539
  }
459
540
 
541
+ /**
542
+ * Core logic for transitioning a call to the ACTIVE state, whether it's an incoming call
543
+ * being answered locally, or an outgoing call being acknowledged as answered by the remote side.
544
+ */
460
545
  private fun coreCallAnswered(callId: String, isLocalAnswer: Boolean) {
461
546
  Log.d(TAG, "coreCallAnswered: $callId, isLocalAnswer: $isLocalAnswer")
462
547
  val callInfo = activeCalls[callId]
@@ -467,14 +552,15 @@ object CallEngine {
467
552
 
468
553
  activeCalls[callId] = callInfo.copy(state = CallState.ACTIVE)
469
554
  currentCallId = callId
470
- callStartTime = System.currentTimeMillis()
471
- wasManuallySetAudioRoute = false
555
+ callStartTime = System.currentTimeMillis() // Record call start time
556
+ wasManuallySetAudioRoute = false // Reset manual audio route flag
472
557
  Log.d(TAG, "Call $callId set to ACTIVE state")
473
558
 
474
559
  stopRingtone()
475
560
  stopRingback()
476
- cancelIncomingCallUI()
561
+ cancelIncomingCallUI() // Clear any incoming call UI/notification
477
562
 
563
+ // If multiple calls are not allowed, hold any other active calls.
478
564
  if (!canMakeMultipleCalls) {
479
565
  activeCalls.filter { it.key != callId }.values.forEach { otherCall ->
480
566
  if (otherCall.state == CallState.ACTIVE) {
@@ -488,62 +574,28 @@ object CallEngine {
488
574
  keepScreenAwake(true)
489
575
  updateLockScreenBypass()
490
576
 
491
- setAudioMode() // Ensure audio mode is correct
577
+ setAudioMode() // Set audio mode to MODE_IN_COMMUNICATION for an active call.
492
578
 
493
- // Set initial audio route using Telecom's CallEndpoint API
579
+ // Set initial audio route using Telecom's CallEndpoint API, with a slight delay.
494
580
  setInitialCallAudioRoute(callId, callInfo.callType)
495
581
 
582
+ // Emit the appropriate event back to JS.
496
583
  if (isLocalAnswer) {
497
- emitCallAnsweredWithMetadata(callId)
584
+ emitCallEventWithMetadata(CallEventType.CALL_ANSWERED, callId)
498
585
  } else {
499
- emitOutgoingCallAnsweredWithMetadata(callId)
586
+ emitCallEventWithMetadata(CallEventType.OUTGOING_CALL_ANSWERED, callId)
500
587
  }
501
588
 
502
589
  Log.d(TAG, "Call $callId successfully answered")
503
590
  }
504
591
 
505
- private fun emitCallAnsweredWithMetadata(callId: String) {
506
- val callInfo = activeCalls[callId] ?: return
507
- val metadata = callMetadata[callId]
508
-
509
- emitEvent(CallEventType.CALL_ANSWERED, JSONObject().apply {
510
- put("callId", callId)
511
- put("callType", callInfo.callType)
512
- put("displayName", callInfo.displayName)
513
- callInfo.pictureUrl?.let { put("pictureUrl", it) }
514
- metadata?.let {
515
- try {
516
- put("metadata", JSONObject(it))
517
- } catch (e: Exception) {
518
- put("metadata", it)
519
- }
520
- }
521
- })
522
- }
523
-
524
- private fun emitOutgoingCallAnsweredWithMetadata(callId: String) {
525
- val callInfo = activeCalls[callId] ?: return
526
- val metadata = callMetadata[callId]
527
-
528
- emitEvent(CallEventType.OUTGOING_CALL_ANSWERED, JSONObject().apply {
529
- put("callId", callId)
530
- put("callType", callInfo.callType)
531
- put("displayName", callInfo.displayName)
532
- callInfo.pictureUrl?.let { put("pictureUrl", it) }
533
- metadata?.let {
534
- try {
535
- put("metadata", JSONObject(it))
536
- } catch (e: Exception) {
537
- put("metadata", it)
538
- }
539
- }
540
- })
541
- }
542
-
543
592
  fun holdCall(callId: String) {
544
593
  holdCallInternal(callId, heldBySystem = false)
545
594
  }
546
595
 
596
+ /**
597
+ * Sets a call to held or unheld state.
598
+ */
547
599
  fun setOnHold(callId: String, onHold: Boolean) {
548
600
  Log.d(TAG, "setOnHold: $callId, onHold: $onHold")
549
601
  val callInfo = activeCalls[callId]
@@ -559,6 +611,9 @@ object CallEngine {
559
611
  }
560
612
  }
561
613
 
614
+ /**
615
+ * Internal logic for putting a call on hold.
616
+ */
562
617
  private fun holdCallInternal(callId: String, heldBySystem: Boolean) {
563
618
  Log.d(TAG, "holdCallInternal: $callId, heldBySystem: $heldBySystem")
564
619
  val callInfo = activeCalls[callId]
@@ -572,7 +627,7 @@ object CallEngine {
572
627
  wasHeldBySystem = heldBySystem
573
628
  )
574
629
 
575
- telecomConnections[callId]?.setOnHold()
630
+ telecomConnections[callId]?.setOnHold() // Notify Telecom
576
631
  updateForegroundNotification()
577
632
  emitEvent(CallEventType.CALL_HELD, JSONObject().put("callId", callId))
578
633
  updateLockScreenBypass()
@@ -582,6 +637,9 @@ object CallEngine {
582
637
  unholdCallInternal(callId, resumedBySystem = false)
583
638
  }
584
639
 
640
+ /**
641
+ * Internal logic for unholding a call.
642
+ */
585
643
  private fun unholdCallInternal(callId: String, resumedBySystem: Boolean) {
586
644
  Log.d(TAG, "unholdCallInternal: $callId, resumedBySystem: $resumedBySystem")
587
645
  val callInfo = activeCalls[callId]
@@ -595,7 +653,7 @@ object CallEngine {
595
653
  wasHeldBySystem = false
596
654
  )
597
655
 
598
- telecomConnections[callId]?.setActive()
656
+ telecomConnections[callId]?.setActive() // Notify Telecom
599
657
  updateForegroundNotification()
600
658
  emitEvent(CallEventType.CALL_UNHELD, JSONObject().put("callId", callId))
601
659
  updateLockScreenBypass()
@@ -609,12 +667,17 @@ object CallEngine {
609
667
  setMutedInternal(callId, false)
610
668
  }
611
669
 
670
+ /**
671
+ * Sets the mute state for a call. This method is typically called by [MyConnection.onMuteStateChanged]
672
+ * when Telecom itself reports a mute state change. It then updates AudioManager and emits an event.
673
+ */
612
674
  fun setMuted(callId: String, muted: Boolean) {
613
675
  setMutedInternal(callId, muted)
614
676
  }
615
677
 
616
- // This method is now called by MyConnection.onMuteStateChanged which receives state from Telecom.
617
- // It applies the mute state to AudioManager and emits the event.
678
+ /**
679
+ * Internal logic to apply mute state to AudioManager and emit relevant events.
680
+ */
618
681
  private fun setMutedInternal(callId: String, muted: Boolean) {
619
682
  val callInfo = activeCalls[callId]
620
683
  if (callInfo == null) {
@@ -642,23 +705,31 @@ object CallEngine {
642
705
  endCallInternal(callId)
643
706
  }
644
707
 
708
+ /**
709
+ * Ends all currently active calls managed by the engine.
710
+ */
645
711
  fun endAllCalls() {
646
712
  Log.d(TAG, "endAllCalls: Ending all active calls")
647
713
  if (activeCalls.isEmpty()) return
648
714
 
715
+ // Create a copy of keys to avoid ConcurrentModificationException
649
716
  activeCalls.keys.toList().forEach { callId ->
650
717
  endCallInternal(callId)
651
718
  }
652
719
 
720
+ // Clear all tracking maps for a clean state
653
721
  activeCalls.clear()
654
722
  telecomConnections.clear()
655
723
  callMetadata.clear()
656
724
  currentCallId = null
657
725
 
658
- cleanup()
726
+ cleanup() // Perform final cleanup after all calls ended
659
727
  updateLockScreenBypass()
660
728
  }
661
729
 
730
+ /**
731
+ * Internal logic for ending a specific call.
732
+ */
662
733
  private fun endCallInternal(callId: String) {
663
734
  Log.d(TAG, "endCallInternal: $callId")
664
735
 
@@ -672,11 +743,11 @@ object CallEngine {
672
743
 
673
744
  stopRingback()
674
745
  stopRingtone()
675
- cancelIncomingCallUI()
746
+ cancelIncomingCallUI() // Clear notification/UI if this was the incoming call
676
747
 
748
+ // Update currentCallId if the ended call was the current one
677
749
  if (currentCallId == callId) {
678
- currentCallId =
679
- activeCalls.filter { it.value.state != CallState.ENDED }.keys.firstOrNull()
750
+ currentCallId = activeCalls.filter { it.value.state != CallState.ENDED }.keys.firstOrNull()
680
751
  }
681
752
 
682
753
  val context = requireContext()
@@ -691,13 +762,14 @@ object CallEngine {
691
762
  Log.w(TAG, "Failed to send close broadcast: ${e.message}")
692
763
  }
693
764
 
765
+ // Notify Telecom that the connection is disconnected and destroy it.
694
766
  telecomConnections[callId]?.let { connection ->
695
- // Disconnect and destroy the Telecom Connection
696
767
  connection.setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
697
768
  connection.destroy()
698
769
  removeTelecomConnection(callId)
699
770
  }
700
771
 
772
+ // Perform cleanup if no active calls remain, otherwise update foreground notification.
701
773
  if (activeCalls.isEmpty()) {
702
774
  cleanup()
703
775
  } else {
@@ -706,6 +778,7 @@ object CallEngine {
706
778
 
707
779
  updateLockScreenBypass()
708
780
 
781
+ // Notify registered listeners that the call has ended.
709
782
  for (listener in callEndListeners) {
710
783
  mainHandler.post {
711
784
  try {
@@ -716,36 +789,39 @@ object CallEngine {
716
789
  }
717
790
  }
718
791
 
719
- emitEvent(CallEventType.CALL_ENDED, JSONObject().apply {
720
- put("callId", callId)
721
- metadata?.let {
722
- try { put("metadata", JSONObject(it)) }
723
- catch (e: Exception) { put("metadata", it) }
724
- }
725
- })
792
+ emitCallEventWithMetadata(CallEventType.CALL_ENDED, callId)
726
793
  }
727
794
 
728
- // ====== IMPROVED AUDIO ROUTING SYSTEM (using CallEndpoint API) ======
795
+ // ====== AUDIO ROUTING SYSTEM using Telecom CallEndpoint API (API 34+) ======
729
796
 
730
- // Stores the currently available CallEndpoints as reported by Telecom
797
+ /**
798
+ * Called by [MyConnection] when Telecom reports a change in available audio endpoints.
799
+ * Updates internal state and notifies JS.
800
+ */
731
801
  fun onTelecomAvailableEndpointsChanged(endpoints: List<CallEndpoint>) {
732
802
  availableCallEndpoints = endpoints
733
803
  Log.d(TAG, "Available CallEndpoints updated: ${endpoints.map { "${it.endpointName}(${mapCallEndpointTypeToString(it.endpointType)})" }}")
734
- emitAudioDevicesChanged()
804
+ emitAudioDevicesChanged() // Emit event to JS with updated list
735
805
  }
736
806
 
737
- // Stores the active CallEndpoint as reported by Telecom
807
+ /**
808
+ * Called by [MyConnection] when Telecom reports a change in the active audio endpoint.
809
+ * Updates internal state and notifies JS.
810
+ */
738
811
  fun onTelecomAudioRouteChanged(callId: String, callEndpoint: CallEndpoint) {
739
812
  Log.d(TAG, "Telecom audio route changed for $callId: endpoint=${callEndpoint.endpointName} (type=${mapCallEndpointTypeToString(callEndpoint.endpointType)})")
740
813
  currentActiveCallEndpoint = callEndpoint
741
814
  emitAudioRouteChanged(mapCallEndpointTypeToString(callEndpoint.endpointType))
742
815
  }
743
816
 
817
+ /**
818
+ * Returns information about available audio devices and the current route.
819
+ */
744
820
  fun getAudioDevices(): AudioRoutesInfo {
821
+ // Collect unique device strings from available endpoints
745
822
  val devices = availableCallEndpoints.map { StringHolder(mapCallEndpointTypeToString(it.endpointType)) }.toMutableSet()
746
823
 
747
- // Add common fallback endpoints if Telecom doesn't explicitly list them,
748
- // although for active calls, Telecom should provide comprehensive lists.
824
+ // Add common fallback endpoints if Telecom doesn't explicitly list them in availableCallEndpoints.
749
825
  // This provides robustness for the JavaScript side's expected string values.
750
826
  if (!devices.any { it.value == "Earpiece" }) devices.add(StringHolder("Earpiece"))
751
827
  if (!devices.any { it.value == "Speaker" }) devices.add(StringHolder("Speaker"))
@@ -756,22 +832,27 @@ object CallEngine {
756
832
  return AudioRoutesInfo(devices.toTypedArray(), current)
757
833
  }
758
834
 
835
+ /**
836
+ * Requests Telecom to change the audio route to the specified type (e.g., "Speaker", "Earpiece").
837
+ * This marks the change as "manual" to prevent automatic overrides later.
838
+ */
759
839
  fun setAudioRoute(route: String) {
760
840
  Log.d(TAG, "setAudioRoute called: $route (manual)")
761
841
  wasManuallySetAudioRoute = true
762
842
 
763
843
  val telecomEndpointType = mapStringToCallEndpointType(route)
764
844
 
765
- // Find the actual CallEndpoint object from the available list that matches the type
845
+ // Find the actual CallEndpoint object from the available list that matches the requested type.
846
+ // If not found (e.g., for generic Earpiece/Speaker), create a generic CallEndpoint.
766
847
  val targetEndpoint = availableCallEndpoints.find { it.endpointType == telecomEndpointType }
767
- ?: getOrCreateGenericCallEndpoint(telecomEndpointType, route) // Fallback to create generic if not found in available list
848
+ ?: getOrCreateGenericCallEndpoint(telecomEndpointType, route)
768
849
 
769
850
  if (targetEndpoint != null) {
770
851
  currentCallId?.let { callId ->
771
852
  telecomConnections[callId]?.let { connection ->
772
853
  if (connection is MyConnection) {
773
854
  Log.d(TAG, "Requesting manual telecom audio route to: ${targetEndpoint.endpointName} (type: ${mapCallEndpointTypeToString(targetEndpoint.endpointType)})")
774
- connection.setTelecomAudioRoute(targetEndpoint)
855
+ connection.setTelecomAudioRoute(targetEndpoint) // Delegate to MyConnection to make the Telecom API call.
775
856
  } else {
776
857
  Log.w(TAG, "Telecom connection for $callId is not MyConnection instance.")
777
858
  }
@@ -780,9 +861,12 @@ object CallEngine {
780
861
  } else {
781
862
  Log.w(TAG, "Could not find or create a valid CallEndpoint for manual route: $route (type: $telecomEndpointType)")
782
863
  }
783
- }
864
+ }
784
865
 
785
- // This method is called by MyConnection when its state becomes ACTIVE, or after startCall/startOutgoingCall.
866
+ /**
867
+ * Sets the initial audio route for a call based on its type (video/audio) and connected devices.
868
+ * This is only performed if a manual route hasn't already been set.
869
+ */
786
870
  fun setInitialCallAudioRoute(callId: String, callType: String) {
787
871
  Log.d(TAG, "Setting initial audio route for callId: $callId, type: $callType")
788
872
 
@@ -795,16 +879,16 @@ object CallEngine {
795
879
  isBluetoothDeviceConnected() -> CallEndpoint.TYPE_BLUETOOTH
796
880
  isWiredHeadsetConnected() -> CallEndpoint.TYPE_WIRED_HEADSET
797
881
  callType.equals("Video", ignoreCase = true) -> CallEndpoint.TYPE_SPEAKER
798
- else -> CallEndpoint.TYPE_EARPIECE
882
+ else -> CallEndpoint.TYPE_EARPIECE // Default for audio calls
799
883
  }
800
884
 
801
- // We should ideally pick from `availableCallEndpoints`.
802
- // If none of that type are explicitly listed (e.g. for default Earpiece/Speaker),
803
- // we can try to create a generic one.
885
+ // Try to find the exact CallEndpoint from the available list.
886
+ // Fallback to creating a generic one if not explicitly listed (e.g., system Earpiece/Speaker).
804
887
  val targetEndpoint = availableCallEndpoints.find { it.endpointType == targetEndpointType }
805
888
  ?: getOrCreateGenericCallEndpoint(targetEndpointType, mapCallEndpointTypeToString(targetEndpointType))
806
889
 
807
890
  if (targetEndpoint != null) {
891
+ // Add a slight delay to allow Telecom to fully process connection activation before routing.
808
892
  mainHandler.postDelayed({
809
893
  telecomConnections[callId]?.let { connection ->
810
894
  if (connection is MyConnection) {
@@ -812,33 +896,44 @@ object CallEngine {
812
896
  connection.setTelecomAudioRoute(targetEndpoint)
813
897
  }
814
898
  } ?: Log.w(TAG, "No telecom connection found for $callId during initial route setting.")
815
- }, 500L) // Small delay to allow Telecom to process connection activation
899
+ }, 500L)
816
900
  } else {
817
901
  Log.w(TAG, "Could not find or create a valid CallEndpoint for initial route type: $targetEndpointType")
818
902
  }
819
903
  }
820
904
 
905
+ /**
906
+ * Sets the AudioManager's mode to MODE_IN_COMMUNICATION, which is optimal for voice calls.
907
+ * This is called when a call becomes active.
908
+ */
821
909
  private fun setAudioMode() {
822
910
  audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION
823
911
  Log.d(TAG, "Audio mode set to MODE_IN_COMMUNICATION")
824
912
  }
825
913
 
914
+ /**
915
+ * Resets AudioManager to normal mode when all calls have ended.
916
+ */
826
917
  private fun resetAudioMode() {
827
918
  if (activeCalls.isEmpty()) {
828
919
  audioManager?.let { am ->
829
920
  am.mode = AudioManager.MODE_NORMAL
921
+ // Explicitly stop Bluetooth SCO if active, for API 28+ devices (handled by CallEndpoint for 34+).
830
922
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && am.isBluetoothScoOn) {
831
923
  am.stopBluetoothSco()
832
924
  }
833
- am.isSpeakerphoneOn = false
925
+ am.isSpeakerphoneOn = false // Ensure speakerphone is off
834
926
  }
835
- currentActiveCallEndpoint = null // Reset active endpoint
927
+ currentActiveCallEndpoint = null // Reset active endpoint tracking
836
928
  availableCallEndpoints = emptyList() // Clear available endpoints
837
- wasManuallySetAudioRoute = false
929
+ wasManuallySetAudioRoute = false // Reset manual flag
838
930
  Log.d(TAG, "Audio mode reset to MODE_NORMAL, audio endpoints reset.")
839
931
  }
840
932
  }
841
933
 
934
+ /**
935
+ * Maps a [CallEndpoint.TYPE_*] integer to a human-readable string.
936
+ */
842
937
  private fun mapCallEndpointTypeToString(type: Int): String {
843
938
  return when (type) {
844
939
  CallEndpoint.TYPE_EARPIECE -> "Earpiece"
@@ -850,6 +945,9 @@ object CallEngine {
850
945
  }
851
946
  }
852
947
 
948
+ /**
949
+ * Maps a human-readable audio route string to a [CallEndpoint.TYPE_*] integer.
950
+ */
853
951
  private fun mapStringToCallEndpointType(typeString: String): Int {
854
952
  return when (typeString) {
855
953
  "Earpiece" -> CallEndpoint.TYPE_EARPIECE
@@ -861,9 +959,14 @@ object CallEngine {
861
959
  }
862
960
  }
863
961
 
962
+ /**
963
+ * Creates a generic [CallEndpoint] object for common audio routes if it's not found
964
+ * in the list reported by Telecom. This is a fallback for basic endpoints like Earpiece/Speaker
965
+ * that might implicitly exist but not always be explicitly listed as "available" by Telecom
966
+ * (especially on older API versions within the 28-33 range that don't have CallEndpoint).
967
+ * Note: The `ParcelUuid` must wrap a `java.util.UUID`.
968
+ */
864
969
  private fun getOrCreateGenericCallEndpoint(type: Int, name: String): CallEndpoint? {
865
- // This is a fallback to create a CallEndpoint if it's not explicitly in availableCallEndpoints.
866
- // Useful for basic types like Earpiece/Speaker that might implicitly exist.
867
970
  return when (type) {
868
971
  CallEndpoint.TYPE_EARPIECE -> CallEndpoint(name, type, ParcelUuid(UUID.nameUUIDFromBytes("Earpiece_Default".toByteArray())))
869
972
  CallEndpoint.TYPE_SPEAKER -> CallEndpoint(name, type, ParcelUuid(UUID.nameUUIDFromBytes("Speaker_Default".toByteArray())))
@@ -873,39 +976,41 @@ object CallEngine {
873
976
  }
874
977
  }
875
978
 
876
-
979
+ /**
980
+ * Checks if a wired headset (including USB headsets) is currently connected.
981
+ * This uses modern API methods suitable for SDK 28+.
982
+ */
877
983
  private fun isWiredHeadsetConnected(): Boolean {
878
984
  val am = audioManager ?: return false
879
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
880
- val devices = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
881
- devices.any { device ->
882
- device.type == android.media.AudioDeviceInfo.TYPE_WIRED_HEADSET ||
883
- device.type == android.media.AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
884
- device.type == android.media.AudioDeviceInfo.TYPE_USB_HEADSET
885
- }
886
- } else {
887
- @Suppress("DEPRECATION")
888
- am.isWiredHeadsetOn
985
+ // getDevices(AudioManager.GET_DEVICES_OUTPUTS) is available from API 23 (M)
986
+ val devices = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
987
+ return devices.any { device ->
988
+ device.type == android.media.AudioDeviceInfo.TYPE_WIRED_HEADSET ||
989
+ device.type == android.media.AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
990
+ device.type == android.media.AudioDeviceInfo.TYPE_USB_HEADSET
889
991
  }
890
992
  }
891
993
 
994
+ /**
995
+ * Checks if a Bluetooth audio device (A2DP, SCO, BLE) is currently connected.
996
+ * This uses modern API methods suitable for SDK 28+.
997
+ */
892
998
  private fun isBluetoothDeviceConnected(): Boolean {
893
999
  val am = audioManager ?: return false
894
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
895
- val devices = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
896
- devices.any { device ->
897
- device.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
898
- device.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
899
- device.type == android.media.AudioDeviceInfo.TYPE_BLE_HEADSET
900
- }
901
- } else {
902
- @Suppress("DEPRECATION")
903
- am.isBluetoothA2dpOn || am.isBluetoothScoOn
1000
+ // getDevices(AudioManager.GET_DEVICES_OUTPUTS) is available from API 23 (M)
1001
+ val devices = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
1002
+ return devices.any { device ->
1003
+ device.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
1004
+ device.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
1005
+ device.type == android.media.AudioDeviceInfo.TYPE_BLE_HEADSET
904
1006
  }
905
1007
  }
906
1008
 
1009
+ /**
1010
+ * Emits an AUDIO_ROUTE_CHANGED event to the JavaScript layer.
1011
+ */
907
1012
  private fun emitAudioRouteChanged(currentRoute: String) {
908
- val info = getAudioDevices() // This now uses CallEngine's internal state
1013
+ val info = getAudioDevices() // Recalculate based on current internal state
909
1014
  val deviceStrings = info.devices.map { it.value }
910
1015
  val payload = JSONObject().apply {
911
1016
  put("devices", JSONArray(deviceStrings))
@@ -915,6 +1020,10 @@ object CallEngine {
915
1020
  Log.d(TAG, "Audio route changed: $currentRoute, available: $deviceStrings")
916
1021
  }
917
1022
 
1023
+ /**
1024
+ * Emits an AUDIO_DEVICES_CHANGED event to the JavaScript layer, indicating a change
1025
+ * in the list of available audio devices.
1026
+ */
918
1027
  private fun emitAudioDevicesChanged() {
919
1028
  val info = getAudioDevices()
920
1029
  val deviceStrings = info.devices.map { it.value }
@@ -926,24 +1035,23 @@ object CallEngine {
926
1035
  Log.d(TAG, "Audio devices changed: available: $deviceStrings")
927
1036
  }
928
1037
 
929
- // Audio device callbacks are now handled by Telecom and MyConnection.
930
- // The CallEngine does not directly register an AudioDeviceCallback.
931
- // This part of the code is removed.
932
-
933
- // ====== END IMPROVED AUDIO ROUTING SYSTEM ======
1038
+ // ====== END AUDIO ROUTING SYSTEM ======
934
1039
 
1040
+ /**
1041
+ * Manages a PARTIAL_WAKE_LOCK to keep the screen awake during calls.
1042
+ */
935
1043
  fun keepScreenAwake(keepAwake: Boolean) {
936
1044
  val context = requireContext()
937
1045
  val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
938
1046
  if (keepAwake) {
939
1047
  if (wakeLock == null || wakeLock!!.isHeld.not()) {
940
- // Use PARTIAL_WAKE_LOCK to keep CPU awake, SCREEN_DIM_WAKE_LOCK to keep screen on (dim)
941
- // For general "keep screen awake", SCREEN_DIM_WAKE_LOCK is appropriate.
1048
+ // Use SCREEN_DIM_WAKE_LOCK or PARTIAL_WAKE_LOCK.
1049
+ // SCREEN_DIM_WAKE_LOCK keeps the screen on but dimmed. ACQUIRE_CAUSES_WAKEUP wakes screen if off.
942
1050
  wakeLock = powerManager.newWakeLock(
943
1051
  PowerManager.SCREEN_DIM_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
944
1052
  "CallEngine:WakeLock"
945
1053
  )
946
- // Set a timeout for the wakelock to prevent indefinite drain, e.g., 10 minutes
1054
+ // Set a timeout to prevent indefinite battery drain, e.g., 10 minutes.
947
1055
  wakeLock?.acquire(10 * 60 * 1000L)
948
1056
  Log.d(TAG, "Acquired SCREEN_DIM_WAKE_LOCK")
949
1057
  }
@@ -960,6 +1068,10 @@ object CallEngine {
960
1068
 
961
1069
  fun getActiveCalls(): List<CallInfo> = activeCalls.values.toList()
962
1070
  fun getCurrentCallId(): String? = currentCallId
1071
+
1072
+ /**
1073
+ * Checks if there's any call currently in an active, incoming, dialing, or held state.
1074
+ */
963
1075
  fun isCallActive(): Boolean = activeCalls.any {
964
1076
  it.value.state == CallState.ACTIVE ||
965
1077
  it.value.state == CallState.INCOMING ||
@@ -967,21 +1079,31 @@ object CallEngine {
967
1079
  it.value.state == CallState.HELD
968
1080
  }
969
1081
 
1082
+ /**
1083
+ * Validates if an outgoing call request is permissible based on current call states and
1084
+ * multi-call allowance.
1085
+ */
970
1086
  private fun validateOutgoingCallRequest(): Boolean {
971
- // Only allow outgoing call if no incoming or active call exists and multi-call not allowed
1087
+ // An outgoing call is permitted if:
1088
+ // - There are no incoming calls.
1089
+ // - OR (If multiple calls are allowed), there are no active calls blocking new outgoing calls.
972
1090
  return !activeCalls.any {
973
- (!canMakeMultipleCalls && (it.value.state == CallState.INCOMING || it.value.state == CallState.ACTIVE))
1091
+ it.value.state == CallState.INCOMING ||
1092
+ (!canMakeMultipleCalls && it.value.state == CallState.ACTIVE)
974
1093
  }
975
1094
  }
976
1095
 
977
-
1096
+ /**
1097
+ * Handles incoming call collision by rejecting the new call and emitting an event.
1098
+ */
978
1099
  private fun rejectIncomingCallCollision(callId: String, reason: String) {
979
1100
  emitEvent(CallEventType.CALL_REJECTED, JSONObject().apply {
980
1101
  put("callId", callId)
981
1102
  put("reason", reason)
982
1103
  })
983
1104
 
984
- // Only remove metadata if there's NO existing active call with this ID
1105
+ // Only remove metadata if there's NO existing active call with this ID,
1106
+ // to avoid deleting metadata for a call that Telecom already knows about.
985
1107
  val existingCall = activeCalls[callId]
986
1108
  if (existingCall == null) {
987
1109
  callMetadata.remove(callId)
@@ -991,26 +1113,28 @@ object CallEngine {
991
1113
  }
992
1114
  }
993
1115
 
1116
+ /**
1117
+ * Creates or updates the notification channel for incoming calls.
1118
+ * On Android O (API 26) and above, notification channels are required.
1119
+ */
994
1120
  private fun createNotificationChannel() {
995
1121
  val context = requireContext()
996
1122
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
997
1123
  val channel = NotificationChannel(
998
1124
  NOTIF_CHANNEL_ID,
999
1125
  "Incoming Call Channel",
1000
- NotificationManager.IMPORTANCE_HIGH
1126
+ NotificationManager.IMPORTANCE_HIGH // High importance for incoming calls
1001
1127
  )
1002
1128
  channel.description = "Notifications for incoming calls"
1003
1129
  channel.enableLights(true)
1004
1130
  channel.lightColor = Color.GREEN
1005
1131
  channel.enableVibration(true)
1006
- channel.setBypassDnd(true)
1007
- channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
1008
-
1009
- // For Android S (API 31) and above, Telecom manages ringing for self-managed calls
1010
- // if it's the default dialer or an InCallService with METADATA_IN_CALL_SERVICE_RINGING.
1011
- // Setting sound to null here allows Telecom to take over or for us to manage manually
1012
- // if not relying on Telecom for ringing.
1013
- // Since we play ringtone manually below, ensure channel sound is null to avoid double ringing.
1132
+ channel.setBypassDnd(true) // Bypass Do Not Disturb mode
1133
+ channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC // Show on lock screen
1134
+
1135
+ // For Android S (API 31) and above, Telecom can manage ringing for self-managed calls
1136
+ // if configured as the default dialer. Setting sound to null here allows Telecom
1137
+ // to take over or for our manual ringtone to be the sole source.
1014
1138
  channel.setSound(null, null)
1015
1139
  channel.importance = NotificationManager.IMPORTANCE_HIGH
1016
1140
 
@@ -1019,6 +1143,10 @@ object CallEngine {
1019
1143
  }
1020
1144
  }
1021
1145
 
1146
+ /**
1147
+ * Decides whether to show a fullscreen overlay or a standard notification for an incoming call,
1148
+ * based on device lock state and CallStyle notification support.
1149
+ */
1022
1150
  private fun showIncomingCallUI(callId: String, callerName: String, callType: String, callerPicUrl: String?) {
1023
1151
  val context = requireContext()
1024
1152
  Log.d(TAG, "Showing incoming call UI for $callId")
@@ -1026,13 +1154,8 @@ object CallEngine {
1026
1154
  val useCallStyleNotification = supportsCallStyleNotifications()
1027
1155
  Log.d(TAG, "Using CallStyle notification: $useCallStyleNotification")
1028
1156
 
1029
- // Determine if device is locked or if CallStyle is not preferred/supported for current scenario
1030
- val isDeviceLocked = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
1031
- val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
1032
- keyguardManager.isKeyguardLocked
1033
- } else {
1034
- false // Older APIs, assume no direct lock screen check needed or handled differently
1035
- }
1157
+ val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
1158
+ val isDeviceLocked = keyguardManager.isKeyguardLocked
1036
1159
 
1037
1160
  if (isDeviceLocked || !useCallStyleNotification) {
1038
1161
  Log.d(TAG, "Device is locked or CallStyle not supported/preferred - using overlay/fallback approach")
@@ -1041,11 +1164,15 @@ object CallEngine {
1041
1164
  Log.d(TAG, "Device is unlocked and supports CallStyle - using enhanced notification")
1042
1165
  showStandardNotification(context, callId, callerName, callType, callerPicUrl)
1043
1166
  }
1044
- playRingtone()
1167
+ playRingtone() // Play our custom ringtone regardless of notification type
1045
1168
  }
1046
1169
 
1170
+ /**
1171
+ * Displays a fullscreen activity overlay for incoming calls, typically on a locked screen.
1172
+ */
1047
1173
  private fun showCallActivityOverlay(context: Context, callId: String, callerName: String, callType: String, callerPicUrl: String?) {
1048
1174
  val overlayIntent = Intent(context, CallActivity::class.java).apply {
1175
+ // Flags to ensure the activity appears on top of the lock screen and in a new task.
1049
1176
  addFlags(
1050
1177
  Intent.FLAG_ACTIVITY_NEW_TASK or
1051
1178
  Intent.FLAG_ACTIVITY_CLEAR_TASK or
@@ -1056,16 +1183,17 @@ object CallEngine {
1056
1183
  putExtra("callerName", callerName)
1057
1184
  putExtra("callType", callType)
1058
1185
  callerPicUrl?.let { putExtra("callerAvatar", it) }
1059
- putExtra("LOCK_SCREEN_MODE", true)
1186
+ putExtra("LOCK_SCREEN_MODE", true) // Hint for the activity itself
1060
1187
  }
1061
1188
 
1062
1189
  try {
1190
+ // Acquire a wake lock briefly to ensure the screen turns on for the overlay.
1063
1191
  val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
1064
1192
  val wakeLock = powerManager.newWakeLock(
1065
1193
  PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
1066
1194
  "CallEngine:LockScreenWake"
1067
1195
  )
1068
- wakeLock.acquire(5000) // Acquire for a short duration
1196
+ wakeLock.acquire(5000) // Acquire for 5 seconds to ensure visibility
1069
1197
  context.startActivity(overlayIntent)
1070
1198
  Log.d(TAG, "Successfully launched CallActivity overlay")
1071
1199
  } catch (e: Exception) {
@@ -1074,10 +1202,14 @@ object CallEngine {
1074
1202
  }
1075
1203
  }
1076
1204
 
1205
+ /**
1206
+ * Displays a standard incoming call notification using Notification.Builder and potentially Notification.CallStyle.
1207
+ */
1077
1208
  private fun showStandardNotification(context: Context, callId: String, callerName: String, callType: String, callerPicUrl: String?) {
1078
- createNotificationChannel()
1209
+ createNotificationChannel() // Ensure channel exists
1079
1210
  val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
1080
1211
 
1212
+ // Intent for tapping the notification body (goes to main call activity)
1081
1213
  val fullScreenIntent = Intent(context, CallActivity::class.java).apply {
1082
1214
  addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
1083
1215
  putExtra("callId", callId)
@@ -1091,6 +1223,7 @@ object CallEngine {
1091
1223
  PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
1092
1224
  )
1093
1225
 
1226
+ // Intent for 'Answer' action button on notification
1094
1227
  val answerIntent = Intent(context, CallNotificationActionReceiver::class.java).apply {
1095
1228
  action = "com.qusaieilouti99.callmanager.ANSWER_CALL"
1096
1229
  putExtra("callId", callId)
@@ -1100,6 +1233,7 @@ object CallEngine {
1100
1233
  PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
1101
1234
  )
1102
1235
 
1236
+ // Intent for 'Decline' action button on notification
1103
1237
  val declineIntent = Intent(context, CallNotificationActionReceiver::class.java).apply {
1104
1238
  action = "com.qusaieilouti99.callmanager.DECLINE_CALL"
1105
1239
  putExtra("callId", callId)
@@ -1110,6 +1244,7 @@ object CallEngine {
1110
1244
  )
1111
1245
 
1112
1246
  val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && supportsCallStyleNotifications()) {
1247
+ // Use Notification.CallStyle for modern Android versions (API 31+) for enhanced call UI.
1113
1248
  val person = android.app.Person.Builder()
1114
1249
  .setName(callerName)
1115
1250
  .setImportant(true)
@@ -1124,14 +1259,15 @@ object CallEngine {
1124
1259
  )
1125
1260
  )
1126
1261
  .setFullScreenIntent(fullScreenPendingIntent, true)
1127
- .setOngoing(true)
1262
+ .setOngoing(true) // Makes the notification non-dismissable
1128
1263
  .setAutoCancel(false)
1129
1264
  .setCategory(Notification.CATEGORY_CALL)
1130
- .setPriority(Notification.PRIORITY_MAX)
1131
- .setVisibility(Notification.VISIBILITY_PUBLIC)
1132
- .setSound(null) // No sound for CallStyle, we manage it separately
1265
+ .setPriority(Notification.PRIORITY_MAX) // High priority for heads-up notification
1266
+ .setVisibility(Notification.VISIBILITY_PUBLIC) // Show on lock screen
1267
+ .setSound(null) // Sound handled by playRingtone()
1133
1268
  .build()
1134
1269
  } else {
1270
+ // Fallback for older Android versions (API 28-30) that don't support CallStyle directly.
1135
1271
  Notification.Builder(context, NOTIF_CHANNEL_ID)
1136
1272
  .setSmallIcon(android.R.drawable.sym_call_incoming)
1137
1273
  .setContentTitle("Incoming Call")
@@ -1144,13 +1280,16 @@ object CallEngine {
1144
1280
  .setOngoing(true)
1145
1281
  .setAutoCancel(false)
1146
1282
  .setVisibility(Notification.VISIBILITY_PUBLIC)
1147
- .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)) // Fallback for older styles
1283
+ .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)) // For older styles, rely on channel/notification sound
1148
1284
  .build()
1149
1285
  }
1150
1286
 
1151
1287
  notificationManager.notify(NOTIF_ID, notification)
1152
1288
  }
1153
1289
 
1290
+ /**
1291
+ * Cancels the incoming call notification and stops the ringtone.
1292
+ */
1154
1293
  fun cancelIncomingCallUI() {
1155
1294
  val context = requireContext()
1156
1295
  val notificationManager =
@@ -1159,8 +1298,13 @@ object CallEngine {
1159
1298
  stopRingtone()
1160
1299
  }
1161
1300
 
1301
+ /**
1302
+ * Starts the foreground service responsible for displaying persistent notification
1303
+ * and maintaining app process priority during an active call.
1304
+ */
1162
1305
  private fun startForegroundService() {
1163
1306
  val context = requireContext()
1307
+ // Find the current main active call to pass its info to the service.
1164
1308
  val currentCall = activeCalls.values.find {
1165
1309
  it.state == CallState.ACTIVE ||
1166
1310
  it.state == CallState.INCOMING ||
@@ -1176,55 +1320,59 @@ object CallEngine {
1176
1320
  intent.putExtra("state", it.state.name)
1177
1321
  }
1178
1322
 
1323
+ // Use startForegroundService for Android O (API 26) and above.
1179
1324
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1180
1325
  context.startForegroundService(intent)
1181
1326
  } else {
1327
+ // Fallback for older Android versions (should not be reached if minSdk is 28+).
1182
1328
  context.startService(intent)
1183
1329
  }
1184
1330
  }
1185
1331
 
1332
+ /**
1333
+ * Stops the foreground service.
1334
+ */
1186
1335
  private fun stopForegroundService() {
1187
1336
  val context = requireContext()
1188
1337
  val intent = Intent(context, CallForegroundService::class.java)
1189
1338
  context.stopService(intent)
1190
1339
  }
1191
1340
 
1341
+ /**
1342
+ * Updates the foreground notification, typically by restarting the foreground service
1343
+ * with updated call information.
1344
+ */
1192
1345
  private fun updateForegroundNotification() {
1193
1346
  startForegroundService()
1194
1347
  }
1195
1348
 
1349
+ /**
1350
+ * Checks if the main activity of the application is currently in the foreground.
1351
+ * Uses modern API methods suitable for SDK 28+.
1352
+ */
1196
1353
  private fun isMainActivityInForeground(): Boolean {
1197
1354
  val context = requireContext()
1198
- val activityManager =
1199
- context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
1355
+ val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
1200
1356
 
1201
- // For API 23+, getRunningTasks is deprecated. Use UsageStatsManager or check AppTasks
1202
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { // LMR1 is API 22, getAppTasks available from API 21, but getRunningTasks deprecated from API 23.
1203
- try {
1204
- val tasks = activityManager.appTasks
1205
- if (tasks.isNotEmpty()) {
1206
- // get(0) is the top-most task
1207
- val topActivityComponentName = tasks[0].taskInfo.topActivity
1208
- return topActivityComponentName?.className?.contains("MainActivity") == true
1209
- }
1210
- } catch (e: Exception) {
1211
- Log.w(TAG, "Failed to get app tasks for foreground check: ${e.message}")
1212
- }
1213
- } else {
1214
- @Suppress("DEPRECATION")
1215
- try {
1216
- val tasks = activityManager.getRunningTasks(1)
1217
- if (tasks.isNotEmpty()) {
1218
- val runningTaskInfo = tasks[0]
1219
- return runningTaskInfo.topActivity?.className?.contains("MainActivity") == true
1220
- }
1221
- } catch (e: Exception) {
1222
- Log.w(TAG, "Failed to get running tasks for foreground check (deprecated): ${e.message}")
1357
+ // getAppTasks() is available from API 21 (Lollipop), but is the recommended approach over
1358
+ // deprecated getRunningTasks() from API 23 (Marshmallow).
1359
+ try {
1360
+ val tasks = activityManager.appTasks
1361
+ if (tasks.isNotEmpty()) {
1362
+ // The first task is usually the top-most one.
1363
+ val topActivityComponentName = tasks[0].taskInfo.topActivity
1364
+ return topActivityComponentName?.className?.contains("MainActivity") == true
1223
1365
  }
1366
+ } catch (e: Exception) {
1367
+ Log.w(TAG, "Failed to get app tasks for foreground check: ${e.message}")
1224
1368
  }
1225
1369
  return false
1226
1370
  }
1227
1371
 
1372
+ /**
1373
+ * Brings the application's main activity to the foreground.
1374
+ * Includes logic to bypass the lock screen if there's an active call.
1375
+ */
1228
1376
  private fun bringAppToForeground() {
1229
1377
  if (isMainActivityInForeground()) {
1230
1378
  Log.d(TAG, "MainActivity is already in foreground, skipping")
@@ -1241,12 +1389,14 @@ object CallEngine {
1241
1389
  Intent.FLAG_ACTIVITY_SINGLE_TOP
1242
1390
  )
1243
1391
 
1392
+ // Add flag to bypass lock screen if there is an active call.
1244
1393
  if (isCallActive()) {
1245
1394
  launchIntent?.putExtra("BYPASS_LOCK_SCREEN", true)
1246
1395
  }
1247
1396
 
1248
1397
  try {
1249
1398
  context.startActivity(launchIntent)
1399
+ // Small delay to ensure the UI has time to update its lock screen bypass state.
1250
1400
  Handler(Looper.getMainLooper()).postDelayed({
1251
1401
  updateLockScreenBypass()
1252
1402
  }, 100)
@@ -1255,15 +1405,19 @@ object CallEngine {
1255
1405
  }
1256
1406
  }
1257
1407
 
1408
+ /**
1409
+ * Registers a self-managed [PhoneAccount] with the Telecom framework.
1410
+ * This is required for your app to interact with Telecom for calls.
1411
+ */
1258
1412
  private fun registerPhoneAccount() {
1259
1413
  val context = requireContext()
1260
- val telecomManager =
1261
- context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
1414
+ val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
1262
1415
  val phoneAccountHandle = getPhoneAccountHandle()
1263
1416
 
1417
+ // Only register if the PhoneAccount isn't already registered.
1264
1418
  if (telecomManager.getPhoneAccount(phoneAccountHandle) == null) {
1265
1419
  val phoneAccount = PhoneAccount.builder(phoneAccountHandle, "PingMe Call")
1266
- .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED)
1420
+ .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED) // Declare as self-managed
1267
1421
  .build()
1268
1422
 
1269
1423
  try {
@@ -1275,6 +1429,9 @@ object CallEngine {
1275
1429
  }
1276
1430
  }
1277
1431
 
1432
+ /**
1433
+ * Gets the [PhoneAccountHandle] for this application's self-managed calling capabilities.
1434
+ */
1278
1435
  private fun getPhoneAccountHandle(): PhoneAccountHandle {
1279
1436
  val context = requireContext()
1280
1437
  return PhoneAccountHandle(
@@ -1283,17 +1440,22 @@ object CallEngine {
1283
1440
  )
1284
1441
  }
1285
1442
 
1443
+ /**
1444
+ * Plays the default incoming call ringtone and vibrates the device.
1445
+ */
1286
1446
  private fun playRingtone() {
1287
1447
  val context = requireContext()
1288
1448
  audioManager = audioManager ?: context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
1289
- audioManager?.mode = AudioManager.MODE_RINGTONE
1449
+ audioManager?.mode = AudioManager.MODE_RINGTONE // Set audio mode for ringing
1290
1450
 
1291
1451
  vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
1292
1452
  vibrator?.let { v ->
1293
- val pattern = longArrayOf(0L, 500L, 500L)
1294
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1453
+ val pattern = longArrayOf(0L, 500L, 500L) // Vibrate for 0.5s, pause 0.5s, loop
1454
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // VibrationEffect requires API 26+
1295
1455
  v.vibrate(VibrationEffect.createWaveform(pattern, 0))
1296
1456
  } else {
1457
+ // This branch is technically for API < 26, which is below targetSdk 28+.
1458
+ // It's retained here just for completeness of original logic.
1297
1459
  @Suppress("DEPRECATION")
1298
1460
  v.vibrate(pattern, 0)
1299
1461
  }
@@ -1309,29 +1471,35 @@ object CallEngine {
1309
1471
  }
1310
1472
  }
1311
1473
 
1474
+ /**
1475
+ * Stops the currently playing ringtone and cancels vibration.
1476
+ */
1312
1477
  fun stopRingtone() {
1313
1478
  try {
1314
1479
  ringtone?.stop()
1315
1480
  Log.d(TAG, "Ringtone stopped")
1316
1481
  } catch (e: Exception) {
1317
1482
  Log.e(TAG, "Error stopping ringtone", e)
1483
+ } finally {
1484
+ ringtone = null
1318
1485
  }
1319
- ringtone = null
1320
1486
 
1321
1487
  vibrator?.cancel()
1322
1488
  vibrator = null
1323
1489
  }
1324
1490
 
1491
+ /**
1492
+ * Starts playing a local ringback tone.
1493
+ */
1325
1494
  private fun startRingback() {
1326
1495
  val context = requireContext()
1327
- if (ringbackPlayer?.isPlaying == true) return
1496
+ if (ringbackPlayer?.isPlaying == true) return // Don't start if already playing
1328
1497
 
1329
1498
  try {
1330
- val ringbackUri =
1331
- Uri.parse("android.resource://${context.packageName}/raw/ringback_tone")
1499
+ val ringbackUri = Uri.parse("android.resource://${context.packageName}/raw/ringback_tone")
1332
1500
  ringbackPlayer = MediaPlayer.create(context, ringbackUri)
1333
1501
  ringbackPlayer?.apply {
1334
- isLooping = true
1502
+ isLooping = true // Loop the tone
1335
1503
  start()
1336
1504
  }
1337
1505
  Log.d(TAG, "Ringback tone started playing")
@@ -1340,10 +1508,13 @@ object CallEngine {
1340
1508
  }
1341
1509
  }
1342
1510
 
1511
+ /**
1512
+ * Stops the currently playing ringback tone.
1513
+ */
1343
1514
  private fun stopRingback() {
1344
1515
  try {
1345
1516
  ringbackPlayer?.stop()
1346
- ringbackPlayer?.release()
1517
+ ringbackPlayer?.release() // Release MediaPlayer resources
1347
1518
  } catch (e: Exception) {
1348
1519
  Log.e(TAG, "Error stopping ringback: ${e.message}")
1349
1520
  } finally {
@@ -1351,32 +1522,42 @@ object CallEngine {
1351
1522
  }
1352
1523
  }
1353
1524
 
1525
+ /**
1526
+ * Performs general cleanup actions when all calls have ended.
1527
+ */
1354
1528
  private fun cleanup() {
1355
1529
  Log.d(TAG, "Performing cleanup")
1356
1530
  stopForegroundService()
1357
- keepScreenAwake(false)
1358
- resetAudioMode()
1531
+ keepScreenAwake(false) // Release wake lock
1532
+ resetAudioMode() // Reset audio system
1359
1533
  }
1360
1534
 
1535
+ /**
1536
+ * Called when the application is about to terminate. Ensures all active calls are properly
1537
+ * disconnected and resources are released.
1538
+ */
1361
1539
  fun onApplicationTerminate() {
1362
1540
  Log.d(TAG, "Application terminating")
1541
+ // Disconnect and destroy any remaining Telecom connections.
1363
1542
  activeCalls.keys.toList().forEach { callId ->
1364
1543
  telecomConnections[callId]?.let { conn ->
1365
1544
  conn.setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
1366
1545
  conn.destroy()
1367
1546
  }
1368
1547
  }
1548
+ // Clear all internal state tracking.
1369
1549
  activeCalls.clear()
1370
1550
  telecomConnections.clear()
1371
1551
  callMetadata.clear()
1372
1552
  currentCallId = null
1373
- cleanup()
1553
+
1554
+ cleanup() // Perform cleanup steps
1374
1555
  lockScreenBypassCallbacks.clear()
1375
- eventHandler = null
1376
- cachedEvents.clear()
1377
- isInitialized.set(false)
1378
- appContext = null
1379
- // Reset audio states
1556
+ eventHandler = null // Clear event handler
1557
+ cachedEvents.clear() // Clear cached events
1558
+ isInitialized.set(false) // Mark as uninitialized
1559
+ appContext = null // Release context reference
1560
+ // Reset audio states explicitly
1380
1561
  currentActiveCallEndpoint = null
1381
1562
  availableCallEndpoints = emptyList()
1382
1563
  wasManuallySetAudioRoute = false