@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.
- package/android/src/main/java/com/margelo/nitro/qusaieilouti99/callmanager/CallEngine.kt +433 -252
- package/android/src/main/java/com/margelo/nitro/qusaieilouti99/callmanager/MyConnection.kt +84 -25
- package/android/src/main/java/com/margelo/nitro/qusaieilouti99/callmanager/MyConnectionService.kt +81 -26
- package/package.json +1 -1
|
@@ -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 call
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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() //
|
|
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
|
-
|
|
584
|
+
emitCallEventWithMetadata(CallEventType.CALL_ANSWERED, callId)
|
|
498
585
|
} else {
|
|
499
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
// ======
|
|
795
|
+
// ====== AUDIO ROUTING SYSTEM using Telecom CallEndpoint API (API 34+) ======
|
|
729
796
|
|
|
730
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
802
|
-
//
|
|
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)
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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() //
|
|
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
|
-
//
|
|
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
|
|
941
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
1010
|
-
// if
|
|
1011
|
-
//
|
|
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
|
-
|
|
1030
|
-
val isDeviceLocked =
|
|
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
|
|
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) //
|
|
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)) //
|
|
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
|
-
//
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
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
|