@qusaieilouti99/call-manager 0.1.165 → 0.1.166

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.
@@ -1,7 +1,7 @@
1
1
  package com.margelo.nitro.qusaieilouti99.callmanager
2
2
 
3
- import android.telecom.CallAudioState
4
3
  import android.app.ActivityManager
4
+ import android.app.KeyguardManager
5
5
  import android.app.Notification
6
6
  import android.app.NotificationChannel
7
7
  import android.app.NotificationManager
@@ -10,9 +10,6 @@ import android.content.ComponentName
10
10
  import android.content.Context
11
11
  import android.content.Intent
12
12
  import android.graphics.Color
13
- import android.media.AudioAttributes
14
- import android.media.AudioDeviceCallback
15
- import android.media.AudioDeviceInfo
16
13
  import android.media.AudioManager
17
14
  import android.media.MediaPlayer
18
15
  import android.media.RingtoneManager
@@ -22,21 +19,29 @@ import android.os.Bundle
22
19
  import android.os.Handler
23
20
  import android.os.Looper
24
21
  import android.os.PowerManager
22
+ import android.os.VibrationEffect
23
+ import android.os.Vibrator
24
+ import android.telecom.CallAudioState
25
25
  import android.telecom.Connection
26
26
  import android.telecom.DisconnectCause
27
27
  import android.telecom.PhoneAccount
28
28
  import android.telecom.PhoneAccountHandle
29
29
  import android.telecom.TelecomManager
30
30
  import android.util.Log
31
- import org.json.JSONArray
32
- import org.json.JSONObject
33
31
  import java.util.concurrent.ConcurrentHashMap
34
32
  import java.util.concurrent.CopyOnWriteArrayList
35
33
  import java.util.concurrent.atomic.AtomicBoolean
36
- import android.app.KeyguardManager
37
- import android.os.Vibrator
38
- import android.os.VibrationEffect
34
+ import org.json.JSONArray
35
+ import org.json.JSONObject
39
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
+ */
40
45
  object CallEngine {
41
46
  private const val TAG = "CallEngine"
42
47
  private const val PHONE_ACCOUNT_ID = "com.qusaieilouti99.callmanager.SELF_MANAGED"
@@ -79,8 +84,6 @@ object CallEngine {
79
84
  private var eventHandler: ((CallEventType, String) -> Unit)? = null
80
85
  private val cachedEvents = mutableListOf<Pair<CallEventType, String>>()
81
86
 
82
- private var currentAudioRoute: String = "Unknown"
83
-
84
87
  interface LockScreenBypassCallback {
85
88
  fun onLockScreenBypassChanged(shouldBypass: Boolean)
86
89
  }
@@ -135,8 +138,12 @@ object CallEngine {
135
138
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
136
139
  val manufacturer = Build.MANUFACTURER.lowercase()
137
140
  val brand = Build.BRAND.lowercase()
138
- val supportedManufacturers = setOf("google", "samsung", "oneplus", "motorola", "sony", "lg", "htc")
139
- val supportedBrands = setOf("google", "samsung", "oneplus", "motorola", "sony", "lg", "htc", "pixel")
141
+ val supportedManufacturers = setOf(
142
+ "google", "samsung", "oneplus", "motorola", "sony", "lg", "htc"
143
+ )
144
+ val supportedBrands = setOf(
145
+ "google", "samsung", "oneplus", "motorola", "sony", "lg", "htc", "pixel"
146
+ )
140
147
  val isSupported = supportedManufacturers.contains(manufacturer) ||
141
148
  supportedBrands.contains(brand) ||
142
149
  manufacturer.contains("google") ||
@@ -212,20 +219,26 @@ object CallEngine {
212
219
  if (!isInitialized.get()) {
213
220
  initialize(context)
214
221
  }
222
+
215
223
  Log.d(TAG, "reportIncomingCall: callId=$callId, type=$callType, name=$displayName")
216
224
  metadata?.let { callMetadata[callId] = it }
225
+
217
226
  val incomingCall = activeCalls.values.find { it.state == CallState.INCOMING }
218
227
  if (incomingCall != null) {
219
228
  Log.d(TAG, "Incoming call collision detected. Auto-rejecting new call: $callId")
220
229
  rejectIncomingCallCollision(callId, "Another call is already incoming")
221
230
  return
222
231
  }
223
- val activeCall = activeCalls.values.find { it.state == CallState.ACTIVE || it.state == CallState.HELD }
232
+
233
+ val activeCall = activeCalls.values.find {
234
+ it.state == CallState.ACTIVE || it.state == CallState.HELD
235
+ }
224
236
  if (activeCall != null && !canMakeMultipleCalls) {
225
237
  Log.d(TAG, "Active call exists when receiving incoming call. Auto-rejecting: $callId")
226
238
  rejectIncomingCallCollision(callId, "Another call is already active")
227
239
  return
228
240
  }
241
+
229
242
  val isVideoCall = callType == "Video"
230
243
  if (!canMakeMultipleCalls && activeCalls.isNotEmpty()) {
231
244
  activeCalls.values.forEach {
@@ -234,12 +247,17 @@ object CallEngine {
234
247
  }
235
248
  }
236
249
  }
237
- activeCalls[callId] = CallInfo(callId, callType, displayName, pictureUrl, CallState.INCOMING)
250
+
251
+ activeCalls[callId] =
252
+ CallInfo(callId, callType, displayName, pictureUrl, CallState.INCOMING)
238
253
  currentCallId = callId
239
254
  Log.d(TAG, "Call $callId added to activeCalls. State: INCOMING")
255
+
240
256
  showIncomingCallUI(callId, displayName, callType, pictureUrl)
241
257
  registerPhoneAccount()
242
- val telecomManager = requireContext().getSystemService(Context.TELECOM_SERVICE) as TelecomManager
258
+
259
+ val telecomManager =
260
+ requireContext().getSystemService(Context.TELECOM_SERVICE) as TelecomManager
243
261
  val phoneAccountHandle = getPhoneAccountHandle()
244
262
  val extras = Bundle().apply {
245
263
  putString(MyConnectionService.EXTRA_CALL_ID, callId)
@@ -248,6 +266,7 @@ object CallEngine {
248
266
  putBoolean(MyConnectionService.EXTRA_IS_VIDEO_CALL_BOOLEAN, isVideoCall)
249
267
  pictureUrl?.let { putString(MyConnectionService.EXTRA_PICTURE_URL, it) }
250
268
  }
269
+
251
270
  try {
252
271
  telecomManager.addNewIncomingCall(phoneAccountHandle, extras)
253
272
  startForegroundService()
@@ -256,6 +275,7 @@ object CallEngine {
256
275
  Log.e(TAG, "Failed to report incoming call: ${e.message}", e)
257
276
  endCallInternal(callId)
258
277
  }
278
+
259
279
  updateLockScreenBypass()
260
280
  }
261
281
 
@@ -268,6 +288,7 @@ object CallEngine {
268
288
  val context = requireContext()
269
289
  Log.d(TAG, "startOutgoingCall: callId=$callId, type=$callType, target=$targetName")
270
290
  metadata?.let { callMetadata[callId] = it }
291
+
271
292
  if (!validateOutgoingCallRequest()) {
272
293
  Log.w(TAG, "Rejecting outgoing call - incoming/active call exists")
273
294
  emitEvent(CallEventType.CALL_REJECTED, JSONObject().apply {
@@ -276,6 +297,7 @@ object CallEngine {
276
297
  })
277
298
  return
278
299
  }
300
+
279
301
  val isVideoCall = callType == "Video"
280
302
  if (!canMakeMultipleCalls && activeCalls.isNotEmpty()) {
281
303
  activeCalls.values.forEach {
@@ -284,14 +306,18 @@ object CallEngine {
284
306
  }
285
307
  }
286
308
  }
309
+
287
310
  activeCalls[callId] = CallInfo(callId, callType, targetName, null, CallState.DIALING)
288
311
  currentCallId = callId
289
312
  Log.d(TAG, "Call $callId added to activeCalls. State: DIALING")
290
- setAudioMode()
313
+
291
314
  registerPhoneAccount()
292
- val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
315
+
316
+ val telecomManager =
317
+ context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
293
318
  val phoneAccountHandle = getPhoneAccountHandle()
294
319
  val addressUri = Uri.fromParts(PhoneAccount.SCHEME_TEL, targetName, null)
320
+
295
321
  val outgoingExtras = Bundle().apply {
296
322
  putString(MyConnectionService.EXTRA_CALL_ID, callId)
297
323
  putString(MyConnectionService.EXTRA_CALL_TYPE, callType)
@@ -299,11 +325,14 @@ object CallEngine {
299
325
  putBoolean(MyConnectionService.EXTRA_IS_VIDEO_CALL_BOOLEAN, isVideoCall)
300
326
  metadata?.let { putString("metadata", it) }
301
327
  }
328
+
302
329
  val extras = Bundle().apply {
303
330
  putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccountHandle)
304
331
  putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingExtras)
332
+ // Let Telecom decide the initial audio route based on devices and video state
305
333
  putBoolean(TelecomManager.EXTRA_START_CALL_WITH_SPEAKERPHONE, isVideoCall)
306
334
  }
335
+
307
336
  try {
308
337
  telecomManager.placeCall(addressUri, extras)
309
338
  startForegroundService()
@@ -315,9 +344,15 @@ object CallEngine {
315
344
  Log.e(TAG, "Failed to start outgoing call: ${e.message}", e)
316
345
  endCallInternal(callId)
317
346
  }
347
+
318
348
  updateLockScreenBypass()
319
349
  }
320
350
 
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
+ */
321
356
  fun startCall(
322
357
  callId: String,
323
358
  callType: String,
@@ -325,57 +360,96 @@ object CallEngine {
325
360
  metadata: String? = null
326
361
  ) {
327
362
  Log.d(TAG, "startCall: callId=$callId, type=$callType, target=$targetName")
363
+
328
364
  val existingCall = activeCalls[callId]
329
- if (existingCall != null && existingCall.state == CallState.INCOMING) {
330
- Log.d(TAG, "startCall: Answering existing incoming call $callId")
331
- answerCall(callId)
365
+ if (existingCall != null) {
366
+ if (existingCall.state == CallState.INCOMING) {
367
+ Log.d(TAG, "startCall: Call $callId is incoming, answering it.")
368
+ answerCall(callId)
369
+ } else {
370
+ Log.w(TAG, "startCall: Call $callId already exists with state ${existingCall.state}. Ignoring.")
371
+ }
332
372
  return
333
373
  }
334
- if (activeCalls.containsKey(callId)) {
335
- Log.w(TAG, "Call $callId already exists and is not incoming, cannot start again.")
336
- return
374
+
375
+ metadata?.let { callMetadata[callId] = it }
376
+
377
+ if (!canMakeMultipleCalls && activeCalls.isNotEmpty()) {
378
+ activeCalls.values.forEach {
379
+ if (it.state == CallState.ACTIVE) {
380
+ holdCallInternal(it.callId, heldBySystem = false)
381
+ }
382
+ }
383
+ }
384
+
385
+ // This call will be set to ACTIVE immediately by MyConnectionService
386
+ activeCalls[callId] = CallInfo(callId, callType, targetName, null, CallState.ACTIVE)
387
+ currentCallId = callId
388
+ Log.d(TAG, "Call $callId will be started as ACTIVE.")
389
+
390
+ registerPhoneAccount()
391
+
392
+ val context = requireContext()
393
+ val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
394
+ val phoneAccountHandle = getPhoneAccountHandle()
395
+ val addressUri = Uri.fromParts(PhoneAccount.SCHEME_TEL, targetName, null)
396
+
397
+ val outgoingExtras = Bundle().apply {
398
+ putString(MyConnectionService.EXTRA_CALL_ID, callId)
399
+ putString(MyConnectionService.EXTRA_CALL_TYPE, callType)
400
+ putString(MyConnectionService.EXTRA_DISPLAY_NAME, targetName)
401
+ putBoolean(MyConnectionService.EXTRA_IS_VIDEO_CALL_BOOLEAN, callType == "Video")
402
+ putBoolean(MyConnectionService.EXTRA_START_IMMEDIATELY_ACTIVE, true) // Custom flag
403
+ metadata?.let { putString("metadata", it) }
404
+ }
405
+
406
+ val extras = Bundle().apply {
407
+ putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccountHandle)
408
+ putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingExtras)
409
+ }
410
+
411
+ try {
412
+ telecomManager.placeCall(addressUri, extras)
413
+ startForegroundService()
414
+ bringAppToForeground()
415
+ keepScreenAwake(true)
416
+ updateLockScreenBypass()
417
+ // Event is emitted by coreCallAnswered once the connection is active
418
+ Log.d(TAG, "Successfully placed call to be immediately active with TelecomManager.")
419
+ } catch (e: Exception) {
420
+ Log.e(TAG, "Failed to start call: ${e.message}", e)
421
+ endCallInternal(callId)
337
422
  }
338
- Log.d(TAG, "startCall: Starting new outgoing call $callId and connecting immediately.")
339
- startOutgoingCall(callId, callType, targetName, metadata)
340
- mainHandler.postDelayed({
341
- callAnsweredFromJS(callId)
342
- }, 200)
343
423
  }
344
424
 
345
425
  fun callAnsweredFromJS(callId: String) {
346
426
  Log.d(TAG, "callAnsweredFromJS: $callId - remote party answered")
347
- val callInfo = activeCalls[callId]
348
- if (callInfo?.state != CallState.DIALING) {
349
- Log.w(TAG, "Cannot answer outgoing call $callId from JS - not in DIALING state. State is ${callInfo?.state}")
350
- return
351
- }
352
427
  coreCallAnswered(callId, isLocalAnswer = false)
353
428
  }
354
429
 
355
430
  fun answerCall(callId: String) {
356
431
  Log.d(TAG, "answerCall: $callId - local party answering")
357
- val callInfo = activeCalls[callId]
358
- if (callInfo?.state != CallState.INCOMING) {
359
- Log.w(TAG, "Cannot answer call $callId - not in INCOMING state. State is ${callInfo?.state}")
360
- return
361
- }
362
432
  coreCallAnswered(callId, isLocalAnswer = true)
363
433
  }
364
434
 
365
- private fun coreCallAnswered(callId: String, isLocalAnswer: Boolean) {
435
+ fun coreCallAnswered(callId: String, isLocalAnswer: Boolean) {
366
436
  Log.d(TAG, "coreCallAnswered: $callId, isLocalAnswer: $isLocalAnswer")
367
437
  val callInfo = activeCalls[callId]
368
438
  if (callInfo == null) {
369
439
  Log.w(TAG, "Cannot answer call $callId - not found in active calls")
370
440
  return
371
441
  }
372
- telecomConnections[callId]?.setActive()
442
+
443
+ // The connection state change (e.g., onAnswer) is the source of truth.
444
+ // We just update our internal state and UI.
373
445
  activeCalls[callId] = callInfo.copy(state = CallState.ACTIVE)
374
446
  currentCallId = callId
375
447
  Log.d(TAG, "Call $callId set to ACTIVE state")
448
+
376
449
  stopRingtone()
377
450
  stopRingback()
378
451
  cancelIncomingCallUI()
452
+
379
453
  if (!canMakeMultipleCalls) {
380
454
  activeCalls.filter { it.key != callId }.values.forEach { otherCall ->
381
455
  if (otherCall.state == CallState.ACTIVE) {
@@ -383,31 +457,39 @@ object CallEngine {
383
457
  }
384
458
  }
385
459
  }
460
+
386
461
  bringAppToForeground()
387
462
  startForegroundService()
388
463
  keepScreenAwake(true)
389
464
  updateLockScreenBypass()
390
- setAudioMode()
391
- registerAudioDeviceCallback()
392
- // The initial audio route will be set automatically by the onStateChanged(STATE_ACTIVE) callback
465
+
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
+
393
469
  if (isLocalAnswer) {
394
470
  emitCallAnsweredWithMetadata(callId)
395
471
  } else {
396
472
  emitOutgoingCallAnsweredWithMetadata(callId)
397
473
  }
474
+
398
475
  Log.d(TAG, "Call $callId successfully answered")
399
476
  }
400
477
 
401
478
  private fun emitCallAnsweredWithMetadata(callId: String) {
402
479
  val callInfo = activeCalls[callId] ?: return
403
480
  val metadata = callMetadata[callId]
481
+
404
482
  emitEvent(CallEventType.CALL_ANSWERED, JSONObject().apply {
405
483
  put("callId", callId)
406
484
  put("callType", callInfo.callType)
407
485
  put("displayName", callInfo.displayName)
408
486
  callInfo.pictureUrl?.let { put("pictureUrl", it) }
409
487
  metadata?.let {
410
- try { put("metadata", JSONObject(it)) } catch (e: Exception) { put("metadata", it) }
488
+ try {
489
+ put("metadata", JSONObject(it))
490
+ } catch (e: Exception) {
491
+ put("metadata", it)
492
+ }
411
493
  }
412
494
  })
413
495
  }
@@ -415,13 +497,18 @@ object CallEngine {
415
497
  private fun emitOutgoingCallAnsweredWithMetadata(callId: String) {
416
498
  val callInfo = activeCalls[callId] ?: return
417
499
  val metadata = callMetadata[callId]
500
+
418
501
  emitEvent(CallEventType.OUTGOING_CALL_ANSWERED, JSONObject().apply {
419
502
  put("callId", callId)
420
503
  put("callType", callInfo.callType)
421
504
  put("displayName", callInfo.displayName)
422
505
  callInfo.pictureUrl?.let { put("pictureUrl", it) }
423
506
  metadata?.let {
424
- try { put("metadata", JSONObject(it)) } catch (e: Exception) { put("metadata", it) }
507
+ try {
508
+ put("metadata", JSONObject(it))
509
+ } catch (e: Exception) {
510
+ put("metadata", it)
511
+ }
425
512
  }
426
513
  })
427
514
  }
@@ -437,6 +524,7 @@ object CallEngine {
437
524
  Log.w(TAG, "Cannot set hold state for call $callId - not found")
438
525
  return
439
526
  }
527
+
440
528
  if (onHold && callInfo.state == CallState.ACTIVE) {
441
529
  holdCallInternal(callId, heldBySystem = false)
442
530
  } else if (!onHold && callInfo.state == CallState.HELD) {
@@ -451,7 +539,12 @@ object CallEngine {
451
539
  Log.w(TAG, "Cannot hold call $callId - not in active state")
452
540
  return
453
541
  }
454
- activeCalls[callId] = callInfo.copy(state = CallState.HELD, wasHeldBySystem = heldBySystem)
542
+
543
+ activeCalls[callId] = callInfo.copy(
544
+ state = CallState.HELD,
545
+ wasHeldBySystem = heldBySystem
546
+ )
547
+
455
548
  telecomConnections[callId]?.setOnHold()
456
549
  updateForegroundNotification()
457
550
  emitEvent(CallEventType.CALL_HELD, JSONObject().put("callId", callId))
@@ -469,7 +562,12 @@ object CallEngine {
469
562
  Log.w(TAG, "Cannot unhold call $callId - not in held state")
470
563
  return
471
564
  }
472
- activeCalls[callId] = callInfo.copy(state = CallState.ACTIVE, wasHeldBySystem = false)
565
+
566
+ activeCalls[callId] = callInfo.copy(
567
+ state = CallState.ACTIVE,
568
+ wasHeldBySystem = false
569
+ )
570
+
473
571
  telecomConnections[callId]?.setActive()
474
572
  updateForegroundNotification()
475
573
  emitEvent(CallEventType.CALL_UNHELD, JSONObject().put("callId", callId))
@@ -477,45 +575,25 @@ object CallEngine {
477
575
  }
478
576
 
479
577
  fun muteCall(callId: String) {
480
- setMuted(callId, true)
578
+ setMutedInternal(callId, true)
481
579
  }
482
580
 
483
581
  fun unmuteCall(callId: String) {
484
- setMuted(callId, false)
582
+ setMutedInternal(callId, false)
485
583
  }
486
584
 
487
- /**
488
- * FIX: This is the correct way to handle mute requests from the app's UI.
489
- * It uses the AudioManager, which is the source of truth for microphone state.
490
- */
491
585
  fun setMuted(callId: String, muted: Boolean) {
492
- if (!activeCalls.containsKey(callId)) {
493
- Log.w(TAG, "Cannot set mute state for call $callId - not found")
494
- return
495
- }
496
- audioManager?.isMicrophoneMute = muted
497
- Log.d(TAG, "Set mute state to $muted for call $callId via AudioManager")
498
-
499
- // We must also emit the event to keep the JS UI in sync.
500
- onTelecomMuteStateChanged(callId, muted)
586
+ setMutedInternal(callId, muted)
501
587
  }
502
588
 
503
- /**
504
- * Internal callback from MyConnection when Telecom reports a mute state change.
505
- * This method ensures our state is consistent and emits the event to JS.
506
- */
507
- fun onTelecomMuteStateChanged(callId: String, isMuted: Boolean) {
508
- if (!activeCalls.containsKey(callId)) {
509
- Log.w(TAG, "Received mute state change for unknown call $callId")
589
+ 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")
510
593
  return
511
594
  }
512
- // Ensure our audio manager state is consistent with the system's request
513
- if (audioManager?.isMicrophoneMute != isMuted) {
514
- audioManager?.isMicrophoneMute = isMuted
515
- }
516
- Log.d(TAG, "Telecom reported mute state for $callId is now: $isMuted. Emitting event.")
517
- val eventType = if (isMuted) CallEventType.CALL_MUTED else CallEventType.CALL_UNMUTED
518
- emitEvent(eventType, JSONObject().put("callId", callId))
595
+ // The Connection's onCallAudioStateChanged will handle the event emission
596
+ (telecomConnections[callId] as? MyConnection)?.setMuted(muted)
519
597
  }
520
598
 
521
599
  fun endCall(callId: String) {
@@ -526,237 +604,169 @@ object CallEngine {
526
604
  fun endAllCalls() {
527
605
  Log.d(TAG, "endAllCalls: Ending all active calls")
528
606
  if (activeCalls.isEmpty()) return
607
+
529
608
  activeCalls.keys.toList().forEach { callId ->
530
609
  endCallInternal(callId)
531
610
  }
532
- activeCalls.clear()
533
- telecomConnections.clear()
534
- callMetadata.clear()
535
- currentCallId = null
536
- cleanup()
537
- updateLockScreenBypass()
538
611
  }
539
612
 
540
613
  private fun endCallInternal(callId: String) {
541
614
  Log.d(TAG, "endCallInternal: $callId")
615
+
542
616
  val callInfo = activeCalls[callId] ?: run {
543
617
  Log.w(TAG, "Call $callId not found in active calls")
544
618
  return
545
619
  }
620
+
546
621
  val metadata = callMetadata.remove(callId)
547
622
  activeCalls.remove(callId)
623
+
548
624
  stopRingback()
549
625
  stopRingtone()
550
626
  cancelIncomingCallUI()
627
+
551
628
  if (currentCallId == callId) {
552
- currentCallId = activeCalls.filter { it.value.state != CallState.ENDED }.keys.firstOrNull()
629
+ currentCallId =
630
+ activeCalls.filter { it.value.state != CallState.ENDED }.keys.firstOrNull()
553
631
  }
632
+
554
633
  val context = requireContext()
555
634
  val closeActivityIntent = Intent("com.qusaieilouti99.callmanager.CLOSE_CALL_ACTIVITY")
556
635
  .setPackage(context.packageName)
557
636
  .putExtra("callId", callId)
637
+
558
638
  try {
559
639
  context.sendBroadcast(closeActivityIntent)
560
640
  Log.d(TAG, "Sent close broadcast for CallActivity: $callId")
561
641
  } catch (e: Exception) {
562
642
  Log.w(TAG, "Failed to send close broadcast: ${e.message}")
563
643
  }
644
+
564
645
  telecomConnections[callId]?.let { connection ->
565
646
  connection.setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
566
647
  connection.destroy()
567
648
  removeTelecomConnection(callId)
568
649
  }
650
+
569
651
  if (activeCalls.isEmpty()) {
570
652
  cleanup()
571
653
  } else {
572
654
  updateForegroundNotification()
573
655
  }
656
+
574
657
  updateLockScreenBypass()
658
+
575
659
  for (listener in callEndListeners) {
576
660
  mainHandler.post {
577
- try { listener.onCallEnded(callId) } catch (_: Throwable) { /* swallow */ }
661
+ try {
662
+ listener.onCallEnded(callId)
663
+ } catch (_: Throwable) {
664
+ // swallow
665
+ }
578
666
  }
579
667
  }
668
+
580
669
  emitEvent(CallEventType.CALL_ENDED, JSONObject().apply {
581
670
  put("callId", callId)
582
671
  metadata?.let {
583
- try { put("metadata", JSONObject(it)) } catch (e: Exception) { put("metadata", it) }
672
+ try { put("metadata", JSONObject(it)) }
673
+ catch (e: Exception) { put("metadata", it) }
584
674
  }
585
675
  })
586
676
  }
587
677
 
678
+ // ====== NEW TELECOM-DRIVEN AUDIO ROUTING SYSTEM ======
679
+
588
680
  fun getAudioDevices(): AudioRoutesInfo {
589
681
  val context = requireContext()
590
- audioManager = audioManager ?: context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
682
+ val am = audioManager ?: context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
591
683
  ?: return AudioRoutesInfo(emptyArray(), "Unknown")
684
+
592
685
  val devices = mutableSetOf<String>()
593
686
  devices.add("Earpiece")
594
687
  devices.add("Speaker")
595
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
596
- val outputDevices = audioManager?.getDevices(AudioManager.GET_DEVICES_OUTPUTS) ?: emptyArray()
597
- var hasBluetooth = false
598
- var hasHeadset = false
599
- for (device in outputDevices) {
600
- when (device.type) {
601
- AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLE_HEADSET -> if (!hasBluetooth) { devices.add("Bluetooth"); hasBluetooth = true }
602
- AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_USB_HEADSET -> if (!hasHeadset) { devices.add("Headset"); hasHeadset = true }
603
- }
688
+
689
+ val outputDevices = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
690
+ for (device in outputDevices) {
691
+ when (device.type) {
692
+ android.media.AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
693
+ android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
694
+ android.media.AudioDeviceInfo.TYPE_BLE_HEADSET -> devices.add("Bluetooth")
695
+ android.media.AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
696
+ android.media.AudioDeviceInfo.TYPE_WIRED_HEADSET,
697
+ android.media.AudioDeviceInfo.TYPE_USB_HEADSET -> devices.add("Headset")
604
698
  }
605
- } else {
606
- @Suppress("DEPRECATION")
607
- if (audioManager?.isBluetoothA2dpOn == true || audioManager?.isBluetoothScoOn == true) { devices.add("Bluetooth") }
608
- @Suppress("DEPRECATION")
609
- if (audioManager?.isWiredHeadsetOn == true) { devices.add("Headset") }
610
699
  }
611
- Log.d(TAG, "Available audio devices: ${devices.toList()}, current: $currentAudioRoute")
612
- val deviceHolders = devices.map { StringHolder(it) }.toTypedArray()
613
- return AudioRoutesInfo(deviceHolders, currentAudioRoute)
614
- }
615
700
 
616
- fun onTelecomAudioRouteChanged(callId: String, audioState: CallAudioState) {
617
- Log.d(TAG, "onTelecomAudioRouteChanged for $callId: route=${audioState.route}")
618
- val routeString = when (audioState.route) {
619
- CallAudioState.ROUTE_EARPIECE -> "Earpiece"
620
- CallAudioState.ROUTE_SPEAKER -> "Speaker"
621
- CallAudioState.ROUTE_BLUETOOTH -> "Bluetooth"
622
- CallAudioState.ROUTE_WIRED_HEADSET -> "Headset"
623
- else -> "Unknown"
624
- }
625
- if (currentAudioRoute != routeString) {
626
- Log.d(TAG, "Audio route has changed. New route: $routeString")
627
- currentAudioRoute = routeString
628
- emitAudioRouteChanged(currentAudioRoute)
629
- }
701
+ val currentRoute = (telecomConnections[currentCallId] as? MyConnection)
702
+ ?.getCurrentRouteString() ?: "Unknown"
703
+
704
+ Log.d(TAG, "getAudioDevices: Available=${devices.toList()}, Current=$currentRoute")
705
+ val deviceHolders = devices.map { StringHolder(it) }.toTypedArray()
706
+ return AudioRoutesInfo(deviceHolders, currentRoute)
630
707
  }
631
708
 
632
709
  fun setAudioRoute(route: String) {
633
- Log.d(TAG, "User requested audio route change to: $route")
634
- val telecomRoute = when (route) {
635
- "Speaker" -> CallAudioState.ROUTE_SPEAKER
636
- "Earpiece" -> CallAudioState.ROUTE_EARPIECE
637
- "Bluetooth" -> CallAudioState.ROUTE_BLUETOOTH
638
- "Headset" -> CallAudioState.ROUTE_WIRED_HEADSET
639
- else -> {
640
- Log.w(TAG, "Unknown audio route requested: $route")
641
- return
642
- }
643
- }
644
- currentCallId?.let { callId ->
645
- val connection = telecomConnections[callId] as? MyConnection
646
- if (connection != null) {
647
- Log.d(TAG, "Requesting audio route change via MyConnection for call $callId")
648
- connection.requestAudioRoute(telecomRoute)
649
- } else {
650
- Log.w(TAG, "Could not find MyConnection for active call $callId to set audio route.")
651
- }
652
- }
653
- }
654
-
655
- private fun isWiredHeadsetConnected(): Boolean {
656
- val am = audioManager ?: return false
657
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
658
- val devices = am.getDevices(AudioManager.GET_DEVICES_ALL)
659
- return devices.any { it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || it.type == AudioDeviceInfo.TYPE_USB_HEADSET }
660
- } else {
661
- @Suppress("DEPRECATION")
662
- return am.isWiredHeadsetOn
710
+ Log.d(TAG, "setAudioRoute requested for: $route")
711
+ val connection = telecomConnections[currentCallId] as? MyConnection ?: run {
712
+ Log.w(TAG, "Cannot set audio route, no active connection found for callId: $currentCallId")
713
+ return
663
714
  }
664
- }
665
715
 
666
- private fun isBluetoothDeviceConnected(): Boolean {
667
- val am = audioManager ?: return false
668
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
669
- val devices = am.getDevices(AudioManager.GET_DEVICES_ALL)
670
- return devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || it.type == AudioDeviceInfo.TYPE_BLE_HEADSET }
671
- } else {
672
- @Suppress("DEPRECATION")
673
- return am.isBluetoothA2dpOn || am.isBluetoothScoOn
716
+ val telecomRoute = when (route) {
717
+ "Speaker" -> CallAudioState.ROUTE_SPEAKER
718
+ "Earpiece" -> CallAudioState.ROUTE_EARPIECE
719
+ "Bluetooth" -> CallAudioState.ROUTE_BLUETOOTH
720
+ "Headset" -> CallAudioState.ROUTE_WIRED_HEADSET
721
+ else -> {
722
+ Log.w(TAG, "Unknown audio route string: $route")
723
+ return
724
+ }
674
725
  }
726
+ connection.requestAudioRouteChange(telecomRoute)
675
727
  }
676
728
 
677
729
  /**
678
- * FIX: This is now called from MyConnection when the call becomes active.
730
+ * Called by MyConnection when the audio route changes. This is the single
731
+ * source of truth for route updates.
732
+ */
733
+ fun onTelecomAudioRouteChanged(callId: String, newRoute: String) {
734
+ Log.d(TAG, "onTelecomAudioRouteChanged for callId $callId, new route: $newRoute")
735
+ emitAudioRouteChanged()
736
+ }
737
+
738
+ /**
739
+ * Called by MyConnection when it becomes active to set the initial, logical
740
+ * audio route.
679
741
  */
680
742
  fun setInitialAudioRouteForCall(callId: String, callType: String) {
681
- Log.d(TAG, "Setting initial audio route for call $callId, type: $callType")
743
+ val am = audioManager ?: return
744
+ val connection = telecomConnections[callId] as? MyConnection ?: return
745
+
746
+ // Determine default route based on Android standards
682
747
  val defaultRoute = when {
683
- isBluetoothDeviceConnected() -> "Bluetooth"
684
- isWiredHeadsetConnected() -> "Headset"
748
+ connection.isBluetoothAvailable() -> "Bluetooth"
749
+ am.isWiredHeadsetOn -> "Headset"
685
750
  callType.equals("Video", ignoreCase = true) -> "Speaker"
686
751
  else -> "Earpiece"
687
752
  }
688
- Log.d(TAG, "Requesting initial audio route to be: $defaultRoute")
689
- setAudioRoute(defaultRoute)
690
- }
691
-
692
- private fun setAudioMode() {
693
- audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION
694
- Log.d(TAG, "Audio mode set to MODE_IN_COMMUNICATION")
695
- }
696
-
697
- private fun resetAudioMode() {
698
- if (activeCalls.isEmpty()) {
699
- audioManager?.mode = AudioManager.MODE_NORMAL
700
- currentAudioRoute = "Unknown"
701
- unregisterAudioDeviceCallback()
702
- Log.d(TAG, "Audio mode reset to MODE_NORMAL")
703
- }
704
- }
705
753
 
706
- private fun emitAudioRouteChanged(currentRoute: String) {
707
- val info = getAudioDevices()
708
- val deviceStrings = info.devices.map { it.value }
709
- val payload = JSONObject().apply {
710
- put("devices", JSONArray(deviceStrings))
711
- put("currentRoute", currentRoute)
712
- }
713
- emitEvent(CallEventType.AUDIO_ROUTE_CHANGED, payload)
714
- Log.d(TAG, "Emitted AUDIO_ROUTE_CHANGED: $currentRoute, available: $deviceStrings")
715
- }
716
-
717
- private val audioDeviceCallback = object : AudioDeviceCallback() {
718
- override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
719
- Log.d(TAG, "Audio devices added, emitting change event.")
720
- emitAudioDevicesChanged()
721
- }
722
- override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
723
- Log.d(TAG, "Audio devices removed, emitting change event.")
724
- emitAudioDevicesChanged()
725
- }
754
+ Log.d(TAG, "Requesting initial audio route for call $callId: $defaultRoute")
755
+ setAudioRoute(defaultRoute)
726
756
  }
727
757
 
728
- private fun emitAudioDevicesChanged() {
758
+ private fun emitAudioRouteChanged() {
729
759
  val info = getAudioDevices()
730
760
  val deviceStrings = info.devices.map { it.value }
731
761
  val payload = JSONObject().apply {
732
762
  put("devices", JSONArray(deviceStrings))
733
763
  put("currentRoute", info.currentRoute)
734
764
  }
735
- emitEvent(CallEventType.AUDIO_DEVICES_CHANGED, payload)
736
- Log.d(TAG, "Emitted AUDIO_DEVICES_CHANGED: available: $deviceStrings")
765
+ emitEvent(CallEventType.AUDIO_ROUTE_CHANGED, payload)
766
+ Log.d(TAG, "Emitted AUDIO_ROUTE_CHANGED: current=${info.currentRoute}, available=$deviceStrings")
737
767
  }
738
768
 
739
- fun registerAudioDeviceCallback() {
740
- if (isCallActive()) {
741
- val context = requireContext()
742
- audioManager = audioManager ?: context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
743
- try {
744
- audioManager?.registerAudioDeviceCallback(audioDeviceCallback, mainHandler)
745
- Log.d(TAG, "Audio device callback registered")
746
- } catch (e: Exception) {
747
- Log.w(TAG, "Failed to register audio device callback: ${e.message}")
748
- }
749
- }
750
- }
751
-
752
- fun unregisterAudioDeviceCallback() {
753
- try {
754
- audioManager?.unregisterAudioDeviceCallback(audioDeviceCallback)
755
- Log.d(TAG, "Audio device callback unregistered")
756
- } catch (e: Exception) {
757
- Log.w(TAG, "Failed to unregister audio device callback: ${e.message}")
758
- }
759
- }
769
+ // ====== END AUDIO ROUTING SYSTEM ======
760
770
 
761
771
  fun keepScreenAwake(keepAwake: Boolean) {
762
772
  val context = requireContext()
@@ -791,7 +801,6 @@ object CallEngine {
791
801
  }
792
802
 
793
803
  private fun validateOutgoingCallRequest(): Boolean {
794
- if (canMakeMultipleCalls) return true
795
804
  return !activeCalls.any {
796
805
  it.value.state == CallState.INCOMING || it.value.state == CallState.ACTIVE
797
806
  }
@@ -802,37 +811,33 @@ object CallEngine {
802
811
  put("callId", callId)
803
812
  put("reason", reason)
804
813
  })
805
- // Only remove metadata if there's NO existing active call with this ID
806
- val existingCall = activeCalls[callId]
807
- if (existingCall == null) {
808
- callMetadata.remove(callId)
809
- Log.d(TAG, "Removed metadata for rejected call $callId (no existing call)")
810
- } else {
811
- Log.d(TAG, "Kept metadata for callId: $callId (existing call: ${existingCall.state})")
812
- }
814
+
815
+ // 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)")
820
+ } else {
821
+ Log.d(TAG, "Kept metadata for callId: $callId (existing call: ${existingCall.state})")
822
+ }
813
823
  }
814
824
 
815
825
  private fun createNotificationChannel() {
816
826
  val context = requireContext()
817
827
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
818
- val channel = NotificationChannel(NOTIF_CHANNEL_ID, "Incoming Call Channel", NotificationManager.IMPORTANCE_HIGH)
819
- channel.description = "Notifications for incoming calls"
820
- channel.enableLights(true)
821
- channel.lightColor = Color.GREEN
822
- channel.enableVibration(true)
823
- channel.setBypassDnd(true)
824
- channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
825
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
826
- channel.setSound(
827
- RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE),
828
- AudioAttributes.Builder()
829
- .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
830
- .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
831
- .build()
832
- )
833
- } else {
834
- channel.setSound(null, null)
835
- channel.importance = NotificationManager.IMPORTANCE_HIGH
828
+ val channel = NotificationChannel(
829
+ NOTIF_CHANNEL_ID,
830
+ "Incoming Call Channel",
831
+ NotificationManager.IMPORTANCE_HIGH
832
+ ).apply {
833
+ description = "Notifications for incoming calls"
834
+ enableLights(true)
835
+ lightColor = Color.GREEN
836
+ enableVibration(true)
837
+ setBypassDnd(true)
838
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
839
+ // Sound is handled by the RingtoneManager directly for better control
840
+ setSound(null, null)
836
841
  }
837
842
  val manager = context.getSystemService(NotificationManager::class.java)
838
843
  manager.createNotificationChannel(channel)
@@ -842,36 +847,37 @@ object CallEngine {
842
847
  private fun showIncomingCallUI(callId: String, callerName: String, callType: String, callerPicUrl: String?) {
843
848
  val context = requireContext()
844
849
  Log.d(TAG, "Showing incoming call UI for $callId")
850
+
845
851
  val useCallStyleNotification = supportsCallStyleNotifications()
846
852
  Log.d(TAG, "Using CallStyle notification: $useCallStyleNotification")
847
- if (isDeviceLocked(context) || !useCallStyleNotification) {
848
- Log.d(TAG, "Device is locked or CallStyle not supported - using overlay/fallback approach")
853
+
854
+ val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
855
+ if (keyguardManager.isKeyguardLocked || !useCallStyleNotification) {
856
+ Log.d(TAG, "Device is locked or CallStyle not supported - using full-screen CallActivity.")
849
857
  showCallActivityOverlay(context, callId, callerName, callType, callerPicUrl)
850
858
  } else {
851
- Log.d(TAG, "Device is unlocked and supports CallStyle - using enhanced notification")
859
+ Log.d(TAG, "Device is unlocked and supports CallStyle - using heads-up notification.")
852
860
  showStandardNotification(context, callId, callerName, callType, callerPicUrl)
853
861
  }
854
862
  playRingtone()
855
863
  }
856
864
 
857
- private fun isDeviceLocked(context: Context): Boolean {
858
- val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
859
- return keyguardManager.isKeyguardLocked
860
- }
861
-
862
865
  private fun showCallActivityOverlay(context: Context, callId: String, callerName: String, callType: String, callerPicUrl: String?) {
863
866
  val overlayIntent = Intent(context, CallActivity::class.java).apply {
864
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
867
+ addFlags(
868
+ Intent.FLAG_ACTIVITY_NEW_TASK or
869
+ Intent.FLAG_ACTIVITY_CLEAR_TASK or
870
+ Intent.FLAG_ACTIVITY_NO_ANIMATION or
871
+ Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
872
+ )
865
873
  putExtra("callId", callId)
866
874
  putExtra("callerName", callerName)
867
875
  putExtra("callType", callType)
868
876
  callerPicUrl?.let { putExtra("callerAvatar", it) }
869
877
  putExtra("LOCK_SCREEN_MODE", true)
870
878
  }
879
+
871
880
  try {
872
- val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
873
- val wakeLock = powerManager.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP, "CallEngine:LockScreenWake")
874
- wakeLock.acquire(5000)
875
881
  context.startActivity(overlayIntent)
876
882
  Log.d(TAG, "Successfully launched CallActivity overlay")
877
883
  } catch (e: Exception) {
@@ -883,6 +889,7 @@ object CallEngine {
883
889
  private fun showStandardNotification(context: Context, callId: String, callerName: String, callType: String, callerPicUrl: String?) {
884
890
  createNotificationChannel()
885
891
  val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
892
+
886
893
  val fullScreenIntent = Intent(context, CallActivity::class.java).apply {
887
894
  addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
888
895
  putExtra("callId", callId)
@@ -890,48 +897,84 @@ object CallEngine {
890
897
  putExtra("callType", callType)
891
898
  callerPicUrl?.let { putExtra("callerAvatar", it) }
892
899
  }
893
- val fullScreenPendingIntent = PendingIntent.getActivity(context, callId.hashCode(), fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
900
+
901
+ val fullScreenPendingIntent = PendingIntent.getActivity(
902
+ context, callId.hashCode(), fullScreenIntent,
903
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
904
+ )
905
+
894
906
  val answerIntent = Intent(context, CallNotificationActionReceiver::class.java).apply {
895
907
  action = "com.qusaieilouti99.callmanager.ANSWER_CALL"
896
908
  putExtra("callId", callId)
897
909
  }
898
- val answerPendingIntent = PendingIntent.getBroadcast(context, 0, answerIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
910
+ val answerPendingIntent = PendingIntent.getBroadcast(
911
+ context, 0, answerIntent,
912
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
913
+ )
914
+
899
915
  val declineIntent = Intent(context, CallNotificationActionReceiver::class.java).apply {
900
916
  action = "com.qusaieilouti99.callmanager.DECLINE_CALL"
901
917
  putExtra("callId", callId)
902
918
  }
903
- val declinePendingIntent = PendingIntent.getBroadcast(context, 1, declineIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
919
+ val declinePendingIntent = PendingIntent.getBroadcast(
920
+ context, 1, declineIntent,
921
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
922
+ )
923
+
904
924
  val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && supportsCallStyleNotifications()) {
905
- val person = android.app.Person.Builder().setName(callerName).setImportant(true).build()
925
+ val person = android.app.Person.Builder()
926
+ .setName(callerName)
927
+ .setImportant(true)
928
+ .build()
906
929
  Notification.Builder(context, NOTIF_CHANNEL_ID)
907
930
  .setSmallIcon(android.R.drawable.sym_call_incoming)
908
- .setStyle(Notification.CallStyle.forIncomingCall(person, declinePendingIntent, answerPendingIntent))
931
+ .setStyle(
932
+ Notification.CallStyle.forIncomingCall(
933
+ person,
934
+ declinePendingIntent,
935
+ answerPendingIntent
936
+ )
937
+ )
909
938
  .setFullScreenIntent(fullScreenPendingIntent, true)
910
- .setOngoing(true).setAutoCancel(false).setCategory(Notification.CATEGORY_CALL)
911
- .setPriority(Notification.PRIORITY_MAX).setVisibility(Notification.VISIBILITY_PUBLIC).build()
939
+ .setOngoing(true)
940
+ .setCategory(Notification.CATEGORY_CALL)
941
+ .setVisibility(Notification.VISIBILITY_PUBLIC)
942
+ .build()
912
943
  } else {
913
944
  Notification.Builder(context, NOTIF_CHANNEL_ID)
914
945
  .setSmallIcon(android.R.drawable.sym_call_incoming)
915
- .setContentTitle("Incoming Call").setContentText(callerName)
916
- .setPriority(Notification.PRIORITY_MAX).setCategory(Notification.CATEGORY_CALL)
946
+ .setContentTitle("Incoming Call")
947
+ .setContentText(callerName)
948
+ .setPriority(Notification.PRIORITY_MAX)
949
+ .setCategory(Notification.CATEGORY_CALL)
917
950
  .setFullScreenIntent(fullScreenPendingIntent, true)
918
951
  .addAction(android.R.drawable.sym_action_call, "Answer", answerPendingIntent)
919
952
  .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Decline", declinePendingIntent)
920
- .setOngoing(true).setAutoCancel(false).setVisibility(Notification.VISIBILITY_PUBLIC).build()
953
+ .setOngoing(true)
954
+ .setVisibility(Notification.VISIBILITY_PUBLIC)
955
+ .build()
921
956
  }
957
+
922
958
  notificationManager.notify(NOTIF_ID, notification)
923
959
  }
924
960
 
925
961
  fun cancelIncomingCallUI() {
926
962
  val context = requireContext()
927
- val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
963
+ val notificationManager =
964
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
928
965
  notificationManager.cancel(NOTIF_ID)
929
966
  stopRingtone()
930
967
  }
931
968
 
932
969
  private fun startForegroundService() {
933
970
  val context = requireContext()
934
- val currentCall = activeCalls.values.find { it.state == CallState.ACTIVE || it.state == CallState.INCOMING || it.state == CallState.DIALING || it.state == CallState.HELD }
971
+ val currentCall = activeCalls.values.find {
972
+ it.state == CallState.ACTIVE ||
973
+ it.state == CallState.INCOMING ||
974
+ it.state == CallState.DIALING ||
975
+ it.state == CallState.HELD
976
+ }
977
+
935
978
  val intent = Intent(context, CallForegroundService::class.java)
936
979
  currentCall?.let {
937
980
  intent.putExtra("callId", it.callId)
@@ -939,6 +982,7 @@ object CallEngine {
939
982
  intent.putExtra("displayName", it.displayName)
940
983
  intent.putExtra("state", it.state.name)
941
984
  }
985
+
942
986
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
943
987
  context.startForegroundService(intent)
944
988
  } else {
@@ -958,28 +1002,16 @@ object CallEngine {
958
1002
 
959
1003
  private fun isMainActivityInForeground(): Boolean {
960
1004
  val context = requireContext()
961
- val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
962
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
963
- try {
964
- val tasks = activityManager.appTasks
965
- if (tasks.isNotEmpty()) {
966
- val taskInfo = tasks[0].taskInfo
967
- return taskInfo.topActivity?.className?.contains("MainActivity") == true
968
- }
969
- } catch (e: Exception) {
970
- Log.w(TAG, "Failed to get app tasks: ${e.message}")
971
- }
972
- } else {
973
- try {
974
- @Suppress("DEPRECATION")
975
- val tasks = activityManager.getRunningTasks(1)
976
- if (tasks.isNotEmpty()) {
977
- val runningTaskInfo = tasks[0]
978
- return runningTaskInfo.topActivity?.className?.contains("MainActivity") == true
979
- }
980
- } catch (e: Exception) {
981
- Log.w(TAG, "Failed to get running tasks: ${e.message}")
1005
+ val activityManager =
1006
+ context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
1007
+ try {
1008
+ val tasks = activityManager.appTasks
1009
+ if (tasks.isNotEmpty()) {
1010
+ val taskInfo = tasks[0].taskInfo
1011
+ return taskInfo.topActivity?.className?.contains("MainActivity") == true
982
1012
  }
1013
+ } catch (e: Exception) {
1014
+ Log.w(TAG, "Failed to get app tasks: ${e.message}")
983
1015
  }
984
1016
  return false
985
1017
  }
@@ -989,17 +1021,23 @@ object CallEngine {
989
1021
  Log.d(TAG, "MainActivity is already in foreground, skipping")
990
1022
  return
991
1023
  }
1024
+
992
1025
  Log.d(TAG, "Bringing app to foreground")
993
1026
  val context = requireContext()
994
1027
  val packageName = context.packageName
995
1028
  val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
996
- launchIntent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
1029
+ launchIntent?.addFlags(
1030
+ Intent.FLAG_ACTIVITY_NEW_TASK or
1031
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or
1032
+ Intent.FLAG_ACTIVITY_SINGLE_TOP
1033
+ )
1034
+
997
1035
  if (isCallActive()) {
998
1036
  launchIntent?.putExtra("BYPASS_LOCK_SCREEN", true)
999
1037
  }
1038
+
1000
1039
  try {
1001
1040
  context.startActivity(launchIntent)
1002
- Handler(Looper.getMainLooper()).postDelayed({ updateLockScreenBypass() }, 100)
1003
1041
  } catch (e: Exception) {
1004
1042
  Log.e(TAG, "Failed to bring app to foreground: ${e.message}")
1005
1043
  }
@@ -1007,12 +1045,15 @@ object CallEngine {
1007
1045
 
1008
1046
  private fun registerPhoneAccount() {
1009
1047
  val context = requireContext()
1010
- val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
1048
+ val telecomManager =
1049
+ context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
1011
1050
  val phoneAccountHandle = getPhoneAccountHandle()
1051
+
1012
1052
  if (telecomManager.getPhoneAccount(phoneAccountHandle) == null) {
1013
1053
  val phoneAccount = PhoneAccount.builder(phoneAccountHandle, "PingMe Call")
1014
1054
  .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED)
1015
1055
  .build()
1056
+
1016
1057
  try {
1017
1058
  telecomManager.registerPhoneAccount(phoneAccount)
1018
1059
  Log.d(TAG, "PhoneAccount registered successfully")
@@ -1024,13 +1065,16 @@ object CallEngine {
1024
1065
 
1025
1066
  private fun getPhoneAccountHandle(): PhoneAccountHandle {
1026
1067
  val context = requireContext()
1027
- return PhoneAccountHandle(ComponentName(context, MyConnectionService::class.java), PHONE_ACCOUNT_ID)
1068
+ return PhoneAccountHandle(
1069
+ ComponentName(context, MyConnectionService::class.java),
1070
+ PHONE_ACCOUNT_ID
1071
+ )
1028
1072
  }
1029
1073
 
1030
1074
  private fun playRingtone() {
1031
1075
  val context = requireContext()
1032
- audioManager = audioManager ?: context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
1033
1076
  audioManager?.mode = AudioManager.MODE_RINGTONE
1077
+
1034
1078
  vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
1035
1079
  vibrator?.let { v ->
1036
1080
  val pattern = longArrayOf(0L, 500L, 500L)
@@ -1041,6 +1085,7 @@ object CallEngine {
1041
1085
  v.vibrate(pattern, 0)
1042
1086
  }
1043
1087
  }
1088
+
1044
1089
  try {
1045
1090
  val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
1046
1091
  ringtone = RingtoneManager.getRingtone(context, uri)
@@ -1059,6 +1104,7 @@ object CallEngine {
1059
1104
  Log.e(TAG, "Error stopping ringtone", e)
1060
1105
  }
1061
1106
  ringtone = null
1107
+
1062
1108
  vibrator?.cancel()
1063
1109
  vibrator = null
1064
1110
  }
@@ -1066,8 +1112,10 @@ object CallEngine {
1066
1112
  private fun startRingback() {
1067
1113
  val context = requireContext()
1068
1114
  if (ringbackPlayer?.isPlaying == true) return
1115
+
1069
1116
  try {
1070
- val ringbackUri = Uri.parse("android.resource://${context.packageName}/raw/ringback_tone")
1117
+ val ringbackUri =
1118
+ Uri.parse("android.resource://${context.packageName}/raw/ringback_tone")
1071
1119
  ringbackPlayer = MediaPlayer.create(context, ringbackUri)
1072
1120
  ringbackPlayer?.apply {
1073
1121
  isLooping = true
@@ -1093,14 +1141,15 @@ object CallEngine {
1093
1141
  Log.d(TAG, "Performing cleanup")
1094
1142
  stopForegroundService()
1095
1143
  keepScreenAwake(false)
1096
- resetAudioMode()
1144
+ // Reset audio mode via AudioManager when all calls are truly gone
1145
+ audioManager?.mode = AudioManager.MODE_NORMAL
1097
1146
  }
1098
1147
 
1099
1148
  fun onApplicationTerminate() {
1100
1149
  Log.d(TAG, "Application terminating")
1101
1150
  activeCalls.keys.toList().forEach { callId ->
1102
1151
  telecomConnections[callId]?.let { conn ->
1103
- conn.setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
1152
+ conn.setDisconnected(DisconnectCause(DisconnectCause.OTHER))
1104
1153
  conn.destroy()
1105
1154
  }
1106
1155
  }