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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Fairley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,197 @@
1
+ # 📞 expo-callkit-telecom
2
+
3
+ > A modern Expo module — written in Swift and Kotlin — that wraps **CallKit** on iOS and **Jetpack Core-Telecom** on Android with API parity. It owns the system call UI, the audio session, and VoIP push — your app owns the media (e.g. LiveKit, plain WebRTC, etc.).
4
+
5
+ The module is opinionated about *system integration* and unopinionated about *media*. You wire your media library to the events it emits.
6
+
7
+ ## ✨ Features
8
+
9
+ - 📱 **Native calling UI** — CallKit on iOS, Telecom incoming-call notification + full-screen intent on Android
10
+ - 🔔 **VoIP notifications** — APNs VoIP on iOS (PushKit), FCM data messages on Android, parsed natively so calls can be reported from a terminated state
11
+ - 🎵 **Ringtones** — system ringtone for incoming calls, configurable via the config plugin
12
+ - ☎️ **Dialtone** — looped dialtone with fade-in for outgoing calls, configurable
13
+ - 🎧 **Audio session management** — cross-platform port types (`builtInReceiver`, `builtInSpeaker`, `headphones`, `bluetoothA2DP`, `bluetoothHFP`, `bluetoothLE`, `airPlay`, `hdmi`, `carAudio`, `usbAudio`, `lineOut`)
14
+ - 🔊 **Speaker override** and live route-change events
15
+ - 🎚️ **Mute, hold, video, DTMF** — both directions: app → system and system → app (e.g. native mute button → your media)
16
+ - 🗣️ **Call intents on iOS** — Recents list, Siri ("call Jane")
17
+ - 🧩 **Typed TypeScript API** with a single `CallSession` object that tracks state across the call lifecycle
18
+
19
+ ## 📦 Install
20
+
21
+ ```sh
22
+ bun add expo-callkit-telecom
23
+ ```
24
+
25
+ Add the config plugin to `app.json` / `app.config.ts`. Minimal form:
26
+
27
+ ```jsonc
28
+ {
29
+ "expo": {
30
+ "plugins": ["expo-callkit-telecom"]
31
+ }
32
+ }
33
+ ```
34
+
35
+ With custom ringtone and dialtone:
36
+
37
+ ```jsonc
38
+ {
39
+ "expo": {
40
+ "plugins": [
41
+ [
42
+ "expo-callkit-telecom",
43
+ {
44
+ "sounds": [
45
+ "./assets/sounds/ringtone.caf",
46
+ "./assets/sounds/dialtone.caf"
47
+ ],
48
+ "defaultRingtoneIos": "ringtone.caf",
49
+ "defaultRingtoneAndroid": "ringtone.caf",
50
+ "defaultDialtone": "dialtone.caf",
51
+ "incomingCallTimeout": 45,
52
+ "outgoingCallTimeout": 60,
53
+ "microphonePermission": "$(PRODUCT_NAME) needs the microphone to make calls."
54
+ }
55
+ ]
56
+ ]
57
+ }
58
+ }
59
+ ```
60
+
61
+ Files in `sounds` are copied into the iOS bundle and Android raw resources at prebuild time. The full prop type is `ExpoCallKitTelecomPluginProps` in `plugin/src/`.
62
+
63
+
64
+ ## 🧠 Concepts
65
+
66
+ The TS API is organised into three verbs:
67
+
68
+ | Verb | Direction | Examples |
69
+ | ------------ | ---------------------- | --------------------------------------------------------------------- |
70
+ | **Request** | App → System | `startOutgoingCall`, `answerCall`, `endCall`, `setMuted` |
71
+ | **Report** | App → System (state) | `reportIncomingCall`, `reportOutgoingCallConnected`, `reportCallEnded` |
72
+ | **Fulfill** | App → System (ack) | `fulfillIncomingCallConnected` |
73
+
74
+ Events flow the other way (System → App) via `addXxxListener`.
75
+
76
+ ## 🚀 VoIP push payload
77
+
78
+ When the OS delivers a VoIP push (PushKit on iOS, an FCM data message on Android), the module parses the payload natively — before JS is running — and reports the call to the OS.
79
+
80
+ The event itself is always the same shape on both transports. All keys are camelCase:
81
+
82
+ ```jsonc
83
+ // IncomingCallEvent (the "inner" event)
84
+ {
85
+ "eventId": "550e8400-e29b-41d4-a716-446655440000", // required (UUID), for dedup
86
+ "serverCallId": "9e7f...", // required — your backend's call id
87
+ // (distinct from CallSession.id,
88
+ // which is the OS-assigned UUID)
89
+ "hasVideo": false,
90
+ "startedAt": "2026-01-15T19:42:11.000Z", // RFC 3339, optional
91
+ "caller": {
92
+ "id": "<caller id>", // required — opaque, stable
93
+ "displayName": "Jane Smith",
94
+ "avatarUrl": "https://...",
95
+ "phoneNumber": "+14155551234", // optional; must be E.164 if present
96
+ "email": "jane@example.com"
97
+ },
98
+ "metadata": { // optional, opaque pass-through
99
+ "chatId": "abc-123",
100
+ "tenantId": "acme-co"
101
+ }
102
+ }
103
+ ```
104
+
105
+ Any keys you put under `metadata` are forwarded verbatim from the push payload all the way through to your JS event handler. The lib treats them as opaque — you cast at the read site:
106
+
107
+ ```ts
108
+ Calls.addCallAnsweredListener(({ id }) => {
109
+ const session = /* lookup */;
110
+ const chatId = session?.incomingCallEvent?.metadata?.chatId as string | undefined;
111
+ });
112
+ ```
113
+
114
+ Both transports wrap the event under an `incomingCall` key, just at different layers — APNs in the push payload dictionary, FCM in the data block:
115
+
116
+ ### 🍎 iOS — APNs VoIP push
117
+
118
+ Send a VoIP push (`apns-push-type: voip`) whose dictionary payload nests the event under `incomingCall`:
119
+
120
+ ```jsonc
121
+ {
122
+ "incomingCall": { /* IncomingCallEvent — see above */ }
123
+ }
124
+ ```
125
+
126
+ ### 🤖 Android — FCM data message
127
+
128
+ FCM data values must be strings, so JSON-encode the inner event and put it under `incomingCall`:
129
+
130
+ ```jsonc
131
+ {
132
+ "data": {
133
+ "messageType": "incomingCall",
134
+ "incomingCall": "{\"eventId\":\"...\",\"serverCallId\":\"...\", ... }"
135
+ }
136
+ }
137
+ ```
138
+
139
+ Non-`incomingCall` data messages are forwarded to `expo-notifications`'s service for normal handling.
140
+
141
+ ## 🧪 Example
142
+
143
+ `example/` contains a runnable Expo app (`example/client/`) and a zero-dep push-sender script (`example/server/`). See their READMEs for setup and how to validate VoIP push end-to-end.
144
+
145
+ ## 🔑 Registering for VoIP push
146
+
147
+ ```ts
148
+ import {
149
+ registerVoIPPush,
150
+ useVoIPPushToken,
151
+ } from "expo-callkit-telecom";
152
+
153
+ // Once, early in app lifecycle:
154
+ registerVoIPPush();
155
+
156
+ // In a React component:
157
+ function App() {
158
+ const voip = useVoIPPushToken();
159
+ useEffect(() => {
160
+ if (voip) {
161
+ // voip.type is "APNS_VOIP" on iOS, "FCM" on Android.
162
+ sendToBackend(voip.token, voip.type);
163
+ }
164
+ }, [voip]);
165
+ }
166
+ ```
167
+
168
+ ## 📚 API surface
169
+
170
+ See `src/Calls.ts` for full JSDoc. Main areas:
171
+
172
+ - **Sessions** — `getActiveCallSession`, `addCallSession{Added,Updated,Removed}Listener`
173
+ - **Outgoing** — `startOutgoingCall`, `addOutgoingCallStartedListener`, `reportOutgoingCallConnected`
174
+ - **Incoming** — `reportIncomingCall`, `addIncomingCallReportedListener`, `answerCall`, `addCallAnsweredListener`, `fulfillIncomingCallConnected`, `failIncomingCallConnected`
175
+ - **End** — `endCall`, `addCallEndedListener`, `reportCallEnded`
176
+ - **Audio** — `getAudioSession`, `setAudioSessionPortOverride`, `prepareAudioSessionForCall`, `addAudioRouteChangedListener`
177
+ - **Mute / Hold / Video / DTMF** — `setMuted`, `setHeld`, `reportVideo`, `playDTMF` and their listeners
178
+ - **VoIP push** — `registerVoIPPush`, `getVoIPPushToken`, `useVoIPPushToken`, `addVoIPPushTokenUpdatedListener`
179
+
180
+ ## 📝 Platform notes
181
+
182
+ - 🍎 **iOS** — requires the `voip` background mode and a VoIP push certificate. Uses CallKit + PushKit + WebRTC's `RTCAudioSession` for manual audio control. Min iOS 15.1.
183
+ - 🤖 **Android** — requires `MANAGE_OWN_CALLS` permission, min SDK 26. Uses `androidx.core:core-telecom`. Incoming calls come via FCM data messages — the config plugin registers `ExpoCallKitTelecomMessagingService` automatically.
184
+ - 🎟️ VoIP push token type is reported as `"APNS_VOIP"` on iOS and `"FCM"` on Android — send both to your backend so it knows which transport to use.
185
+
186
+ ## 🆚 Comparison with `react-native-callkeep`
187
+
188
+ [`react-native-callkeep`](https://github.com/react-native-webrtc/react-native-callkeep) is the long-standing option in this space. Here are some differences with this package:
189
+
190
+ - **Android backend.** callkeep uses the classic `android.telecom.ConnectionService` (`minSdk 23`). This module uses Jetpack `androidx.core:core-telecom` (`minSdk 26`), the Google-recommended path going forward — it owns the foreground service, the incoming-call notification, and the full-screen intent, so you don't wire any of that up.
191
+ - **Native language.** callkeep is Objective-C + Java. This module is Swift + Kotlin.
192
+ - **VoIP push parsing.** callkeep doesn't parse pushes — you wire up `pushRegistry:didReceiveIncomingPushWithPayload:` (or `react-native-voip-push-notification`) and FCM data handling yourself. This module parses APNs VoIP and FCM payloads natively, before JS is running, so calls report correctly from a terminated state without app-side glue.
193
+ - **Audio session.** callkeep manipulates `AVAudioSession` directly, leaving WebRTC's `RTCAudioSession` to be coordinated by the app. This module integrates with `RTCAudioSession` so manual-audio WebRTC stacks (LiveKit, plain WebRTC) work without extra wiring.
194
+ - **API shape.** callkeep's options are split into `{ ios: {...}, android: {...} }` and several methods are platform-only. This module exposes one typed `CallSession` object and one set of verbs (`request` / `report` / `fulfill`) that work the same on both platforms.
195
+ - **Expo support.** This module is an Expo Module with a config plugin that handles entitlements, background modes, permissions, ringtone bundling, and FCM service registration.
196
+ - **Tested with.** iOS 26 / Android 15, on real devices, with LiveKit as the media transport.
197
+
@@ -0,0 +1,32 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'expo.modules.callkittelecom'
7
+ version = '0.1.0'
8
+
9
+ android {
10
+ namespace "expo.modules.callkittelecom"
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName "0.1.0"
14
+ }
15
+ lintOptions {
16
+ abortOnError false
17
+ }
18
+ }
19
+
20
+ dependencies {
21
+ implementation "androidx.core:core-telecom:1.0.1"
22
+ implementation "com.google.firebase:firebase-messaging:24.1.0"
23
+
24
+ // expo-notifications may be included as a source project or as a prebuilt
25
+ // Maven artifact via Expo's version catalog, depending on the SDK version.
26
+ def notificationsProject = rootProject.findProject(":expo-notifications")
27
+ if (notificationsProject != null) {
28
+ compileOnly notificationsProject
29
+ } else {
30
+ compileOnly "host.exp.exponent:expo.modules.notifications:+"
31
+ }
32
+ }
@@ -0,0 +1,33 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
3
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
4
+ <uses-permission android:name="android.permission.CAMERA" />
5
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
6
+ <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
7
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
8
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
9
+
10
+ <application>
11
+ <activity
12
+ android:name="expo.modules.callkittelecom.IncomingCallActivity"
13
+ android:exported="false"
14
+ android:showWhenLocked="true"
15
+ android:turnScreenOn="true"
16
+ android:launchMode="singleInstance"
17
+ android:excludeFromRecents="true"
18
+ android:taskAffinity=""
19
+ android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen" />
20
+
21
+ <receiver
22
+ android:name="expo.modules.callkittelecom.services.CallNotificationReceiver"
23
+ android:exported="false" />
24
+
25
+ <service
26
+ android:name="expo.modules.callkittelecom.services.ExpoCallKitTelecomMessagingService"
27
+ android:exported="false">
28
+ <intent-filter>
29
+ <action android:name="com.google.firebase.MESSAGING_EVENT" />
30
+ </intent-filter>
31
+ </service>
32
+ </application>
33
+ </manifest>
@@ -0,0 +1,384 @@
1
+ package expo.modules.callkittelecom
2
+
3
+ import expo.modules.callkittelecom.events.CallEventEmitter
4
+ import expo.modules.callkittelecom.events.CallEvents
5
+ import expo.modules.callkittelecom.managers.CallAudioManager
6
+ import expo.modules.callkittelecom.managers.CallManager
7
+ import expo.modules.callkittelecom.managers.CaptureSessionManager
8
+ import expo.modules.callkittelecom.services.CallNotificationReceiver
9
+ import expo.modules.callkittelecom.managers.VoIPPushManager
10
+ import expo.modules.callkittelecom.models.CallEndedReason
11
+ import expo.modules.callkittelecom.models.CallOptions
12
+ import expo.modules.callkittelecom.models.CallParticipant
13
+ import expo.modules.callkittelecom.models.IncomingCallEvent
14
+ import expo.modules.callkittelecom.store.CallStore
15
+ import expo.modules.kotlin.modules.Module
16
+ import expo.modules.kotlin.modules.ModuleDefinition
17
+ import java.util.UUID
18
+
19
+ class ExpoCallKitTelecomModule : Module() {
20
+ /**
21
+ * Checks the current activity's launch intent for ACTION_ANSWER.
22
+ *
23
+ * On cold start from a notification answer action, the intent is delivered
24
+ * to onCreate() — not onNewIntent() — so OnNewIntent never fires.
25
+ * This method handles that case by checking the launch intent directly.
26
+ */
27
+ private fun handleLaunchIntent() {
28
+ val intent = appContext.currentActivity?.intent ?: return
29
+ handleAnswerIntent(intent)
30
+ }
31
+
32
+ /** Extracts the call ID from an ACTION_ANSWER intent and answers the call. */
33
+ private fun handleAnswerIntent(intent: android.content.Intent) {
34
+ if (intent.action != CallNotificationReceiver.ACTION_ANSWER) return
35
+
36
+ val callIdStr = intent.getStringExtra(CallNotificationReceiver.EXTRA_CALL_ID)
37
+ ?: return
38
+ val callId = try {
39
+ UUID.fromString(callIdStr)
40
+ } catch (_: IllegalArgumentException) {
41
+ return
42
+ }
43
+
44
+ // Clear the action so it isn't re-processed on configuration changes
45
+ intent.action = null
46
+
47
+ CallManager.shared.answerCall(callId)
48
+ }
49
+
50
+ override fun definition() =
51
+ ModuleDefinition {
52
+ Name("ExpoCallKitTelecom")
53
+
54
+ // region Events
55
+
56
+ Events(
57
+ CallEvents.CALL_SESSION_ADDED,
58
+ CallEvents.CALL_SESSION_UPDATED,
59
+ CallEvents.CALL_SESSION_REMOVED,
60
+ CallEvents.AUDIO_SESSION_ACTIVATED,
61
+ CallEvents.AUDIO_SESSION_DEACTIVATED,
62
+ CallEvents.AUDIO_ROUTE_CHANGED,
63
+ CallEvents.INCOMING_CALL_REPORTED,
64
+ CallEvents.OUTGOING_CALL_STARTED,
65
+ CallEvents.CALL_ANSWERED,
66
+ CallEvents.CALL_ENDED,
67
+ CallEvents.CALL_REPORTED_ENDED,
68
+ CallEvents.SET_MUTED_ACTION,
69
+ CallEvents.VIDEO_CHANGED,
70
+ CallEvents.SET_HELD_ACTION,
71
+ CallEvents.DTMF,
72
+ CallEvents.CALL_INTENT_RECEIVED,
73
+ CallEvents.VOIP_PUSH_TOKEN_UPDATED,
74
+ )
75
+
76
+ // endregion
77
+
78
+ // region Lifecycle
79
+
80
+ OnCreate {
81
+ val context = appContext.reactContext ?: return@OnCreate
82
+
83
+ CallManager.shared.initialize(context)
84
+
85
+ VoIPPushManager.register()
86
+
87
+ CallEventEmitter.setSender { eventName, body ->
88
+ sendEvent(eventName, body)
89
+ }
90
+
91
+ // Handle the launch intent for cold-start answer actions.
92
+ // OnNewIntent only fires when the activity is already running.
93
+ // On cold start the ACTION_ANSWER intent is the launch intent,
94
+ // so we must check it here.
95
+ handleLaunchIntent()
96
+ }
97
+
98
+ OnDestroy {
99
+ CallEventEmitter.setSender(null)
100
+ }
101
+
102
+ OnNewIntent { intent ->
103
+ handleAnswerIntent(intent)
104
+ }
105
+
106
+ // Register per-event observers
107
+ OnStartObserving(CallEvents.CALL_SESSION_ADDED) {
108
+ CallEventEmitter.startObserving(CallEvents.CALL_SESSION_ADDED)
109
+ }
110
+ OnStopObserving(CallEvents.CALL_SESSION_ADDED) {
111
+ CallEventEmitter.stopObserving(CallEvents.CALL_SESSION_ADDED)
112
+ }
113
+
114
+ OnStartObserving(CallEvents.CALL_SESSION_UPDATED) {
115
+ CallEventEmitter.startObserving(CallEvents.CALL_SESSION_UPDATED)
116
+ }
117
+ OnStopObserving(CallEvents.CALL_SESSION_UPDATED) {
118
+ CallEventEmitter.stopObserving(CallEvents.CALL_SESSION_UPDATED)
119
+ }
120
+
121
+ OnStartObserving(CallEvents.CALL_SESSION_REMOVED) {
122
+ CallEventEmitter.startObserving(CallEvents.CALL_SESSION_REMOVED)
123
+ }
124
+ OnStopObserving(CallEvents.CALL_SESSION_REMOVED) {
125
+ CallEventEmitter.stopObserving(CallEvents.CALL_SESSION_REMOVED)
126
+ }
127
+
128
+ OnStartObserving(CallEvents.AUDIO_SESSION_ACTIVATED) {
129
+ CallEventEmitter.startObserving(CallEvents.AUDIO_SESSION_ACTIVATED)
130
+ }
131
+ OnStopObserving(CallEvents.AUDIO_SESSION_ACTIVATED) {
132
+ CallEventEmitter.stopObserving(CallEvents.AUDIO_SESSION_ACTIVATED)
133
+ }
134
+
135
+ OnStartObserving(CallEvents.AUDIO_SESSION_DEACTIVATED) {
136
+ CallEventEmitter.startObserving(CallEvents.AUDIO_SESSION_DEACTIVATED)
137
+ }
138
+ OnStopObserving(CallEvents.AUDIO_SESSION_DEACTIVATED) {
139
+ CallEventEmitter.stopObserving(CallEvents.AUDIO_SESSION_DEACTIVATED)
140
+ }
141
+
142
+ OnStartObserving(CallEvents.AUDIO_ROUTE_CHANGED) {
143
+ CallEventEmitter.startObserving(CallEvents.AUDIO_ROUTE_CHANGED)
144
+ }
145
+ OnStopObserving(CallEvents.AUDIO_ROUTE_CHANGED) {
146
+ CallEventEmitter.stopObserving(CallEvents.AUDIO_ROUTE_CHANGED)
147
+ }
148
+
149
+ OnStartObserving(CallEvents.INCOMING_CALL_REPORTED) {
150
+ CallEventEmitter.startObserving(CallEvents.INCOMING_CALL_REPORTED)
151
+ }
152
+ OnStopObserving(CallEvents.INCOMING_CALL_REPORTED) {
153
+ CallEventEmitter.stopObserving(CallEvents.INCOMING_CALL_REPORTED)
154
+ }
155
+
156
+ OnStartObserving(CallEvents.OUTGOING_CALL_STARTED) {
157
+ CallEventEmitter.startObserving(CallEvents.OUTGOING_CALL_STARTED)
158
+ }
159
+ OnStopObserving(CallEvents.OUTGOING_CALL_STARTED) {
160
+ CallEventEmitter.stopObserving(CallEvents.OUTGOING_CALL_STARTED)
161
+ }
162
+
163
+ OnStartObserving(CallEvents.CALL_ANSWERED) {
164
+ CallEventEmitter.startObserving(CallEvents.CALL_ANSWERED)
165
+ }
166
+ OnStopObserving(CallEvents.CALL_ANSWERED) {
167
+ CallEventEmitter.stopObserving(CallEvents.CALL_ANSWERED)
168
+ }
169
+
170
+ OnStartObserving(CallEvents.CALL_ENDED) {
171
+ CallEventEmitter.startObserving(CallEvents.CALL_ENDED)
172
+ }
173
+ OnStopObserving(CallEvents.CALL_ENDED) {
174
+ CallEventEmitter.stopObserving(CallEvents.CALL_ENDED)
175
+ }
176
+
177
+ OnStartObserving(CallEvents.CALL_REPORTED_ENDED) {
178
+ CallEventEmitter.startObserving(CallEvents.CALL_REPORTED_ENDED)
179
+ }
180
+ OnStopObserving(CallEvents.CALL_REPORTED_ENDED) {
181
+ CallEventEmitter.stopObserving(CallEvents.CALL_REPORTED_ENDED)
182
+ }
183
+
184
+ OnStartObserving(CallEvents.SET_MUTED_ACTION) {
185
+ CallEventEmitter.startObserving(CallEvents.SET_MUTED_ACTION)
186
+ }
187
+ OnStopObserving(CallEvents.SET_MUTED_ACTION) {
188
+ CallEventEmitter.stopObserving(CallEvents.SET_MUTED_ACTION)
189
+ }
190
+
191
+ OnStartObserving(CallEvents.VIDEO_CHANGED) {
192
+ CallEventEmitter.startObserving(CallEvents.VIDEO_CHANGED)
193
+ }
194
+ OnStopObserving(CallEvents.VIDEO_CHANGED) {
195
+ CallEventEmitter.stopObserving(CallEvents.VIDEO_CHANGED)
196
+ }
197
+
198
+ OnStartObserving(CallEvents.SET_HELD_ACTION) {
199
+ CallEventEmitter.startObserving(CallEvents.SET_HELD_ACTION)
200
+ }
201
+ OnStopObserving(CallEvents.SET_HELD_ACTION) {
202
+ CallEventEmitter.stopObserving(CallEvents.SET_HELD_ACTION)
203
+ }
204
+
205
+ OnStartObserving(CallEvents.DTMF) {
206
+ CallEventEmitter.startObserving(CallEvents.DTMF)
207
+ }
208
+ OnStopObserving(CallEvents.DTMF) {
209
+ CallEventEmitter.stopObserving(CallEvents.DTMF)
210
+ }
211
+
212
+ OnStartObserving(CallEvents.CALL_INTENT_RECEIVED) {
213
+ CallEventEmitter.startObserving(CallEvents.CALL_INTENT_RECEIVED)
214
+ }
215
+ OnStopObserving(CallEvents.CALL_INTENT_RECEIVED) {
216
+ CallEventEmitter.stopObserving(CallEvents.CALL_INTENT_RECEIVED)
217
+ }
218
+
219
+ OnStartObserving(CallEvents.VOIP_PUSH_TOKEN_UPDATED) {
220
+ CallEventEmitter.startObserving(CallEvents.VOIP_PUSH_TOKEN_UPDATED)
221
+ }
222
+ OnStopObserving(CallEvents.VOIP_PUSH_TOKEN_UPDATED) {
223
+ CallEventEmitter.stopObserving(CallEvents.VOIP_PUSH_TOKEN_UPDATED)
224
+ }
225
+
226
+ // endregion
227
+
228
+ // region Call Session
229
+
230
+ // Returns the first active call session, if present.
231
+ AsyncFunction("getActiveCallSession") {
232
+ CallStore.firstSession()?.toMap()
233
+ }
234
+
235
+ // endregion
236
+
237
+ // region Audio Session
238
+
239
+ // Returns current audio session snapshot for diagnostics/UI state.
240
+ Function("getAudioSessionState") {
241
+ CallAudioManager.getAudioSessionState()
242
+ }
243
+
244
+ // Prepares audio session state before starting/reporting a call.
245
+ Function("prepareAudioSessionForCall") { hasVideo: Boolean ->
246
+ CallAudioManager.prepareAudioSessionForCall(hasVideo)
247
+ }
248
+
249
+ // Restores pre-call audio mode/route state.
250
+ Function("restoreAudioSession") {
251
+ CallAudioManager.restoreAudioSession()
252
+ }
253
+
254
+ // Overrides route to speaker (true) or clears override (false).
255
+ Function("setAudioSessionPortOverride") { enabled: Boolean ->
256
+ CallAudioManager.setAudioSessionPortOverride(enabled)
257
+ }
258
+
259
+ // endregion
260
+
261
+ // region Capture Session
262
+
263
+ // Returns capture session state (currently camera permission).
264
+ Function("getCaptureSessionState") {
265
+ CaptureSessionManager.getCaptureSessionState()
266
+ }
267
+
268
+ // endregion
269
+
270
+ // region Start Outgoing Call
271
+
272
+ // Starts a new outgoing call and returns the native call session ID.
273
+ AsyncFunction("startOutgoingCall") { recipient: Map<String, Any?>, options: Map<String, Any?> ->
274
+ val callId =
275
+ CallManager.shared.startOutgoingCall(
276
+ recipient = CallParticipant.fromMap(recipient),
277
+ options = CallOptions(hasVideo = options["hasVideo"] as? Boolean ?: false),
278
+ )
279
+ callId
280
+ }
281
+
282
+ // endregion
283
+
284
+ // region Report Incoming Call
285
+
286
+ // Reports an incoming call to Telecom from app/push events.
287
+ AsyncFunction("reportIncomingCall") { event: Map<String, Any?> ->
288
+ CallManager.shared.reportIncomingCall(IncomingCallEvent.fromMap(event))
289
+ }
290
+
291
+ // endregion
292
+
293
+ // region Answer Call
294
+
295
+ // Answers an existing incoming call session.
296
+ AsyncFunction("answerCall") { id: String ->
297
+ CallManager.shared.answerCall(UUID.fromString(id))
298
+ }
299
+
300
+ // Fulfills pending incoming-call answer once media is connected.
301
+ AsyncFunction("fulfillIncomingCallAnswered") { requestId: String ->
302
+ CallManager.shared.fulfillIncomingCallConnected(UUID.fromString(requestId))
303
+ }
304
+
305
+
306
+
307
+ // Reports outgoing call media connection established.
308
+ AsyncFunction("reportOutgoingCallConnected") { id: String ->
309
+ CallManager.shared.reportOutgoingCallConnected(UUID.fromString(id))
310
+ }
311
+
312
+ // endregion
313
+
314
+ // region End Call
315
+
316
+ // Ends an active call.
317
+ AsyncFunction("endCall") { id: String ->
318
+ CallManager.shared.endCall(UUID.fromString(id))
319
+ }
320
+
321
+ // Reports an externally-ended call with explicit reason.
322
+ AsyncFunction("reportCallEnded") { id: String, reason: String ->
323
+ CallManager.shared.reportCallEnded(
324
+ UUID.fromString(id),
325
+ CallEndedReason.fromValue(reason),
326
+ )
327
+ }
328
+
329
+ // endregion
330
+
331
+ // region Mute Support
332
+
333
+ // Sets mute state for a call.
334
+ AsyncFunction("setMuted") { id: String, muted: Boolean ->
335
+ CallManager.shared.setMuted(UUID.fromString(id), muted)
336
+ }
337
+
338
+ // endregion
339
+
340
+ // region Video Support
341
+
342
+ // Reports call video enabled state changes.
343
+ AsyncFunction("reportVideo") { id: String, enabled: Boolean ->
344
+ CallManager.shared.reportVideo(UUID.fromString(id), enabled)
345
+ }
346
+
347
+ // endregion
348
+
349
+ // region Hold Support
350
+
351
+ // Sets call hold state.
352
+ AsyncFunction("setHeld") { id: String, onHold: Boolean ->
353
+ CallManager.shared.setHeld(UUID.fromString(id), onHold)
354
+ }
355
+
356
+ // endregion
357
+
358
+ // region DTMF Support
359
+
360
+ // Sends requested DTMF digits for a call.
361
+ AsyncFunction("playDTMF") { id: String, digits: String ->
362
+ CallManager.shared.playDTMF(UUID.fromString(id), digits)
363
+ }
364
+
365
+ // endregion
366
+
367
+ // region VoIP Push
368
+
369
+ // Registers for VoIP push by fetching the FCM token.
370
+ Function("registerVoIPPush") {
371
+ VoIPPushManager.register()
372
+ }
373
+
374
+ // Returns the current VoIP push token and its type.
375
+ Function("getVoIPPushToken") {
376
+ mapOf(
377
+ "token" to VoIPPushManager.token,
378
+ "type" to "FCM",
379
+ )
380
+ }
381
+
382
+ // endregion
383
+ }
384
+ }