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,361 @@
1
+ package expo.modules.callkittelecom.managers
2
+
3
+ import android.content.Context
4
+ import android.media.AudioDeviceCallback
5
+ import android.media.AudioDeviceInfo
6
+ import android.media.AudioManager
7
+ import androidx.core.telecom.CallEndpointCompat
8
+ import expo.modules.callkittelecom.events.CallEventEmitter
9
+ import expo.modules.callkittelecom.events.CallEvents
10
+ import expo.modules.callkittelecom.models.CallSession
11
+ import expo.modules.callkittelecom.utils.CallKitTelecomLog
12
+ import expo.modules.callkittelecom.utils.PermissionUtils
13
+
14
+ /**
15
+ * Manages Android call audio state and routing for the shared calls API.
16
+ *
17
+ * Audio focus and mode are managed by Core-Telecom. This manager tracks
18
+ * endpoint state, emits route changes to JS, and requests endpoint switches
19
+ * via the active call scope.
20
+ */
21
+ object CallAudioManager {
22
+ private const val TAG = "ExpoCallKitTelecom.Audio"
23
+
24
+ private lateinit var context: Context
25
+ private lateinit var audioManager: AudioManager
26
+
27
+ private var isInitialized = false
28
+ internal var isActive = false
29
+ private var configuredForVideo = false
30
+
31
+ /** Callback to request endpoint change on the active Core-Telecom call scope. */
32
+ internal var onRequestEndpointChange: ((CallEndpointCompat) -> Unit)? = null
33
+
34
+ /** Last known active endpoint from Core-Telecom. */
35
+ private var currentEndpoint: CallEndpointCompat? = null
36
+
37
+ /** Last known available endpoints from Core-Telecom. */
38
+ internal var currentAvailableEndpoints: List<CallEndpointCompat> = emptyList()
39
+
40
+ private var routeCallback: AudioDeviceCallback? = null
41
+
42
+ /** Initializes audio manager and route-change observation. Safe to call repeatedly. */
43
+ fun initialize(appContext: Context) {
44
+ if (isInitialized) return
45
+
46
+ context = appContext.applicationContext
47
+ audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
48
+
49
+ routeCallback =
50
+ object : AudioDeviceCallback() {
51
+ override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
52
+ CallKitTelecomLog.d(TAG) { "Audio devices added - count: ${addedDevices.size}" }
53
+ // During active calls, Core-Telecom handles routing via endpoint Flows
54
+ if (!isActive) emitRouteChanged()
55
+ }
56
+
57
+ override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
58
+ CallKitTelecomLog.d(TAG) { "Audio devices removed - count: ${removedDevices.size}" }
59
+ if (!isActive) emitRouteChanged()
60
+ }
61
+ }
62
+
63
+ routeCallback?.let { callback ->
64
+ audioManager.registerAudioDeviceCallback(callback, null)
65
+ }
66
+
67
+ isInitialized = true
68
+ CallKitTelecomLog.d(TAG) { "Initialized CallAudioManager" }
69
+ }
70
+
71
+ /** Receives Core-Telecom current endpoint updates from the active call scope. */
72
+ fun onEndpointChanged(endpoint: CallEndpointCompat) {
73
+ currentEndpoint = endpoint
74
+ emitRouteChanged()
75
+ }
76
+
77
+ /** Receives Core-Telecom available endpoint updates from the active call scope. */
78
+ fun onAvailableEndpointsChanged(endpoints: List<CallEndpointCompat>) {
79
+ currentAvailableEndpoints = endpoints
80
+ emitRouteChanged()
81
+ }
82
+
83
+ /** Returns current audio session state in the shared TypeScript shape. */
84
+ fun getAudioSessionState(): Map<String, Any?> {
85
+ val sampleRate =
86
+ audioManager
87
+ .getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)
88
+ ?.toDoubleOrNull()
89
+ val framesPerBuffer =
90
+ audioManager
91
+ .getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)
92
+ ?.toDoubleOrNull()
93
+ val ioBufferDuration =
94
+ if (sampleRate != null && framesPerBuffer != null) framesPerBuffer / sampleRate else null
95
+
96
+ val currentRoute = currentRouteMap()
97
+
98
+ return mapOf(
99
+ "isActive" to isActive,
100
+ "isOtherAudioPlaying" to audioManager.isMusicActive,
101
+ "category" to "communication",
102
+ "mode" to modeString(audioManager.mode),
103
+ "sampleRate" to sampleRate,
104
+ "ioBufferDuration" to ioBufferDuration,
105
+ "inputNumberOfChannels" to 1,
106
+ "outputNumberOfChannels" to 2,
107
+ "microphonePermission" to PermissionUtils.microphonePermission(context),
108
+ "currentRoute" to currentRoute,
109
+ "availableRoutes" to availableRoutesMap(),
110
+ )
111
+ }
112
+
113
+ /** Records whether the upcoming call uses video (affects default endpoint selection). */
114
+ fun prepareAudioSessionForCall(hasVideo: Boolean) {
115
+ CallKitTelecomLog.d(TAG) { "Preparing audio session for call - hasVideo: $hasVideo" }
116
+ configuredForVideo = hasVideo
117
+ }
118
+
119
+ /** No-op on Android — Core-Telecom manages audio mode and focus. Kept for JS API compat. */
120
+ fun restoreAudioSession() {
121
+ CallKitTelecomLog.d(TAG) { "restoreAudioSession is a no-op on Android (Core-Telecom manages audio)" }
122
+ }
123
+
124
+ /**
125
+ * Marks audio session as active and emits `onAudioSessionActivated`.
126
+ *
127
+ * Core-Telecom handles audio focus and mode automatically.
128
+ */
129
+ fun onAudioActivated(calls: List<CallSession>) {
130
+ if (!isInitialized) return
131
+
132
+ CallKitTelecomLog.d(TAG) { "Activating audio session - calls: ${calls.size}" }
133
+
134
+ isActive = true
135
+
136
+ val callInfos =
137
+ calls.map {
138
+ mapOf(
139
+ "id" to it.id.toString(),
140
+ "status" to it.status.value,
141
+ )
142
+ }
143
+
144
+ CallEventEmitter.send(
145
+ CallEvents.AUDIO_SESSION_ACTIVATED,
146
+ mapOf("calls" to callInfos),
147
+ )
148
+ }
149
+
150
+ /**
151
+ * Resets audio state after final call teardown and emits `onAudioSessionDeactivated`.
152
+ *
153
+ * Core-Telecom releases audio focus and restores mode when the call scope ends.
154
+ */
155
+ fun onAudioDeactivated(calls: List<CallSession>) {
156
+ if (!isInitialized) return
157
+
158
+ DialtonePlayer.stop()
159
+ CallKitTelecomLog.d(TAG) { "Deactivating audio session - calls: ${calls.size}" }
160
+
161
+ currentEndpoint = null
162
+ currentAvailableEndpoints = emptyList()
163
+ isActive = false
164
+
165
+ emitRouteChanged()
166
+
167
+ val callInfos =
168
+ calls.map {
169
+ mapOf(
170
+ "id" to it.id.toString(),
171
+ "status" to it.status.value,
172
+ )
173
+ }
174
+
175
+ CallEventEmitter.send(
176
+ CallEvents.AUDIO_SESSION_DEACTIVATED,
177
+ mapOf("calls" to callInfos),
178
+ )
179
+ }
180
+
181
+ /** Requests endpoint change to speaker (`true`) or best non-speaker device (`false`). */
182
+ fun setAudioSessionPortOverride(enabled: Boolean) {
183
+ CallKitTelecomLog.d(TAG) { "Setting speaker - enabled: $enabled" }
184
+
185
+ val endpoint =
186
+ if (enabled) {
187
+ currentAvailableEndpoints.firstOrNull { it.type == CallEndpointCompat.TYPE_SPEAKER }
188
+ } else {
189
+ selectBestNonSpeakerEndpoint()
190
+ }
191
+
192
+ if (endpoint != null) {
193
+ onRequestEndpointChange?.invoke(endpoint)
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Selects the best non-speaker endpoint matching iOS priority:
199
+ * Bluetooth > Wired Headset > Earpiece (audio) / Speaker (video).
200
+ */
201
+ private fun selectBestNonSpeakerEndpoint(): CallEndpointCompat? {
202
+ val available = currentAvailableEndpoints
203
+
204
+ available
205
+ .firstOrNull { it.type == CallEndpointCompat.TYPE_BLUETOOTH }
206
+ ?.let { return it }
207
+ available
208
+ .firstOrNull { it.type == CallEndpointCompat.TYPE_WIRED_HEADSET }
209
+ ?.let { return it }
210
+
211
+ return if (configuredForVideo) {
212
+ available.firstOrNull { it.type == CallEndpointCompat.TYPE_SPEAKER }
213
+ } else {
214
+ available.firstOrNull { it.type == CallEndpointCompat.TYPE_EARPIECE }
215
+ }
216
+ }
217
+
218
+ /** Emits the current route and available routes snapshot to JS listeners. */
219
+ private fun emitRouteChanged() {
220
+ CallEventEmitter.send(
221
+ CallEvents.AUDIO_ROUTE_CHANGED,
222
+ mapOf(
223
+ "currentRoute" to currentRouteMap(),
224
+ "availableRoutes" to availableRoutesMap(),
225
+ ),
226
+ )
227
+ }
228
+
229
+ /**
230
+ * Converts Core-Telecom available endpoints into JS-facing AudioPort objects.
231
+ * Each endpoint maps to a single output port.
232
+ */
233
+ private fun availableRoutesMap(): List<Map<String, String>> =
234
+ currentAvailableEndpoints.mapNotNull { endpoint -> resolveOutputPort(endpoint) }
235
+
236
+ /**
237
+ * Builds the current route payload with the single active input/output device.
238
+ *
239
+ * During an active call, uses Core-Telecom's [CallEndpointCompat] to determine
240
+ * the active device (matching iOS's `AVAudioSession.currentRoute` behavior).
241
+ * When no call is active, falls back to built-in defaults.
242
+ */
243
+ private fun currentRouteMap(): Map<String, Any?> {
244
+ val endpoint = currentEndpoint
245
+ if (endpoint != null) {
246
+ val output = resolveOutputPort(endpoint)
247
+ val input = resolveInputPort(endpoint.type)
248
+ return mapOf(
249
+ "inputs" to listOfNotNull(input),
250
+ "outputs" to listOfNotNull(output),
251
+ )
252
+ }
253
+
254
+ val input = findDevicePort(AudioManager.GET_DEVICES_INPUTS, AudioDeviceInfo.TYPE_BUILTIN_MIC)
255
+ val output = findDevicePort(AudioManager.GET_DEVICES_OUTPUTS, AudioDeviceInfo.TYPE_BUILTIN_EARPIECE)
256
+ return mapOf(
257
+ "inputs" to listOfNotNull(input),
258
+ "outputs" to listOfNotNull(output),
259
+ )
260
+ }
261
+
262
+ /** Maps Core-Telecom endpoint to a port map for the JS layer. */
263
+ private fun resolveOutputPort(endpoint: CallEndpointCompat): Map<String, String>? =
264
+ when (endpoint.type) {
265
+ CallEndpointCompat.TYPE_SPEAKER -> {
266
+ findDevicePort(AudioManager.GET_DEVICES_OUTPUTS, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER)
267
+ }
268
+
269
+ CallEndpointCompat.TYPE_EARPIECE -> {
270
+ findDevicePort(AudioManager.GET_DEVICES_OUTPUTS, AudioDeviceInfo.TYPE_BUILTIN_EARPIECE)
271
+ }
272
+
273
+ CallEndpointCompat.TYPE_BLUETOOTH -> {
274
+ mapOf(
275
+ "portType" to "bluetoothHFP",
276
+ "portName" to endpoint.name.toString(),
277
+ "uid" to endpoint.identifier.toString(),
278
+ )
279
+ }
280
+
281
+ CallEndpointCompat.TYPE_WIRED_HEADSET -> {
282
+ findDevicePort(AudioManager.GET_DEVICES_OUTPUTS, AudioDeviceInfo.TYPE_WIRED_HEADSET)
283
+ ?: findDevicePort(AudioManager.GET_DEVICES_OUTPUTS, AudioDeviceInfo.TYPE_WIRED_HEADPHONES)
284
+ }
285
+
286
+ else -> {
287
+ null
288
+ }
289
+ }
290
+
291
+ /** Infers the active input device based on the endpoint type. */
292
+ private fun resolveInputPort(endpointType: Int): Map<String, String>? {
293
+ val inputType =
294
+ when (endpointType) {
295
+ CallEndpointCompat.TYPE_BLUETOOTH -> AudioDeviceInfo.TYPE_BLUETOOTH_SCO
296
+ CallEndpointCompat.TYPE_WIRED_HEADSET -> AudioDeviceInfo.TYPE_WIRED_HEADSET
297
+ else -> AudioDeviceInfo.TYPE_BUILTIN_MIC
298
+ }
299
+ return findDevicePort(AudioManager.GET_DEVICES_INPUTS, inputType)
300
+ ?: findDevicePort(AudioManager.GET_DEVICES_INPUTS, AudioDeviceInfo.TYPE_BUILTIN_MIC)
301
+ }
302
+
303
+ /** Finds the first matching device by type and returns its port map. */
304
+ private fun findDevicePort(
305
+ direction: Int,
306
+ type: Int,
307
+ ): Map<String, String>? =
308
+ audioManager
309
+ .getDevices(direction)
310
+ .firstOrNull { it.type == type }
311
+ ?.let { portMap(it) }
312
+
313
+ /** Serializes an Android audio device into the shared audio port payload shape. */
314
+ private fun portMap(info: AudioDeviceInfo): Map<String, String> =
315
+ mapOf(
316
+ "portType" to portTypeString(info.type),
317
+ "portName" to info.productName?.toString().orEmpty(),
318
+ "uid" to info.id.toString(),
319
+ )
320
+
321
+ /** Maps Android audio device constants to cross-platform port type identifiers. */
322
+ private fun portTypeString(type: Int): String =
323
+ when (type) {
324
+ AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "builtInReceiver"
325
+
326
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "builtInSpeaker"
327
+
328
+ AudioDeviceInfo.TYPE_WIRED_HEADSET,
329
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
330
+ -> "headphones"
331
+
332
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "bluetoothA2DP"
333
+
334
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "bluetoothHFP"
335
+
336
+ AudioDeviceInfo.TYPE_BLE_HEADSET -> "bluetoothLE"
337
+
338
+ AudioDeviceInfo.TYPE_HDMI -> "hdmi"
339
+
340
+ AudioDeviceInfo.TYPE_USB_DEVICE,
341
+ AudioDeviceInfo.TYPE_USB_ACCESSORY,
342
+ AudioDeviceInfo.TYPE_USB_HEADSET,
343
+ -> "usbAudio"
344
+
345
+ AudioDeviceInfo.TYPE_LINE_ANALOG,
346
+ AudioDeviceInfo.TYPE_LINE_DIGITAL,
347
+ -> "lineOut"
348
+
349
+ else -> "android_$type"
350
+ }
351
+
352
+ /** Maps Android audio mode constants to readable string values for diagnostics. */
353
+ private fun modeString(mode: Int): String =
354
+ when (mode) {
355
+ AudioManager.MODE_IN_COMMUNICATION -> "inCommunication"
356
+ AudioManager.MODE_IN_CALL -> "inCall"
357
+ AudioManager.MODE_NORMAL -> "normal"
358
+ AudioManager.MODE_RINGTONE -> "ringtone"
359
+ else -> "unknown"
360
+ }
361
+ }