expo-callkit-telecom 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/android/build.gradle +32 -0
- package/android/src/main/AndroidManifest.xml +33 -0
- package/android/src/main/java/expo/modules/callkittelecom/ExpoCallKitTelecomModule.kt +384 -0
- package/android/src/main/java/expo/modules/callkittelecom/IncomingCallActivity.kt +275 -0
- package/android/src/main/java/expo/modules/callkittelecom/events/CallEventEmitter.kt +151 -0
- package/android/src/main/java/expo/modules/callkittelecom/events/CallEvents.kt +59 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallAudioManager.kt +361 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallManager.kt +891 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CallNotificationManager.kt +445 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/CaptureSessionManager.kt +27 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/DialtonePlayer.kt +171 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/FulfillRequestManager.kt +150 -0
- package/android/src/main/java/expo/modules/callkittelecom/managers/VoIPPushManager.kt +54 -0
- package/android/src/main/java/expo/modules/callkittelecom/models/CallModels.kt +269 -0
- package/android/src/main/java/expo/modules/callkittelecom/services/CallNotificationReceiver.kt +54 -0
- package/android/src/main/java/expo/modules/callkittelecom/services/ExpoCallKitTelecomMessagingService.kt +161 -0
- package/android/src/main/java/expo/modules/callkittelecom/store/CallStore.kt +181 -0
- package/android/src/main/java/expo/modules/callkittelecom/utils/CallKitTelecomLog.kt +52 -0
- package/android/src/main/java/expo/modules/callkittelecom/utils/PermissionUtils.kt +28 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_answer.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_avatar.xml +5 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_bg_decline.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_answer.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_decline.xml +9 -0
- package/android/src/main/res/drawable/expo_callkit_telecom_ic_videocam.xml +9 -0
- package/android/src/main/res/layout/activity_incoming_call.xml +169 -0
- package/app.json +8 -0
- package/app.plugin.js +1 -0
- package/build/Calls.d.ts +577 -0
- package/build/Calls.d.ts.map +1 -0
- package/build/Calls.js +715 -0
- package/build/Calls.js.map +1 -0
- package/build/Calls.types.d.ts +203 -0
- package/build/Calls.types.d.ts.map +1 -0
- package/build/Calls.types.js +2 -0
- package/build/Calls.types.js.map +1 -0
- package/build/ExpoCallKitTelecomModule.d.ts +3 -0
- package/build/ExpoCallKitTelecomModule.d.ts.map +1 -0
- package/build/ExpoCallKitTelecomModule.js +4 -0
- package/build/ExpoCallKitTelecomModule.js.map +1 -0
- package/build/hooks/index.d.ts +2 -0
- package/build/hooks/index.d.ts.map +1 -0
- package/build/hooks/index.js +2 -0
- package/build/hooks/index.js.map +1 -0
- package/build/hooks/useVoIPPushToken.d.ts +14 -0
- package/build/hooks/useVoIPPushToken.d.ts.map +1 -0
- package/build/hooks/useVoIPPushToken.js +26 -0
- package/build/hooks/useVoIPPushToken.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +10 -0
- package/ios/AppDelegateSubscriber.swift +93 -0
- package/ios/ExpoCallKitTelecom.podspec +31 -0
- package/ios/ExpoCallKitTelecomLogger.swift +55 -0
- package/ios/ExpoCallKitTelecomModule.swift +503 -0
- package/ios/Managers/AudioManager.swift +363 -0
- package/ios/Managers/CallEventEmitter.swift +199 -0
- package/ios/Managers/CallManager+CXProviderDelegate.swift +195 -0
- package/ios/Managers/CallManager.swift +714 -0
- package/ios/Managers/CaptureSessionManager.swift +54 -0
- package/ios/Managers/DialtonePlayer.swift +126 -0
- package/ios/Managers/FulfillRequestManager.swift +154 -0
- package/ios/Managers/VoIPPushManager+PKPushRegistryDelegate.swift +123 -0
- package/ios/Managers/VoIPPushManager.swift +58 -0
- package/ios/Models/CallEvents.swift +263 -0
- package/ios/Models/CallOptions.swift +15 -0
- package/ios/Models/CallParticipant.swift +37 -0
- package/ios/Models/CallSession.swift +80 -0
- package/ios/Models/IncomingCallEvent.swift +196 -0
- package/ios/Stores/CallStore.swift +149 -0
- package/package.json +56 -0
- package/plugin/build/constants.d.ts +3 -0
- package/plugin/build/constants.js +7 -0
- package/plugin/build/withExpoCallKitTelecom.d.ts +67 -0
- package/plugin/build/withExpoCallKitTelecom.js +16 -0
- package/plugin/build/withExpoCallKitTelecomAndroid.d.ts +3 -0
- package/plugin/build/withExpoCallKitTelecomAndroid.js +177 -0
- package/plugin/build/withExpoCallKitTelecomIos.d.ts +3 -0
- package/plugin/build/withExpoCallKitTelecomIos.js +195 -0
- package/plugin/src/constants.ts +4 -0
- package/plugin/src/withExpoCallKitTelecom.ts +83 -0
- package/plugin/src/withExpoCallKitTelecomAndroid.ts +293 -0
- package/plugin/src/withExpoCallKitTelecomIos.ts +276 -0
- package/src/Calls.ts +848 -0
- package/src/Calls.types.ts +275 -0
- package/src/ExpoCallKitTelecomModule.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useVoIPPushToken.ts +34 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,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
|
+
}
|
package/android/src/main/java/expo/modules/callkittelecom/services/CallNotificationReceiver.kt
ADDED
|
@@ -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
|
+
}
|