expo-callkit-telecom 0.1.0

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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +197 -0
  3. package/android/build.gradle +32 -0
  4. package/android/src/main/AndroidManifest.xml +33 -0
  5. package/android/src/main/java/expo/modules/callkittelecom/ExpoCallKitTelecomModule.kt +384 -0
  6. package/android/src/main/java/expo/modules/callkittelecom/IncomingCallActivity.kt +275 -0
  7. package/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt +151 -0
  8. package/android/src/main/java/expo/modules/callkittelecom/events/CallEvents.kt +59 -0
  9. package/android/src/main/java/expo/modules/callkittelecom/managers/CallAudioManager.kt +361 -0
  10. package/android/src/main/java/expo/modules/callkittelecom/managers/CallManager.kt +891 -0
  11. package/android/src/main/java/expo/modules/callkittelecom/managers/CallNotificationManager.kt +445 -0
  12. package/android/src/main/java/expo/modules/callkittelecom/managers/CaptureSessionManager.kt +27 -0
  13. package/android/src/main/java/expo/modules/callkittelecom/managers/DialtonePlayer.kt +171 -0
  14. package/android/src/main/java/expo/modules/callkittelecom/managers/FulfillRequestManager.kt +150 -0
  15. package/android/src/main/java/expo/modules/callkittelecom/managers/VoIPPushManager.kt +54 -0
  16. package/android/src/main/java/expo/modules/callkittelecom/models/CallModels.kt +269 -0
  17. package/android/src/main/java/expo/modules/callkittelecom/services/CallNotificationReceiver.kt +54 -0
  18. package/android/src/main/java/expo/modules/callkittelecom/services/ExpoCallKitTelecomMessagingService.kt +161 -0
  19. package/android/src/main/java/expo/modules/callkittelecom/store/CallStore.kt +181 -0
  20. package/android/src/main/java/expo/modules/callkittelecom/utils/CallKitTelecomLog.kt +52 -0
  21. package/android/src/main/java/expo/modules/callkittelecom/utils/PermissionUtils.kt +28 -0
  22. package/android/src/main/res/drawable/expo_callkit_telecom_bg_answer.xml +9 -0
  23. package/android/src/main/res/drawable/expo_callkit_telecom_bg_avatar.xml +5 -0
  24. package/android/src/main/res/drawable/expo_callkit_telecom_bg_decline.xml +9 -0
  25. package/android/src/main/res/drawable/expo_callkit_telecom_ic_answer.xml +9 -0
  26. package/android/src/main/res/drawable/expo_callkit_telecom_ic_decline.xml +9 -0
  27. package/android/src/main/res/drawable/expo_callkit_telecom_ic_videocam.xml +9 -0
  28. package/android/src/main/res/layout/activity_incoming_call.xml +169 -0
  29. package/app.json +8 -0
  30. package/app.plugin.js +1 -0
  31. package/build/Calls.d.ts +577 -0
  32. package/build/Calls.d.ts.map +1 -0
  33. package/build/Calls.js +715 -0
  34. package/build/Calls.js.map +1 -0
  35. package/build/Calls.types.d.ts +203 -0
  36. package/build/Calls.types.d.ts.map +1 -0
  37. package/build/Calls.types.js +2 -0
  38. package/build/Calls.types.js.map +1 -0
  39. package/build/ExpoCallKitTelecomModule.d.ts +3 -0
  40. package/build/ExpoCallKitTelecomModule.d.ts.map +1 -0
  41. package/build/ExpoCallKitTelecomModule.js +4 -0
  42. package/build/ExpoCallKitTelecomModule.js.map +1 -0
  43. package/build/hooks/index.d.ts +2 -0
  44. package/build/hooks/index.d.ts.map +1 -0
  45. package/build/hooks/index.js +2 -0
  46. package/build/hooks/index.js.map +1 -0
  47. package/build/hooks/useVoIPPushToken.d.ts +14 -0
  48. package/build/hooks/useVoIPPushToken.d.ts.map +1 -0
  49. package/build/hooks/useVoIPPushToken.js +26 -0
  50. package/build/hooks/useVoIPPushToken.js.map +1 -0
  51. package/build/index.d.ts +4 -0
  52. package/build/index.d.ts.map +1 -0
  53. package/build/index.js +4 -0
  54. package/build/index.js.map +1 -0
  55. package/expo-module.config.json +10 -0
  56. package/ios/AppDelegateSubscriber.swift +93 -0
  57. package/ios/ExpoCallKitTelecom.podspec +31 -0
  58. package/ios/ExpoCallKitTelecomLogger.swift +55 -0
  59. package/ios/ExpoCallKitTelecomModule.swift +503 -0
  60. package/ios/Managers/AudioManager.swift +363 -0
  61. package/ios/Managers/CallEventEmitter.swift +199 -0
  62. package/ios/Managers/CallManager+CXProviderDelegate.swift +195 -0
  63. package/ios/Managers/CallManager.swift +714 -0
  64. package/ios/Managers/CaptureSessionManager.swift +54 -0
  65. package/ios/Managers/DialtonePlayer.swift +126 -0
  66. package/ios/Managers/FulfillRequestManager.swift +154 -0
  67. package/ios/Managers/VoIPPushManager+PKPushRegistryDelegate.swift +123 -0
  68. package/ios/Managers/VoIPPushManager.swift +58 -0
  69. package/ios/Models/CallEvents.swift +263 -0
  70. package/ios/Models/CallOptions.swift +15 -0
  71. package/ios/Models/CallParticipant.swift +37 -0
  72. package/ios/Models/CallSession.swift +80 -0
  73. package/ios/Models/IncomingCallEvent.swift +196 -0
  74. package/ios/Stores/CallStore.swift +149 -0
  75. package/package.json +56 -0
  76. package/plugin/build/constants.d.ts +3 -0
  77. package/plugin/build/constants.js +7 -0
  78. package/plugin/build/withExpoCallKitTelecom.d.ts +67 -0
  79. package/plugin/build/withExpoCallKitTelecom.js +16 -0
  80. package/plugin/build/withExpoCallKitTelecomAndroid.d.ts +3 -0
  81. package/plugin/build/withExpoCallKitTelecomAndroid.js +177 -0
  82. package/plugin/build/withExpoCallKitTelecomIos.d.ts +3 -0
  83. package/plugin/build/withExpoCallKitTelecomIos.js +195 -0
  84. package/plugin/src/constants.ts +4 -0
  85. package/plugin/src/withExpoCallKitTelecom.ts +83 -0
  86. package/plugin/src/withExpoCallKitTelecomAndroid.ts +293 -0
  87. package/plugin/src/withExpoCallKitTelecomIos.ts +276 -0
  88. package/src/Calls.ts +848 -0
  89. package/src/Calls.types.ts +275 -0
  90. package/src/ExpoCallKitTelecomModule.ts +4 -0
  91. package/src/hooks/index.ts +1 -0
  92. package/src/hooks/useVoIPPushToken.ts +34 -0
  93. package/src/index.ts +3 -0
@@ -0,0 +1,891 @@
1
+ package expo.modules.callkittelecom.managers
2
+
3
+ import android.content.Context
4
+ import android.content.pm.PackageManager
5
+ import android.net.Uri
6
+ import android.telecom.DisconnectCause
7
+ import android.telecom.PhoneAccount
8
+ import androidx.core.telecom.CallAttributesCompat
9
+ import androidx.core.telecom.CallControlResult
10
+ import androidx.core.telecom.CallControlScope
11
+ import androidx.core.telecom.CallEndpointCompat
12
+ import androidx.core.telecom.CallsManager
13
+ import expo.modules.callkittelecom.events.CallEventEmitter
14
+ import expo.modules.callkittelecom.events.CallEvents
15
+ import expo.modules.callkittelecom.events.CallEvents.AUDIO_SESSION_ACTIVATED
16
+ import expo.modules.callkittelecom.events.CallEvents.AUDIO_SESSION_DEACTIVATED
17
+ import expo.modules.callkittelecom.events.CallEvents.CALL_ANSWERED
18
+ import expo.modules.callkittelecom.events.CallEvents.CALL_INTENT_RECEIVED
19
+ import expo.modules.callkittelecom.events.CallEvents.INCOMING_CALL_REPORTED
20
+ import expo.modules.callkittelecom.events.CallEvents.VOIP_PUSH_TOKEN_UPDATED
21
+ import expo.modules.callkittelecom.models.CallEndedReason
22
+ import expo.modules.callkittelecom.models.CallOptions
23
+ import expo.modules.callkittelecom.models.CallParticipant
24
+ import expo.modules.callkittelecom.models.CallSession
25
+ import expo.modules.callkittelecom.models.CallSessionOrigin
26
+ import expo.modules.callkittelecom.models.CallSessionStatus
27
+ import expo.modules.callkittelecom.models.IncomingCallEvent
28
+ import expo.modules.callkittelecom.store.CallStore
29
+ import expo.modules.callkittelecom.utils.CallKitTelecomLog
30
+ import kotlinx.coroutines.CancellationException
31
+ import kotlinx.coroutines.CoroutineName
32
+ import kotlinx.coroutines.CoroutineScope
33
+ import kotlinx.coroutines.Dispatchers
34
+ import kotlinx.coroutines.Job
35
+ import kotlinx.coroutines.SupervisorJob
36
+ import kotlinx.coroutines.channels.Channel
37
+ import kotlinx.coroutines.currentCoroutineContext
38
+ import kotlinx.coroutines.delay
39
+ import kotlinx.coroutines.isActive
40
+ import kotlinx.coroutines.launch
41
+ import kotlinx.coroutines.selects.select
42
+ import java.time.Instant
43
+ import java.util.UUID
44
+ import java.util.concurrent.ConcurrentHashMap
45
+
46
+ /**
47
+ * Central Android call lifecycle manager using Core-Telecom Jetpack.
48
+ *
49
+ * Responsibilities:
50
+ * - Create/report calls through Core-Telecom's CallsManager
51
+ * - Maintain native call session state
52
+ * - Emit shared JS call events in expected order
53
+ * - Coordinate audio lifecycle and request fulfillment semantics
54
+ */
55
+ class CallManager private constructor() {
56
+ companion object {
57
+ private const val TAG = "ExpoCallKitTelecom.Call"
58
+
59
+ /** Shared singleton instance used by module and notification receiver. */
60
+ val shared = CallManager()
61
+ }
62
+
63
+ private lateinit var context: Context
64
+ private lateinit var callsManager: CallsManager
65
+
66
+ private var isInitialized = false
67
+
68
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
69
+
70
+ /**
71
+ * Channels for dispatching actions into active Core-Telecom call scopes.
72
+ *
73
+ * Scope methods are called from within the addCall block via channel-based
74
+ * dispatch rather than storing and calling CallControlScope references externally.
75
+ */
76
+ private class CallActions {
77
+ val setActive = Channel<Unit>(Channel.CONFLATED)
78
+ val setInactive = Channel<Unit>(Channel.CONFLATED)
79
+ val disconnect = Channel<DisconnectCause>(Channel.CONFLATED)
80
+ val endpointChange = Channel<CallEndpointCompat>(Channel.CONFLATED)
81
+ }
82
+
83
+ /**
84
+ * Encapsulates the coroutine job, action channels, and timeout for a single call.
85
+ * Consolidates what was previously three separate maps.
86
+ */
87
+ private class CallController(
88
+ val job: Job,
89
+ val actions: CallActions,
90
+ var timeoutJob: Job? = null,
91
+ )
92
+
93
+ /** Active call controllers, keyed by call UUID. */
94
+ private val activeCalls = ConcurrentHashMap<UUID, CallController>()
95
+
96
+ private var incomingCallTimeoutMs = 45_000L
97
+ private var outgoingCallTimeoutMs = 60_000L
98
+ private var fulfillAnswerTimeoutMs = 30_000L
99
+
100
+ /** Initializes Core-Telecom CallsManager + dependent managers. Safe to call repeatedly. */
101
+ fun initialize(appContext: Context) {
102
+ if (isInitialized) return
103
+
104
+ context = appContext.applicationContext
105
+ CallKitTelecomLog.init(context)
106
+
107
+ // Configure event queue limits early so events emitted before the Expo
108
+ // module loads (e.g. CALL_ANSWERED during cold-start answer) are queued
109
+ // instead of dropped by the default limit of 0.
110
+ CallEventEmitter.setQueueLimit(CALL_INTENT_RECEIVED, 1)
111
+ CallEventEmitter.setQueueLimit(AUDIO_SESSION_ACTIVATED, 1)
112
+ CallEventEmitter.setQueueLimit(AUDIO_SESSION_DEACTIVATED, 1)
113
+ CallEventEmitter.setQueueLimit(INCOMING_CALL_REPORTED, 1)
114
+ CallEventEmitter.setQueueLimit(CALL_ANSWERED, 1)
115
+ CallEventEmitter.setQueueLimit(VOIP_PUSH_TOKEN_UPDATED, 1)
116
+
117
+ callsManager = CallsManager(context)
118
+ callsManager.registerAppWithTelecom(
119
+ CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING,
120
+ )
121
+
122
+ incomingCallTimeoutMs = readTimeoutMs("ExpoCallKitTelecomIncomingCallTimeout", incomingCallTimeoutMs)
123
+ outgoingCallTimeoutMs = readTimeoutMs("ExpoCallKitTelecomOutgoingCallTimeout", outgoingCallTimeoutMs)
124
+ fulfillAnswerTimeoutMs = readTimeoutMs("ExpoCallKitTelecomFulfillAnswerCallTimeout", fulfillAnswerTimeoutMs)
125
+
126
+ CallAudioManager.initialize(context)
127
+ CallAudioManager.onRequestEndpointChange = { endpoint ->
128
+ val activeId = CallStore.firstSession()?.id
129
+ if (activeId != null) {
130
+ activeCalls[activeId]?.actions?.endpointChange?.trySend(endpoint)
131
+ }
132
+ }
133
+ DialtonePlayer.initialize(context)
134
+ CaptureSessionManager.initialize(context)
135
+ CallNotificationManager.initialize(context)
136
+
137
+ isInitialized = true
138
+ CallKitTelecomLog.d(TAG) { "Initialized CallManager" }
139
+ }
140
+
141
+ /** Reads a timeout from Android manifest metadata (seconds) and returns milliseconds. */
142
+ private fun readTimeoutMs(
143
+ key: String,
144
+ defaultMs: Long,
145
+ ): Long =
146
+ try {
147
+ val defaultSeconds = (defaultMs / 1000).toInt()
148
+ val appInfo =
149
+ context.packageManager.getApplicationInfo(
150
+ context.packageName,
151
+ PackageManager.GET_META_DATA,
152
+ )
153
+ val seconds = appInfo.metaData?.getInt(key, defaultSeconds) ?: defaultSeconds
154
+ seconds.toLong() * 1000
155
+ } catch (_: Throwable) {
156
+ defaultMs
157
+ }
158
+
159
+ // region Call Timeout
160
+
161
+ /** Starts a call timeout that marks non-connected calls as unanswered. */
162
+ private fun startCallTimeout(
163
+ id: UUID,
164
+ timeoutMs: Long,
165
+ ) {
166
+ cancelCallTimeout(id)
167
+ CallKitTelecomLog.d(TAG) { "Starting call timeout - id: $id, timeout: ${timeoutMs}ms" }
168
+
169
+ val job =
170
+ scope.launch {
171
+ delay(timeoutMs)
172
+ val session = CallStore.session(id) ?: return@launch
173
+ if (session.status == CallSessionStatus.CONNECTED) {
174
+ return@launch
175
+ }
176
+ CallKitTelecomLog.d(TAG) { "Call timeout expired - id: $id" }
177
+ DialtonePlayer.stop()
178
+ reportCallEnded(id, CallEndedReason.UNANSWERED)
179
+ }
180
+
181
+ activeCalls[id]?.timeoutJob = job
182
+ }
183
+
184
+ /** Cancels an active timeout for a specific call UUID. */
185
+ private fun cancelCallTimeout(id: UUID) {
186
+ val job = activeCalls[id]?.timeoutJob ?: return
187
+ job.cancel()
188
+ activeCalls[id]?.timeoutJob = null
189
+ CallKitTelecomLog.d(TAG) { "Cancelled call timeout - id: $id" }
190
+ }
191
+
192
+ // endregion
193
+
194
+ /** Creates a Telecom URI from participant fields, preferring phone number. */
195
+ private fun participantUri(participant: CallParticipant): Uri {
196
+ val phoneNumber = participant.phoneNumber
197
+ if (!phoneNumber.isNullOrBlank()) {
198
+ return Uri.fromParts(PhoneAccount.SCHEME_TEL, phoneNumber, null)
199
+ }
200
+
201
+ val email = participant.email
202
+ if (!email.isNullOrBlank()) {
203
+ return Uri.fromParts(PhoneAccount.SCHEME_SIP, email, null)
204
+ }
205
+
206
+ return Uri.fromParts(PhoneAccount.SCHEME_SIP, "${participant.id}@callkit-telecom.local", null)
207
+ }
208
+
209
+ // region Start Outgoing Call
210
+
211
+ /**
212
+ * Starts a new outgoing call via Core-Telecom.
213
+ *
214
+ * Steps:
215
+ * - validates single-session constraint
216
+ * - creates/queues local session
217
+ * - preps audio for call
218
+ * - calls addCall with DIRECTION_OUTGOING
219
+ */
220
+ fun startOutgoingCall(
221
+ recipient: CallParticipant,
222
+ options: CallOptions,
223
+ ): String {
224
+ val existingSession = CallStore.firstSession()
225
+ if (existingSession != null) {
226
+ CallKitTelecomLog.w(TAG) { "Cannot start outgoing call - session already exists: ${existingSession.id}" }
227
+ throw IllegalStateException("A call session already exists")
228
+ }
229
+
230
+ val id = UUID.randomUUID()
231
+ CallKitTelecomLog.d(TAG) { "Starting outgoing call - id: $id" }
232
+
233
+ val session =
234
+ CallSession(
235
+ id = id,
236
+ options = options,
237
+ origin = CallSessionOrigin.OUTGOING_APP,
238
+ remoteParticipants = listOf(recipient),
239
+ incomingCallEvent = null,
240
+ status = CallSessionStatus.REQUESTING,
241
+ connectedAt = null,
242
+ isMuted = false,
243
+ isOnHold = false,
244
+ dtmfDigits = null,
245
+ )
246
+
247
+ CallAudioManager.prepareAudioSessionForCall(options.hasVideo)
248
+ CallStore.add(session)
249
+
250
+ val attributes =
251
+ CallAttributesCompat(
252
+ displayName = recipient.displayName ?: "Unknown",
253
+ address = participantUri(recipient),
254
+ direction = CallAttributesCompat.DIRECTION_OUTGOING,
255
+ callType =
256
+ if (options.hasVideo) {
257
+ CallAttributesCompat.CALL_TYPE_VIDEO_CALL
258
+ } else {
259
+ CallAttributesCompat.CALL_TYPE_AUDIO_CALL
260
+ },
261
+ callCapabilities = CallAttributesCompat.SUPPORTS_SET_INACTIVE,
262
+ )
263
+
264
+ val actions = CallActions()
265
+ val job =
266
+ launchCallScope(
267
+ id = id,
268
+ attributes = attributes,
269
+ actions = actions,
270
+ onAnswer = { /* Outgoing calls don't receive onAnswer */ },
271
+ ) {
272
+ CallStore.updateStatus(id, CallSessionStatus.CONNECTING)
273
+ CallAudioManager.onAudioActivated(CallStore.allSessions())
274
+
275
+ CallNotificationManager.showDialingCall(context, id, recipient.displayName)
276
+
277
+ // Request speaker for video calls
278
+ if (options.hasVideo) {
279
+ val speakerEndpoint = findEndpointByType(CallEndpointCompat.TYPE_SPEAKER)
280
+ if (speakerEndpoint != null) {
281
+ actions.endpointChange.trySend(speakerEndpoint)
282
+ }
283
+ }
284
+
285
+ DialtonePlayer.play(context)
286
+
287
+ CallEventEmitter.send(
288
+ CallEvents.OUTGOING_CALL_STARTED,
289
+ mapOf("id" to id.toString()),
290
+ )
291
+
292
+ startCallTimeout(id, outgoingCallTimeoutMs)
293
+ }
294
+
295
+ activeCalls[id] = CallController(job, actions)
296
+
297
+ return id.toString()
298
+ }
299
+
300
+ // endregion
301
+
302
+ // region Report Incoming Call
303
+
304
+ /**
305
+ * Reports an incoming call via Core-Telecom.
306
+ *
307
+ * Steps:
308
+ * - validates single-session constraint
309
+ * - creates ringing session in store
310
+ * - shows incoming call notification
311
+ * - calls addCall with DIRECTION_INCOMING
312
+ * - emits `onIncomingCallReported`
313
+ */
314
+ fun reportIncomingCall(event: IncomingCallEvent) {
315
+ val existingSession = CallStore.firstSession()
316
+ if (existingSession != null) {
317
+ CallKitTelecomLog.w(TAG) { "Cannot report incoming call - session already exists: ${existingSession.id}" }
318
+ throw IllegalStateException("A call session already exists")
319
+ }
320
+
321
+ val id = UUID.randomUUID()
322
+ CallKitTelecomLog.d(TAG) { "Reporting incoming call - id: $id" }
323
+
324
+ val caller =
325
+ CallParticipant(
326
+ id = event.caller.id,
327
+ phoneNumber = event.caller.phoneNumber,
328
+ email = event.caller.email,
329
+ displayName = event.caller.displayName,
330
+ avatarUrl = event.caller.avatarUrl,
331
+ )
332
+
333
+ val session =
334
+ CallSession(
335
+ id = id,
336
+ options = CallOptions(hasVideo = event.hasVideo),
337
+ origin = CallSessionOrigin.INCOMING,
338
+ remoteParticipants = listOf(caller),
339
+ incomingCallEvent = event,
340
+ status = CallSessionStatus.RINGING,
341
+ connectedAt = null,
342
+ isMuted = false,
343
+ isOnHold = false,
344
+ dtmfDigits = null,
345
+ )
346
+
347
+ CallAudioManager.prepareAudioSessionForCall(event.hasVideo)
348
+ CallStore.add(session)
349
+
350
+ CallNotificationManager.showIncomingCall(
351
+ context,
352
+ id,
353
+ event.caller.displayName,
354
+ event.hasVideo,
355
+ )
356
+
357
+ val attributes =
358
+ CallAttributesCompat(
359
+ displayName = event.caller.displayName ?: "Unknown",
360
+ address = participantUri(caller),
361
+ direction = CallAttributesCompat.DIRECTION_INCOMING,
362
+ callType =
363
+ if (event.hasVideo) {
364
+ CallAttributesCompat.CALL_TYPE_VIDEO_CALL
365
+ } else {
366
+ CallAttributesCompat.CALL_TYPE_AUDIO_CALL
367
+ },
368
+ callCapabilities = CallAttributesCompat.SUPPORTS_SET_INACTIVE,
369
+ )
370
+
371
+ val actions = CallActions()
372
+ val job =
373
+ launchCallScope(
374
+ id = id,
375
+ attributes = attributes,
376
+ actions = actions,
377
+ onAnswer = { _ ->
378
+ CallKitTelecomLog.d(TAG) { "Incoming call onAnswer (system) - id: $id" }
379
+ onCallAnswered(id)
380
+ },
381
+ ) {
382
+ CallEventEmitter.send(
383
+ CallEvents.INCOMING_CALL_REPORTED,
384
+ mapOf("id" to id.toString()),
385
+ )
386
+
387
+ startCallTimeout(id, incomingCallTimeoutMs)
388
+ }
389
+
390
+ activeCalls[id] = CallController(job, actions)
391
+ }
392
+
393
+ // endregion
394
+
395
+ // region Answer Call
396
+
397
+ /** App-level answer request entrypoint (custom in-app answer button path). */
398
+ fun answerCall(id: UUID) {
399
+ CallKitTelecomLog.d(TAG) { "Answering call - id: $id" }
400
+ CallNotificationManager.cancel(context)
401
+ onCallAnswered(id)
402
+ }
403
+
404
+ /**
405
+ * Fulfills pending incoming-call answer request.
406
+ *
407
+ * Marks call connected and transitions Core-Telecom scope to active.
408
+ *
409
+ * @return `true` if request existed and was fulfilled, `false` otherwise.
410
+ */
411
+ fun fulfillIncomingCallConnected(requestId: UUID): Boolean {
412
+ val callId = FulfillRequestManager.fulfill(requestId) ?: return false
413
+
414
+ val now = Instant.now()
415
+ CallStore.update(callId) { session ->
416
+ session.copy(status = CallSessionStatus.CONNECTED, connectedAt = now)
417
+ }
418
+
419
+ activeCalls[callId]?.actions?.setActive?.trySend(Unit)
420
+
421
+ val callerName =
422
+ CallStore
423
+ .session(callId)
424
+ ?.remoteParticipants
425
+ ?.firstOrNull()
426
+ ?.displayName
427
+ CallNotificationManager.showOngoingCall(context, callId, callerName, now.toEpochMilli())
428
+
429
+ CallKitTelecomLog.d(TAG) { "Fulfilled incoming call - callId: $callId, requestId: $requestId" }
430
+ return true
431
+ }
432
+
433
+ /** Reports outgoing media is connected and sets call state to connected. */
434
+ fun reportOutgoingCallConnected(id: UUID) {
435
+ CallKitTelecomLog.d(TAG) { "Reporting outgoing call connected - id: $id" }
436
+ DialtonePlayer.stop()
437
+ cancelCallTimeout(id)
438
+
439
+ val now = Instant.now()
440
+ CallStore.update(id) { session ->
441
+ session.copy(status = CallSessionStatus.CONNECTED, connectedAt = now)
442
+ }
443
+
444
+ activeCalls[id]?.actions?.setActive?.trySend(Unit)
445
+
446
+ val callerName =
447
+ CallStore
448
+ .session(id)
449
+ ?.remoteParticipants
450
+ ?.firstOrNull()
451
+ ?.displayName
452
+ CallNotificationManager.showOngoingCall(context, id, callerName, now.toEpochMilli())
453
+ }
454
+
455
+ // endregion
456
+
457
+ // region End Call
458
+
459
+ /** Ends a call as a local/system user action (`onCallEnded` path). */
460
+ fun endCall(id: UUID) {
461
+ CallKitTelecomLog.d(TAG) { "Ending call - id: $id" }
462
+ finishCall(id, emitEnded = true, reportedReason = null)
463
+ }
464
+
465
+ /** Reports externally-ended call with explicit reason (`onCallReportedEnded` path). */
466
+ fun reportCallEnded(
467
+ id: UUID,
468
+ reason: CallEndedReason,
469
+ ) {
470
+ CallKitTelecomLog.d(TAG) { "Reporting call ended - id: $id, reason: ${reason.value}" }
471
+ finishCall(id, emitEnded = false, reportedReason = reason)
472
+ }
473
+
474
+ /**
475
+ * Shared call-finalization routine.
476
+ *
477
+ * - Cancels timeouts and pending fulfill requests
478
+ * - Disconnects Core-Telecom scope (which causes addCall to return)
479
+ * - Emits ended/reported-ended events as requested
480
+ * - Removes session from store
481
+ * - Deactivates audio after last session
482
+ */
483
+ private fun finishCall(
484
+ id: UUID,
485
+ emitEnded: Boolean,
486
+ reportedReason: CallEndedReason?,
487
+ sendDisconnect: Boolean = true,
488
+ ) {
489
+ val existingSession = CallStore.session(id) ?: return
490
+ DialtonePlayer.stop()
491
+ cancelCallTimeout(id)
492
+ FulfillRequestManager.cancelForCall(id)
493
+
494
+ val callerName = existingSession.remoteParticipants.firstOrNull()?.displayName
495
+ CallNotificationManager.showEndedCall(context, id, callerName)
496
+
497
+ // Send disconnect cause to Core-Telecom scope via the action channel.
498
+ // Don't cancel the job — let disconnect() cause addCall to return naturally
499
+ // so the DisconnectCause is properly delivered to the Telecom framework.
500
+ // The finally block provides safety-net cleanup if needed.
501
+ if (sendDisconnect) {
502
+ activeCalls
503
+ .remove(id)
504
+ ?.actions
505
+ ?.disconnect
506
+ ?.trySend(disconnectCauseFor(reportedReason))
507
+ }
508
+
509
+ if (existingSession.status != CallSessionStatus.ENDED) {
510
+ CallStore.updateStatus(id, CallSessionStatus.ENDED)
511
+ }
512
+
513
+ if (emitEnded) {
514
+ CallEventEmitter.send(CallEvents.CALL_ENDED, mapOf("id" to id.toString()))
515
+ }
516
+
517
+ if (reportedReason != null) {
518
+ CallEventEmitter.send(
519
+ CallEvents.CALL_REPORTED_ENDED,
520
+ mapOf(
521
+ "id" to id.toString(),
522
+ "reason" to reportedReason.value,
523
+ ),
524
+ )
525
+ }
526
+
527
+ CallStore.remove(id)
528
+
529
+ val remainingSessions = CallStore.allSessions()
530
+ if (remainingSessions.isEmpty()) {
531
+ CallAudioManager.onAudioDeactivated(remainingSessions)
532
+ }
533
+
534
+ CallKitTelecomLog.d(TAG) { "Call finished - id: $id, emitEnded: $emitEnded, reason: ${reportedReason?.value}" }
535
+ }
536
+
537
+ /**
538
+ * Safety-net cleanup called from the addCall finally block.
539
+ *
540
+ * Ensures all resources are released even if finishCall didn't run due to
541
+ * an unexpected exception or cancellation.
542
+ */
543
+ private fun cleanupCallIfNeeded(id: UUID) {
544
+ activeCalls.remove(id)
545
+
546
+ val session = CallStore.session(id) ?: return
547
+ CallKitTelecomLog.w(TAG) { "Safety-net cleanup for call - id: $id, status: ${session.status.value}" }
548
+
549
+ DialtonePlayer.stop()
550
+ FulfillRequestManager.cancelForCall(id)
551
+ CallNotificationManager.cancel(context)
552
+
553
+ if (session.status != CallSessionStatus.ENDED) {
554
+ CallStore.updateStatus(id, CallSessionStatus.ENDED)
555
+ }
556
+ CallEventEmitter.send(CallEvents.CALL_ENDED, mapOf("id" to id.toString()))
557
+ CallStore.remove(id)
558
+
559
+ if (CallStore.allSessions().isEmpty()) {
560
+ CallAudioManager.onAudioDeactivated(emptyList())
561
+ }
562
+ }
563
+
564
+ /** Maps shared end reasons to Android `DisconnectCause`. */
565
+ private fun disconnectCauseFor(reason: CallEndedReason?): DisconnectCause =
566
+ when (reason) {
567
+ CallEndedReason.REMOTE_ENDED -> DisconnectCause(DisconnectCause.REMOTE)
568
+
569
+ CallEndedReason.UNANSWERED -> DisconnectCause(DisconnectCause.MISSED)
570
+
571
+ CallEndedReason.ANSWERED_ELSEWHERE,
572
+ CallEndedReason.DECLINED_ELSEWHERE,
573
+ -> DisconnectCause(DisconnectCause.REMOTE)
574
+
575
+ CallEndedReason.FAILED,
576
+ CallEndedReason.UNKNOWN,
577
+ -> DisconnectCause(DisconnectCause.LOCAL)
578
+
579
+ null -> DisconnectCause(DisconnectCause.LOCAL)
580
+ }
581
+
582
+ // endregion
583
+
584
+ // region Mute Support
585
+
586
+ /** Sets local mute state and emits `onSetMutedAction`. */
587
+ fun setMuted(
588
+ id: UUID,
589
+ muted: Boolean,
590
+ ) {
591
+ CallKitTelecomLog.d(TAG) { "Setting mute state - id: $id, muted: $muted" }
592
+ CallStore.updateMuted(id, muted)
593
+ CallEventEmitter.send(
594
+ CallEvents.SET_MUTED_ACTION,
595
+ mapOf(
596
+ "id" to id.toString(),
597
+ "isMuted" to muted,
598
+ ),
599
+ )
600
+ }
601
+
602
+ // endregion
603
+
604
+ // region Video Support
605
+
606
+ /** Reports video enabled state change and emits `onVideoChanged`. */
607
+ fun reportVideo(
608
+ id: UUID,
609
+ enabled: Boolean,
610
+ ) {
611
+ CallKitTelecomLog.d(TAG) { "Setting video state - id: $id, enabled: $enabled" }
612
+ CallStore.update(id) { session ->
613
+ session.copy(options = session.options.copy(hasVideo = enabled))
614
+ }
615
+
616
+ CallAudioManager.prepareAudioSessionForCall(enabled)
617
+
618
+ CallEventEmitter.send(
619
+ CallEvents.VIDEO_CHANGED,
620
+ mapOf(
621
+ "id" to id.toString(),
622
+ "hasVideo" to enabled,
623
+ ),
624
+ )
625
+ }
626
+
627
+ // endregion
628
+
629
+ // region Hold Support
630
+
631
+ /** Sets hold state, updates Core-Telecom scope state, and emits `onSetHeldAction`. */
632
+ fun setHeld(
633
+ id: UUID,
634
+ onHold: Boolean,
635
+ ) {
636
+ CallKitTelecomLog.d(TAG) { "Setting hold state - id: $id, onHold: $onHold" }
637
+ CallStore.updateHeld(id, onHold)
638
+
639
+ if (onHold) {
640
+ activeCalls[id]?.actions?.setInactive?.trySend(Unit)
641
+ } else if (CallStore.session(id)?.status == CallSessionStatus.CONNECTED) {
642
+ activeCalls[id]?.actions?.setActive?.trySend(Unit)
643
+ }
644
+
645
+ CallEventEmitter.send(
646
+ CallEvents.SET_HELD_ACTION,
647
+ mapOf(
648
+ "id" to id.toString(),
649
+ "isOnHold" to onHold,
650
+ ),
651
+ )
652
+ }
653
+
654
+ // endregion
655
+
656
+ // region DTMF Support
657
+
658
+ /** Records requested DTMF digits and emits `onDTMF`. */
659
+ fun playDTMF(
660
+ id: UUID,
661
+ digits: String,
662
+ ) {
663
+ CallKitTelecomLog.d(TAG) { "Playing DTMF - id: $id, length: ${digits.length}" }
664
+ CallStore.update(id) { session ->
665
+ session.copy(dtmfDigits = digits)
666
+ }
667
+
668
+ CallEventEmitter.send(
669
+ CallEvents.DTMF,
670
+ mapOf(
671
+ "id" to id.toString(),
672
+ "digits" to digits,
673
+ ),
674
+ )
675
+ }
676
+
677
+ // endregion
678
+
679
+ // region Core-Telecom Helpers
680
+
681
+ /**
682
+ * Launches a Core-Telecom call scope with shared lifecycle management.
683
+ *
684
+ * Handles channel setup, action dispatch via select, flow collectors,
685
+ * and safety-net cleanup in a single place.
686
+ *
687
+ * @param id Call UUID
688
+ * @param attributes Core-Telecom call attributes
689
+ * @param actions Pre-created CallActions for this call's channel dispatch
690
+ * @param onAnswer Callback for system-initiated answer (incoming calls)
691
+ * @param onScopeReady Called after scope setup for direction-specific logic.
692
+ * @return The launched Job
693
+ */
694
+ private fun launchCallScope(
695
+ id: UUID,
696
+ attributes: CallAttributesCompat,
697
+ actions: CallActions,
698
+ onAnswer: (callType: Int) -> Unit,
699
+ onScopeReady: CallControlScope.() -> Unit,
700
+ ): Job =
701
+ scope.launch(CoroutineName("Call-$id")) {
702
+ try {
703
+ callsManager.addCall(
704
+ callAttributes = attributes,
705
+ onAnswer = onAnswer,
706
+ onDisconnect = { cause ->
707
+ CallKitTelecomLog.d(TAG) { "Call onDisconnect - id: $id, cause: ${cause.code}" }
708
+ finishCall(id, emitEnded = true, reportedReason = null, sendDisconnect = false)
709
+ },
710
+ onSetActive = {
711
+ setHeld(id, false)
712
+ },
713
+ onSetInactive = {
714
+ setHeld(id, true)
715
+ },
716
+ ) {
717
+ val callScope: CallControlScope = this
718
+
719
+ // Single coroutine handles all action channels via select
720
+ launch { handleCallActions(id, actions, callScope) }
721
+
722
+ // Direction-specific setup
723
+ onScopeReady()
724
+
725
+ // Collect endpoint and mute state flows to keep scope alive
726
+ collectCallFlows(id, callScope)
727
+ }
728
+ } catch (_: CancellationException) {
729
+ CallKitTelecomLog.d(TAG) { "Call coroutine cancelled - id: $id" }
730
+ } catch (e: Exception) {
731
+ CallKitTelecomLog.e(TAG) { "Call addCall failed - id: $id, error: ${e.message}" }
732
+ finishCall(id, emitEnded = true, reportedReason = CallEndedReason.FAILED, sendDisconnect = false)
733
+ } finally {
734
+ cleanupCallIfNeeded(id)
735
+ CallKitTelecomLog.d(TAG) { "Call addCall block exited - id: $id" }
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Processes call action channels using a single select loop.
741
+ *
742
+ * Runs until the coroutine is cancelled (when addCall returns).
743
+ * Using select ensures actions are processed sequentially, preventing
744
+ * concurrent scope method calls from racing.
745
+ */
746
+ private suspend fun handleCallActions(
747
+ id: UUID,
748
+ actions: CallActions,
749
+ scope: CallControlScope,
750
+ ) {
751
+ while (currentCoroutineContext().isActive) {
752
+ select<Unit> {
753
+ actions.setActive.onReceive {
754
+ handleControlResult(id, "setActive", scope.setActive())
755
+ }
756
+ actions.setInactive.onReceive {
757
+ logIfError(id, "setInactive", scope.setInactive())
758
+ }
759
+ actions.disconnect.onReceive { cause ->
760
+ scope.disconnect(cause)
761
+ }
762
+ actions.endpointChange.onReceive { endpoint ->
763
+ logIfError(id, "requestEndpointChange", scope.requestEndpointChange(endpoint))
764
+ }
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Handles CallControlResult for critical actions like setActive.
771
+ *
772
+ * When a critical action fails, the call cannot continue in a valid state
773
+ * and is ended with a FAILED reason.
774
+ */
775
+ private fun handleControlResult(
776
+ id: UUID,
777
+ action: String,
778
+ result: CallControlResult,
779
+ ) {
780
+ if (result is CallControlResult.Error) {
781
+ CallKitTelecomLog.e(TAG) { "$action failed - id: $id, error: ${result.errorCode}" }
782
+ reportCallEnded(id, CallEndedReason.FAILED)
783
+ }
784
+ }
785
+
786
+ /** Logs Core-Telecom control action failures at error level. */
787
+ private fun logIfError(
788
+ id: UUID,
789
+ action: String,
790
+ result: CallControlResult,
791
+ ) {
792
+ if (result is CallControlResult.Error) {
793
+ CallKitTelecomLog.e(TAG) { "$action failed - id: $id, error: ${result.errorCode}" }
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Collects Core-Telecom endpoint and mute flows within the CallControlScope.
799
+ *
800
+ * These coroutines keep the addCall block alive and forward audio state
801
+ * changes to CallAudioManager and CallStore.
802
+ */
803
+ private fun CoroutineScope.collectCallFlows(
804
+ id: UUID,
805
+ callScope: CallControlScope,
806
+ ) {
807
+ launch {
808
+ callScope.availableEndpoints.collect { endpoints ->
809
+ CallStore.session(id) ?: return@collect
810
+ CallKitTelecomLog.d(TAG) { "Available endpoints changed - id: $id, count: ${endpoints.size}" }
811
+ CallAudioManager.onAvailableEndpointsChanged(endpoints)
812
+ }
813
+ }
814
+
815
+ launch {
816
+ callScope.currentCallEndpoint.collect { endpoint ->
817
+ CallStore.session(id) ?: return@collect
818
+ CallKitTelecomLog.d(TAG) { "Endpoint changed - id: $id, type: ${endpoint.type}, name: ${endpoint.name}" }
819
+ CallAudioManager.onEndpointChanged(endpoint)
820
+ }
821
+ }
822
+
823
+ launch {
824
+ callScope.isMuted.collect { muted ->
825
+ val session = CallStore.session(id) ?: return@collect
826
+ if (session.isMuted != muted) {
827
+ CallKitTelecomLog.d(TAG) { "Mute state changed - id: $id, isMuted: $muted" }
828
+ CallStore.updateMuted(id, muted)
829
+ CallEventEmitter.send(
830
+ CallEvents.SET_MUTED_ACTION,
831
+ mapOf(
832
+ "id" to id.toString(),
833
+ "isMuted" to muted,
834
+ ),
835
+ )
836
+ }
837
+ }
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Shared answer workflow for incoming calls.
843
+ *
844
+ * Emits `onCallAnswered` with a generated `requestId` and waits for JS to call
845
+ * `fulfillIncomingCallAnswered(requestId)` once media is connected.
846
+ */
847
+ private fun onCallAnswered(id: UUID) {
848
+ val session = CallStore.session(id) ?: return
849
+ if (session.status == CallSessionStatus.CONNECTED) {
850
+ CallKitTelecomLog.d(TAG) { "Call already connected, ignoring answer - id: $id" }
851
+ return
852
+ }
853
+
854
+ cancelCallTimeout(id)
855
+
856
+ CallStore.updateStatus(id, CallSessionStatus.CONNECTING)
857
+ CallAudioManager.onAudioActivated(CallStore.allSessions())
858
+
859
+ // Request speaker for video calls
860
+ if (session.options.hasVideo) {
861
+ val speakerEndpoint = findEndpointByType(CallEndpointCompat.TYPE_SPEAKER)
862
+ if (speakerEndpoint != null) {
863
+ activeCalls[id]?.actions?.endpointChange?.trySend(speakerEndpoint)
864
+ }
865
+ }
866
+
867
+ val request =
868
+ FulfillRequestManager.createRequest(
869
+ callId = id,
870
+ timeoutMs = fulfillAnswerTimeoutMs,
871
+ ) {
872
+ reportCallEnded(it, CallEndedReason.FAILED)
873
+ }
874
+
875
+ CallKitTelecomLog.d(TAG) { "Call answered - id: $id, requestId: ${request.requestId}" }
876
+
877
+ CallEventEmitter.send(
878
+ CallEvents.CALL_ANSWERED,
879
+ mapOf(
880
+ "id" to id.toString(),
881
+ "requestId" to request.requestId.toString(),
882
+ ),
883
+ )
884
+ }
885
+
886
+ /** Finds an endpoint by type from the cached available endpoints. */
887
+ private fun findEndpointByType(type: Int): CallEndpointCompat? =
888
+ CallAudioManager.currentAvailableEndpoints.firstOrNull { it.type == type }
889
+
890
+ // endregion
891
+ }