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
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
|
+
}
|