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.
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/android/build.gradle +32 -0
- package/android/src/main/AndroidManifest.xml +33 -0
- package/android/src/main/java/expo/modules/callkittelecom/ExpoCallKitTelecomModule.kt +384 -0
- package/android/src/main/java/expo/modules/callkittelecom/IncomingCallActivity.kt +275 -0
- package/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt +151 -0
- package/android/src/main/java/expo/modules/callkittelecom/events/CallEvents.kt +59 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallAudioManager.kt +361 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallManager.kt +891 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallNotificationManager.kt +445 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CaptureSessionManager.kt +27 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/DialtonePlayer.kt +171 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/FulfillRequestManager.kt +150 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/VoIPPushManager.kt +54 -0
- package/android/src/main/java/expo/modules/callkittelecom/models/CallModels.kt +269 -0
- package/android/src/main/java/expo/modules/callkittelecom/services/CallNotificationReceiver.kt +54 -0
- package/android/src/main/java/expo/modules/callkittelecom/services/ExpoCallKitTelecomMessagingService.kt +161 -0
- package/android/src/main/java/expo/modules/callkittelecom/store/CallStore.kt +181 -0
- package/android/src/main/java/expo/modules/callkittelecom/utils/CallKitTelecomLog.kt +52 -0
- package/android/src/main/java/expo/modules/callkittelecom/utils/PermissionUtils.kt +28 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_answer.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_avatar.xml +5 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_decline.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_answer.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_decline.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_videocam.xml +9 -0
- package/android/src/main/res/layout/activity_incoming_call.xml +169 -0
- package/app.json +8 -0
- package/app.plugin.js +1 -0
- package/build/Calls.d.ts +577 -0
- package/build/Calls.d.ts.map +1 -0
- package/build/Calls.js +715 -0
- package/build/Calls.js.map +1 -0
- package/build/Calls.types.d.ts +203 -0
- package/build/Calls.types.d.ts.map +1 -0
- package/build/Calls.types.js +2 -0
- package/build/Calls.types.js.map +1 -0
- package/build/ExpoCallKitTelecomModule.d.ts +3 -0
- package/build/ExpoCallKitTelecomModule.d.ts.map +1 -0
- package/build/ExpoCallKitTelecomModule.js +4 -0
- package/build/ExpoCallKitTelecomModule.js.map +1 -0
- package/build/hooks/index.d.ts +2 -0
- package/build/hooks/index.d.ts.map +1 -0
- package/build/hooks/index.js +2 -0
- package/build/hooks/index.js.map +1 -0
- package/build/hooks/useVoIPPushToken.d.ts +14 -0
- package/build/hooks/useVoIPPushToken.d.ts.map +1 -0
- package/build/hooks/useVoIPPushToken.js +26 -0
- package/build/hooks/useVoIPPushToken.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +10 -0
- package/ios/AppDelegateSubscriber.swift +93 -0
- package/ios/ExpoCallKitTelecom.podspec +31 -0
- package/ios/ExpoCallKitTelecomLogger.swift +55 -0
- package/ios/ExpoCallKitTelecomModule.swift +503 -0
- package/ios/Managers/AudioManager.swift +363 -0
- package/ios/Managers/CallEventEmitter.swift +199 -0
- package/ios/Managers/CallManager+CXProviderDelegate.swift +195 -0
- package/ios/Managers/CallManager.swift +714 -0
- package/ios/Managers/CaptureSessionManager.swift +54 -0
- package/ios/Managers/DialtonePlayer.swift +126 -0
- package/ios/Managers/FulfillRequestManager.swift +154 -0
- package/ios/Managers/VoIPPushManager+PKPushRegistryDelegate.swift +123 -0
- package/ios/Managers/VoIPPushManager.swift +58 -0
- package/ios/Models/CallEvents.swift +263 -0
- package/ios/Models/CallOptions.swift +15 -0
- package/ios/Models/CallParticipant.swift +37 -0
- package/ios/Models/CallSession.swift +80 -0
- package/ios/Models/IncomingCallEvent.swift +196 -0
- package/ios/Stores/CallStore.swift +149 -0
- package/package.json +56 -0
- package/plugin/build/constants.d.ts +3 -0
- package/plugin/build/constants.js +7 -0
- package/plugin/build/withExpoCallKitTelecom.d.ts +67 -0
- package/plugin/build/withExpoCallKitTelecom.js +16 -0
- package/plugin/build/withExpoCallKitTelecomAndroid.d.ts +3 -0
- package/plugin/build/withExpoCallKitTelecomAndroid.js +177 -0
- package/plugin/build/withExpoCallKitTelecomIos.d.ts +3 -0
- package/plugin/build/withExpoCallKitTelecomIos.js +195 -0
- package/plugin/src/constants.ts +4 -0
- package/plugin/src/withExpoCallKitTelecom.ts +83 -0
- package/plugin/src/withExpoCallKitTelecomAndroid.ts +293 -0
- package/plugin/src/withExpoCallKitTelecomIos.ts +276 -0
- package/src/Calls.ts +848 -0
- package/src/Calls.types.ts +275 -0
- package/src/ExpoCallKitTelecomModule.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useVoIPPushToken.ts +34 -0
- 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
|
+
}
|