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,150 @@
1
+ package expo.modules.callkittelecom.managers
2
+
3
+ import expo.modules.callkittelecom.utils.CallKitTelecomLog
4
+ import kotlinx.coroutines.CoroutineScope
5
+ import kotlinx.coroutines.Dispatchers
6
+ import kotlinx.coroutines.Job
7
+ import kotlinx.coroutines.SupervisorJob
8
+ import kotlinx.coroutines.delay
9
+ import kotlinx.coroutines.launch
10
+ import java.util.UUID
11
+
12
+ /**
13
+ * Pending fulfill request metadata for answered incoming calls.
14
+ *
15
+ * `requestId` is sent to JS and must be fulfilled once media is connected.
16
+ */
17
+ data class FulfillRequest(
18
+ val requestId: UUID,
19
+ val callId: UUID,
20
+ )
21
+
22
+ /** Result of a fulfill request. */
23
+ sealed interface FulfillResult {
24
+ /** The request was successfully fulfilled, includes the associated call ID. */
25
+ data class Fulfilled(
26
+ val callId: UUID,
27
+ ) : FulfillResult
28
+
29
+ /** The request timed out before being fulfilled. */
30
+ data object TimedOut : FulfillResult
31
+ }
32
+
33
+ /**
34
+ * Tracks pending answer fulfill requests with timeout behavior.
35
+ *
36
+ * Semantics:
37
+ * - create request on answer
38
+ * - resolve via fulfill(requestId)
39
+ * - auto-timeout if JS never fulfills
40
+ *
41
+ * All mutable state is guarded by [lock] for thread safety.
42
+ */
43
+ object FulfillRequestManager {
44
+ private const val TAG = "ExpoCallKitTelecom.Fulfill"
45
+
46
+ private val lock = Any()
47
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
48
+ private val requests = mutableMapOf<UUID, UUID>()
49
+ private val timeoutJobs = mutableMapOf<UUID, Job>()
50
+
51
+ /**
52
+ * Creates a pending fulfill request for a call.
53
+ *
54
+ * @param callId Call UUID associated with this request.
55
+ * @param timeoutMs Maximum wait time before automatic timeout.
56
+ * @param onTimeout Callback invoked with the call UUID when request expires.
57
+ */
58
+ fun createRequest(
59
+ callId: UUID,
60
+ timeoutMs: Long,
61
+ onTimeout: (UUID) -> Unit,
62
+ ): FulfillRequest {
63
+ val requestId = UUID.randomUUID()
64
+
65
+ val job =
66
+ scope.launch {
67
+ delay(timeoutMs)
68
+ val removedCallId =
69
+ synchronized(lock) {
70
+ timeoutJobs.remove(requestId)
71
+ requests.remove(requestId)
72
+ }
73
+ if (removedCallId != null) {
74
+ CallKitTelecomLog.d(TAG) { "Fulfill request timed out - requestId: $requestId, callId: $removedCallId" }
75
+ onTimeout(removedCallId)
76
+ }
77
+ }
78
+
79
+ synchronized(lock) {
80
+ requests[requestId] = callId
81
+ timeoutJobs[requestId] = job
82
+ }
83
+
84
+ CallKitTelecomLog.d(TAG) { "Created fulfill request - requestId: $requestId, callId: $callId, timeout: ${timeoutMs}ms" }
85
+ return FulfillRequest(requestId = requestId, callId = callId)
86
+ }
87
+
88
+ /**
89
+ * Fulfills a request by ID.
90
+ *
91
+ * @return associated call UUID when request exists, else null (already timed out/handled).
92
+ */
93
+ fun fulfill(requestId: UUID): UUID? {
94
+ val job: Job?
95
+ val callId: UUID?
96
+ synchronized(lock) {
97
+ job = timeoutJobs.remove(requestId)
98
+ callId = requests.remove(requestId)
99
+ }
100
+ job?.cancel()
101
+
102
+ if (callId != null) {
103
+ CallKitTelecomLog.d(TAG) { "Fulfill request succeeded - requestId: $requestId, callId: $callId" }
104
+ } else {
105
+ CallKitTelecomLog.d(TAG) { "Fulfill request not found (likely timed out) - requestId: $requestId" }
106
+ }
107
+ return callId
108
+ }
109
+
110
+ /**
111
+ * Cancels a pending request by request ID without fulfilling it.
112
+ *
113
+ * Use when the request should be aborted (e.g., call ended before connection).
114
+ */
115
+ fun cancel(requestId: UUID) {
116
+ val job: Job?
117
+ synchronized(lock) {
118
+ job = timeoutJobs.remove(requestId)
119
+ requests.remove(requestId)
120
+ }
121
+ job?.cancel()
122
+ if (job != null) {
123
+ CallKitTelecomLog.d(TAG) { "Fulfill request cancelled - requestId: $requestId" }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Cancels any pending fulfill request associated with a specific call.
129
+ *
130
+ * Used when a call ends before JS fulfills the answer request.
131
+ * This is a convenience for the call-end path where only the call ID
132
+ * is available rather than the request ID.
133
+ */
134
+ fun cancelForCall(callId: UUID) {
135
+ val entriesToCancel: List<Pair<UUID, Job?>>
136
+ synchronized(lock) {
137
+ val requestIds = requests.entries.filter { it.value == callId }.map { it.key }
138
+ entriesToCancel =
139
+ requestIds.map { reqId ->
140
+ val job = timeoutJobs.remove(reqId)
141
+ requests.remove(reqId)
142
+ reqId to job
143
+ }
144
+ }
145
+ entriesToCancel.forEach { (reqId, job) ->
146
+ job?.cancel()
147
+ CallKitTelecomLog.d(TAG) { "Fulfill request cancelled for call - requestId: $reqId, callId: $callId" }
148
+ }
149
+ }
150
+ }
@@ -0,0 +1,54 @@
1
+ package expo.modules.callkittelecom.managers
2
+
3
+ import android.util.Log
4
+ import com.google.firebase.messaging.FirebaseMessaging
5
+ import expo.modules.callkittelecom.events.CallEventEmitter
6
+ import expo.modules.callkittelecom.events.CallEvents
7
+ import expo.modules.callkittelecom.utils.CallKitTelecomLog
8
+
9
+ /**
10
+ * Manages FCM push token registration and storage.
11
+ *
12
+ * This singleton mirrors iOS's VoIPPushManager and handles:
13
+ * - Registering for FCM push tokens
14
+ * - Storing and exposing the current push token
15
+ * - Emitting events when the token updates
16
+ */
17
+ object VoIPPushManager {
18
+ private const val TAG = "ExpoCallKitTelecom.VoIPPush"
19
+
20
+ /** The current FCM push token, if available. */
21
+ @Volatile
22
+ var token: String? = null
23
+ private set
24
+
25
+ /** Registers for FCM push tokens by fetching the current token. */
26
+ fun register() {
27
+ FirebaseMessaging
28
+ .getInstance()
29
+ .token
30
+ .addOnSuccessListener { newToken ->
31
+ updateToken(newToken)
32
+ }.addOnFailureListener { error ->
33
+ Log.e(TAG, "Failed to get FCM token: ${error.message}", error)
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Updates the stored token and emits an event to JS.
39
+ *
40
+ * @param newToken The new token string, or null if invalidated.
41
+ */
42
+ fun updateToken(newToken: String?) {
43
+ val oldToken = token
44
+ token = newToken
45
+
46
+ if (oldToken != newToken) {
47
+ CallKitTelecomLog.d(TAG) { "VoIP token updated - hasToken: ${newToken != null}" }
48
+ CallEventEmitter.send(
49
+ CallEvents.VOIP_PUSH_TOKEN_UPDATED,
50
+ mapOf("token" to newToken, "type" to "FCM"),
51
+ )
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,269 @@
1
+ package expo.modules.callkittelecom.models
2
+
3
+ import java.time.Instant
4
+ import java.time.format.DateTimeFormatter
5
+ import java.util.UUID
6
+
7
+ /** Source/origin of a call session from the app/system perspective. */
8
+ enum class CallSessionOrigin(
9
+ val value: String,
10
+ ) {
11
+ INCOMING("incoming"),
12
+ OUTGOING_APP("outgoingApp"),
13
+ OUTGOING_SYSTEM("outgoingSystem"),
14
+ }
15
+
16
+ /** Lifecycle status of a native call session. */
17
+ enum class CallSessionStatus(
18
+ val value: String,
19
+ ) {
20
+ REQUESTING("requesting"),
21
+ CONNECTING("connecting"),
22
+ RINGING("ringing"),
23
+ CONNECTED("connected"),
24
+ ENDED("ended"),
25
+ }
26
+
27
+ /** Call-level options shared with JS and stored in session state. */
28
+ data class CallOptions(
29
+ val hasVideo: Boolean,
30
+ )
31
+
32
+ /** Remote participant identity and optional contact/display details. */
33
+ data class CallParticipant(
34
+ val id: String,
35
+ val phoneNumber: String? = null,
36
+ val email: String? = null,
37
+ val displayName: String? = null,
38
+ val avatarUrl: String? = null,
39
+ ) {
40
+ /** Serializes participant data into the JS-facing event/session shape. */
41
+ fun toMap(): Map<String, Any?> =
42
+ mapOf(
43
+ "id" to id,
44
+ "phoneNumber" to phoneNumber,
45
+ "email" to email,
46
+ "displayName" to displayName,
47
+ "avatarUrl" to avatarUrl,
48
+ ).filterValues { it != null }
49
+
50
+ companion object {
51
+ /** Parses a JS/record dictionary into a strongly-typed participant model. */
52
+ fun fromMap(map: Map<String, Any?>): CallParticipant =
53
+ CallParticipant(
54
+ id = map["id"] as? String ?: "",
55
+ phoneNumber = map["phoneNumber"] as? String,
56
+ email = map["email"] as? String,
57
+ displayName = map["displayName"] as? String,
58
+ avatarUrl = map["avatarUrl"] as? String,
59
+ )
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Validated incoming call event.
65
+ *
66
+ * Mirrors the TS `IncomingCallEvent` in `src/Calls.types.ts`.
67
+ *
68
+ * Two construction paths:
69
+ * - [fromMap]: parses JS camelCase dictionaries (used by `reportIncomingCall`).
70
+ * - [fromPayload]: parses a push payload that wraps the event under the
71
+ * top-level `incoming_call` key (used by VoIP push handling).
72
+ */
73
+ data class IncomingCallEvent(
74
+ val eventId: String,
75
+ /** Server-assigned id for this call (distinct from the native UUID). */
76
+ val serverCallId: String,
77
+ val caller: Caller,
78
+ val hasVideo: Boolean,
79
+ /** Optional; defaults to now if absent. */
80
+ val startedAt: Instant,
81
+ /** App-defined extra fields forwarded verbatim from the push payload. */
82
+ val metadata: Map<String, Any?>? = null,
83
+ ) {
84
+ /** Caller information embedded in incoming call events. */
85
+ data class Caller(
86
+ val id: String,
87
+ val displayName: String? = null,
88
+ val phoneNumber: String? = null,
89
+ val email: String? = null,
90
+ val avatarUrl: String? = null,
91
+ ) {
92
+ /** Serializes caller data into JS-facing payload shape. */
93
+ fun toMap(): Map<String, Any?> =
94
+ mapOf(
95
+ "id" to id,
96
+ "displayName" to displayName,
97
+ "phoneNumber" to phoneNumber,
98
+ "email" to email,
99
+ "avatarUrl" to avatarUrl,
100
+ ).filterValues { it != null }
101
+
102
+ companion object {
103
+ /** Parses caller dictionaries (camelCase). */
104
+ fun fromMap(map: Map<String, Any?>): Caller =
105
+ Caller(
106
+ id = map["id"] as? String ?: "",
107
+ displayName = map["displayName"] as? String,
108
+ phoneNumber = map["phoneNumber"] as? String,
109
+ email = map["email"] as? String,
110
+ avatarUrl = map["avatarUrl"] as? String,
111
+ )
112
+ }
113
+ }
114
+
115
+ /** Serializes the event into the session payload shape expected by JS. */
116
+ fun toMap(): Map<String, Any?> {
117
+ val map =
118
+ mutableMapOf<String, Any?>(
119
+ "eventId" to eventId,
120
+ "serverCallId" to serverCallId,
121
+ "caller" to caller.toMap(),
122
+ "hasVideo" to hasVideo,
123
+ "startedAt" to DateTimeFormatter.ISO_INSTANT.format(startedAt),
124
+ )
125
+ if (metadata != null) {
126
+ map["metadata"] = metadata
127
+ }
128
+ return map
129
+ }
130
+
131
+ companion object {
132
+ /**
133
+ * Parses and validates a JS-supplied event dictionary (camelCase keys).
134
+ *
135
+ * Required: `eventId`, `serverCallId`, `caller.id`.
136
+ */
137
+ fun fromMap(map: Map<String, Any?>): IncomingCallEvent {
138
+ val callerMap = map["caller"] as? Map<String, Any?> ?: emptyMap()
139
+ val startedAt =
140
+ (map["startedAt"] as? String)?.let {
141
+ try {
142
+ Instant.parse(it)
143
+ } catch (_: Throwable) {
144
+ Instant.now()
145
+ }
146
+ } ?: Instant.now()
147
+
148
+ val eventId = map["eventId"] as? String ?: ""
149
+ val serverCallId = map["serverCallId"] as? String ?: ""
150
+ val callerId = callerMap["id"] as? String ?: ""
151
+
152
+ require(eventId.isNotBlank()) { "IncomingCallEvent.eventId is required" }
153
+ require(serverCallId.isNotBlank()) { "IncomingCallEvent.serverCallId is required" }
154
+ require(callerId.isNotBlank()) { "IncomingCallEvent.caller.id is required" }
155
+
156
+ @Suppress("UNCHECKED_CAST")
157
+ val metadata = map["metadata"] as? Map<String, Any?>
158
+
159
+ return IncomingCallEvent(
160
+ eventId = eventId,
161
+ serverCallId = serverCallId,
162
+ caller = Caller.fromMap(callerMap),
163
+ hasVideo = map["hasVideo"] as? Boolean ?: false,
164
+ startedAt = startedAt,
165
+ metadata = metadata,
166
+ )
167
+ }
168
+
169
+ /**
170
+ * Parses an `IncomingCallEvent` from a push payload.
171
+ *
172
+ * The payload MUST wrap the event under the top-level key `incoming_call`.
173
+ * There is no fallback to a flat top-level shape. Inner keys are
174
+ * camelCase, matching the TS contract and the example server.
175
+ *
176
+ * Returns `null` if the envelope is missing or required fields are
177
+ * absent.
178
+ */
179
+ @Suppress("UNCHECKED_CAST")
180
+ fun fromPayload(payload: Map<String, Any?>): IncomingCallEvent? {
181
+ val event = payload["incoming_call"] as? Map<String, Any?> ?: return null
182
+
183
+ val eventId = event["eventId"] as? String ?: ""
184
+ val serverCallId = event["serverCallId"] as? String ?: ""
185
+ val callerMap = event["caller"] as? Map<String, Any?> ?: return null
186
+ val callerId = callerMap["id"] as? String ?: ""
187
+
188
+ if (eventId.isBlank() || serverCallId.isBlank() || callerId.isBlank()) {
189
+ return null
190
+ }
191
+
192
+ val startedAt =
193
+ (event["startedAt"] as? String)?.let {
194
+ try {
195
+ Instant.parse(it)
196
+ } catch (_: Throwable) {
197
+ null
198
+ }
199
+ } ?: Instant.now()
200
+
201
+ return IncomingCallEvent(
202
+ eventId = eventId,
203
+ serverCallId = serverCallId,
204
+ caller = Caller.fromMap(callerMap),
205
+ hasVideo = event["hasVideo"] as? Boolean ?: false,
206
+ startedAt = startedAt,
207
+ metadata = event["metadata"] as? Map<String, Any?>,
208
+ )
209
+ }
210
+ }
211
+ }
212
+
213
+ /** In-memory representation of an active native call session. */
214
+ data class CallSession(
215
+ val id: UUID,
216
+ val options: CallOptions,
217
+ val origin: CallSessionOrigin,
218
+ val remoteParticipants: List<CallParticipant>,
219
+ val incomingCallEvent: IncomingCallEvent? = null,
220
+ val status: CallSessionStatus,
221
+ val connectedAt: Instant? = null,
222
+ val isMuted: Boolean = false,
223
+ val isOnHold: Boolean = false,
224
+ val dtmfDigits: String? = null,
225
+ ) {
226
+ /** Serializes session state into the exact JS-facing `CallSession` shape. */
227
+ fun toMap(): Map<String, Any?> {
228
+ val map =
229
+ mutableMapOf<String, Any?>(
230
+ "id" to id.toString(),
231
+ "options" to mapOf("hasVideo" to options.hasVideo),
232
+ "origin" to origin.value,
233
+ "remoteParticipants" to remoteParticipants.map { it.toMap() },
234
+ "status" to status.value,
235
+ "isMuted" to isMuted,
236
+ "isOnHold" to isOnHold,
237
+ )
238
+
239
+ if (dtmfDigits != null) {
240
+ map["dtmfDigits"] = dtmfDigits
241
+ }
242
+ if (connectedAt != null) {
243
+ map["connectedAt"] = DateTimeFormatter.ISO_INSTANT.format(connectedAt)
244
+ }
245
+ if (incomingCallEvent != null) {
246
+ map["incomingCallEvent"] = incomingCallEvent.toMap()
247
+ }
248
+
249
+ return map
250
+ }
251
+ }
252
+
253
+ /** Normalized call end reasons supported by the shared JS API. */
254
+ enum class CallEndedReason(
255
+ val value: String,
256
+ ) {
257
+ FAILED("failed"),
258
+ REMOTE_ENDED("remoteEnded"),
259
+ UNANSWERED("unanswered"),
260
+ ANSWERED_ELSEWHERE("answeredElsewhere"),
261
+ DECLINED_ELSEWHERE("declinedElsewhere"),
262
+ UNKNOWN("unknown"),
263
+ ;
264
+
265
+ companion object {
266
+ /** Safely maps a reason string to enum, defaulting to `UNKNOWN`. */
267
+ fun fromValue(value: String): CallEndedReason = entries.firstOrNull { it.value == value } ?: UNKNOWN
268
+ }
269
+ }
@@ -0,0 +1,54 @@
1
+ package expo.modules.callkittelecom.services
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import expo.modules.callkittelecom.managers.CallManager
7
+ import expo.modules.callkittelecom.utils.CallKitTelecomLog
8
+ import java.util.UUID
9
+
10
+ /**
11
+ * Handles decline actions from call notifications.
12
+ *
13
+ * Answer actions use PendingIntent.getActivity() to bring the app to the
14
+ * foreground directly (required on Android 12+), and are handled by
15
+ * [ExpoCallKitTelecomModule.OnNewIntent]. This receiver only handles decline.
16
+ */
17
+ class CallNotificationReceiver : BroadcastReceiver() {
18
+ companion object {
19
+ private const val TAG = "ExpoCallKitTelecom.NotifReceiver"
20
+
21
+ const val ACTION_ANSWER = "expo.modules.callkittelecom.ACTION_ANSWER"
22
+ const val ACTION_DECLINE = "expo.modules.callkittelecom.ACTION_DECLINE"
23
+ const val EXTRA_CALL_ID = "expo.modules.callkittelecom.EXTRA_CALL_ID"
24
+ }
25
+
26
+ override fun onReceive(
27
+ context: Context,
28
+ intent: Intent,
29
+ ) {
30
+ val callIdStr = intent.getStringExtra(EXTRA_CALL_ID) ?: return
31
+ val callId =
32
+ try {
33
+ UUID.fromString(callIdStr)
34
+ } catch (_: IllegalArgumentException) {
35
+ CallKitTelecomLog.e(TAG) { "Invalid call ID in notification action: $callIdStr" }
36
+ return
37
+ }
38
+
39
+ when (intent.action) {
40
+ ACTION_ANSWER -> {
41
+ // Answer action is handled by OnNewIntent in ExpoCallKitTelecomModule
42
+ // since the PendingIntent launches the Activity directly.
43
+ // This branch only fires for legacy broadcast-based answer actions.
44
+ CallKitTelecomLog.d(TAG) { "Notification answer action (broadcast) - callId: $callId" }
45
+ CallManager.shared.answerCall(callId)
46
+ }
47
+
48
+ ACTION_DECLINE -> {
49
+ CallKitTelecomLog.d(TAG) { "Notification decline action - callId: $callId" }
50
+ CallManager.shared.endCall(callId)
51
+ }
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,161 @@
1
+ package expo.modules.callkittelecom.services
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.util.Log
6
+ import com.google.firebase.messaging.RemoteMessage
7
+ import expo.modules.callkittelecom.managers.CallManager
8
+ import expo.modules.callkittelecom.managers.VoIPPushManager
9
+ import expo.modules.callkittelecom.models.IncomingCallEvent
10
+ import expo.modules.notifications.service.ExpoFirebaseMessagingService
11
+ import org.json.JSONArray
12
+ import org.json.JSONObject
13
+ import java.util.concurrent.ConcurrentHashMap
14
+
15
+ /**
16
+ * Android FCM entry point for incoming call payloads.
17
+ *
18
+ * Extends expo-notifications' [ExpoFirebaseMessagingService] so that non-call
19
+ * messages are handled by the existing notification delegate via [super], and
20
+ * call payloads are routed directly to Telecom.
21
+ *
22
+ * Wire format (matches example/server/lib/fcm.ts):
23
+ * data["messageType"] = "incoming_call"
24
+ * data["incoming_call"] = JSON string of the IncomingCallEvent (camelCase)
25
+ */
26
+ class ExpoCallKitTelecomMessagingService : ExpoFirebaseMessagingService() {
27
+ companion object {
28
+ private const val TAG = "ExpoCallKitTelecom.FCM"
29
+ private const val KEY_MESSAGE_TYPE = "messageType"
30
+ private const val MESSAGE_TYPE_INCOMING_CALL = "incoming_call"
31
+ private const val KEY_INCOMING_CALL = "incoming_call"
32
+ private const val DEDUP_WINDOW_MS = 120_000L
33
+
34
+ private val dedupeLock = Any()
35
+ private val recentMessages = ConcurrentHashMap<String, Long>()
36
+ }
37
+
38
+ override fun onMessageReceived(message: RemoteMessage) {
39
+ val data = message.data
40
+
41
+ // Try to parse as an incoming call payload.
42
+ val eventMap = if (data.isNotEmpty()) parseIncomingCallEvent(data) else null
43
+ if (eventMap == null) {
44
+ // Not a call push — let expo-notifications handle it.
45
+ super.onMessageReceived(message)
46
+ return
47
+ }
48
+
49
+ val dedupeKey = dedupeKey(eventMap) ?: return
50
+ if (!markMessageAsNew(dedupeKey)) {
51
+ Log.d(TAG, "Dropping duplicate incoming call push - key: $dedupeKey")
52
+ return
53
+ }
54
+
55
+ Handler(Looper.getMainLooper()).post {
56
+ processIncomingCall(eventMap)
57
+ }
58
+ }
59
+
60
+ override fun onNewToken(token: String) {
61
+ VoIPPushManager.updateToken(token)
62
+
63
+ // Let expo-notifications update its own token listeners.
64
+ super.onNewToken(token)
65
+ }
66
+
67
+ private fun processIncomingCall(eventMap: Map<String, Any?>) {
68
+ try {
69
+ CallManager.shared.initialize(applicationContext)
70
+
71
+ // Wrap under the envelope so we go through the same parser path as iOS.
72
+ val event = IncomingCallEvent.fromPayload(mapOf(KEY_INCOMING_CALL to eventMap))
73
+ if (event == null) {
74
+ Log.w(TAG, "Failed to validate incoming call event from FCM payload")
75
+ return
76
+ }
77
+
78
+ CallManager.shared.reportIncomingCall(event)
79
+ Log.d(TAG, "Reported incoming call from FCM payload")
80
+ } catch (error: IllegalStateException) {
81
+ Log.w(
82
+ TAG,
83
+ "Ignoring incoming call push while another session exists: ${error.message}",
84
+ )
85
+ } catch (error: Throwable) {
86
+ Log.e(TAG, "Failed to process incoming call push: ${error.message}", error)
87
+ }
88
+ }
89
+
90
+ private fun parseIncomingCallEvent(data: Map<String, String>): Map<String, Any?>? {
91
+ if (data[KEY_MESSAGE_TYPE] != MESSAGE_TYPE_INCOMING_CALL) {
92
+ return null
93
+ }
94
+
95
+ val nestedPayload = data[KEY_INCOMING_CALL] ?: return null
96
+ return try {
97
+ jsonObjectToMap(JSONObject(nestedPayload))
98
+ } catch (error: Throwable) {
99
+ Log.w(TAG, "Failed to parse incoming_call JSON payload: ${error.message}")
100
+ null
101
+ }
102
+ }
103
+
104
+ private fun dedupeKey(eventMap: Map<String, Any?>): String? {
105
+ val eventId = eventMap["eventId"] as? String
106
+ if (!eventId.isNullOrBlank()) {
107
+ return "event:$eventId"
108
+ }
109
+
110
+ val serverCallId = eventMap["serverCallId"] as? String
111
+ if (!serverCallId.isNullOrBlank()) {
112
+ return "call:$serverCallId"
113
+ }
114
+
115
+ return null
116
+ }
117
+
118
+ private fun markMessageAsNew(key: String): Boolean {
119
+ val now = System.currentTimeMillis()
120
+ synchronized(dedupeLock) {
121
+ recentMessages.entries.removeIf { (_, seenAt) -> now - seenAt > DEDUP_WINDOW_MS }
122
+ val seenAt = recentMessages[key]
123
+ if (seenAt != null && now - seenAt <= DEDUP_WINDOW_MS) {
124
+ return false
125
+ }
126
+ recentMessages[key] = now
127
+ return true
128
+ }
129
+ }
130
+
131
+ private fun jsonObjectToMap(jsonObject: JSONObject): Map<String, Any?> {
132
+ val result = mutableMapOf<String, Any?>()
133
+ val iterator = jsonObject.keys()
134
+ while (iterator.hasNext()) {
135
+ val key = iterator.next()
136
+ result[key] = jsonValueToAny(jsonObject.opt(key))
137
+ }
138
+ return result
139
+ }
140
+
141
+ private fun jsonArrayToList(array: JSONArray): List<Any?> {
142
+ val result = mutableListOf<Any?>()
143
+ for (index in 0 until array.length()) {
144
+ result.add(jsonValueToAny(array.opt(index)))
145
+ }
146
+ return result
147
+ }
148
+
149
+ private fun jsonValueToAny(value: Any?): Any? =
150
+ when (value) {
151
+ null,
152
+ JSONObject.NULL,
153
+ -> null
154
+
155
+ is JSONObject -> jsonObjectToMap(value)
156
+
157
+ is JSONArray -> jsonArrayToList(value)
158
+
159
+ else -> value
160
+ }
161
+ }