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,275 @@
|
|
|
1
|
+
package expo.modules.callkittelecom
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.app.KeyguardManager
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.graphics.BitmapFactory
|
|
8
|
+
import android.os.Bundle
|
|
9
|
+
import android.view.View
|
|
10
|
+
import android.view.WindowManager
|
|
11
|
+
import android.widget.ImageButton
|
|
12
|
+
import android.widget.ImageView
|
|
13
|
+
import android.widget.TextView
|
|
14
|
+
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
|
|
15
|
+
import expo.modules.callkittelecom.managers.CallManager
|
|
16
|
+
import expo.modules.callkittelecom.models.CallSessionStatus
|
|
17
|
+
import expo.modules.callkittelecom.store.CallStore
|
|
18
|
+
import expo.modules.callkittelecom.utils.CallKitTelecomLog
|
|
19
|
+
import kotlinx.coroutines.CoroutineScope
|
|
20
|
+
import kotlinx.coroutines.Dispatchers
|
|
21
|
+
import kotlinx.coroutines.SupervisorJob
|
|
22
|
+
import kotlinx.coroutines.cancel
|
|
23
|
+
import kotlinx.coroutines.launch
|
|
24
|
+
import kotlinx.coroutines.withContext
|
|
25
|
+
import java.net.HttpURLConnection
|
|
26
|
+
import java.net.URL
|
|
27
|
+
import java.util.UUID
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Native full-screen incoming call Activity displayed over the lock screen.
|
|
31
|
+
*
|
|
32
|
+
* Shows caller information with answer/decline buttons. Automatically
|
|
33
|
+
* dismisses when the call leaves the RINGING state (answered, declined,
|
|
34
|
+
* timed out, or ended elsewhere).
|
|
35
|
+
*
|
|
36
|
+
* Answer flow: answers the call directly, dismisses the keyguard via
|
|
37
|
+
* [KeyguardManager.requestDismissKeyguard], then launches the main Activity
|
|
38
|
+
* so the user sees the in-call UI after unlocking.
|
|
39
|
+
*/
|
|
40
|
+
class IncomingCallActivity : Activity() {
|
|
41
|
+
companion object {
|
|
42
|
+
private const val TAG = "ExpoCallKitTelecom.IncomingCallActivity"
|
|
43
|
+
const val EXTRA_CALL_ID = "expo.modules.callkittelecom.EXTRA_CALL_ID"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private var callId: UUID? = null
|
|
47
|
+
private var isAnswering = false
|
|
48
|
+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
49
|
+
|
|
50
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
51
|
+
super.onCreate(savedInstanceState)
|
|
52
|
+
|
|
53
|
+
configureWindowForLockScreen()
|
|
54
|
+
setContentView(R.layout.activity_incoming_call)
|
|
55
|
+
|
|
56
|
+
val id = parseCallId() ?: return
|
|
57
|
+
callId = id
|
|
58
|
+
|
|
59
|
+
val session = CallStore.session(id)
|
|
60
|
+
if (session == null || session.status != CallSessionStatus.RINGING) {
|
|
61
|
+
CallKitTelecomLog.d(TAG) { "No ringing session for $id, finishing" }
|
|
62
|
+
finish()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
val caller = session.remoteParticipants.firstOrNull()
|
|
67
|
+
|
|
68
|
+
bindAppBranding()
|
|
69
|
+
bindCallerInfo(caller?.displayName, session.options.hasVideo)
|
|
70
|
+
loadAvatar(caller?.avatarUrl)
|
|
71
|
+
bindButtons(id, session.options.hasVideo)
|
|
72
|
+
observeSessionChanges(id)
|
|
73
|
+
|
|
74
|
+
CallKitTelecomLog.d(TAG) { "Showing incoming call UI - callId: $id" }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Suppress("DEPRECATION")
|
|
78
|
+
private fun configureWindowForLockScreen() {
|
|
79
|
+
setShowWhenLocked(true)
|
|
80
|
+
setTurnScreenOn(true)
|
|
81
|
+
window.addFlags(
|
|
82
|
+
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
|
83
|
+
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
|
84
|
+
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun parseCallId(): UUID? {
|
|
89
|
+
val callIdStr = intent.getStringExtra(EXTRA_CALL_ID)
|
|
90
|
+
return try {
|
|
91
|
+
UUID.fromString(callIdStr)
|
|
92
|
+
} catch (_: Exception) {
|
|
93
|
+
CallKitTelecomLog.e(TAG) { "Invalid or missing call ID: $callIdStr" }
|
|
94
|
+
finish()
|
|
95
|
+
null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private fun bindAppBranding() {
|
|
100
|
+
try {
|
|
101
|
+
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
|
102
|
+
findViewById<ImageView>(R.id.expo_callkit_telecom_app_icon).setImageDrawable(
|
|
103
|
+
packageManager.getApplicationIcon(appInfo),
|
|
104
|
+
)
|
|
105
|
+
findViewById<TextView>(R.id.expo_callkit_telecom_app_name).text =
|
|
106
|
+
packageManager.getApplicationLabel(appInfo)
|
|
107
|
+
} catch (_: Exception) {
|
|
108
|
+
// Non-critical — branding just won't show
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private fun bindCallerInfo(
|
|
113
|
+
displayName: String?,
|
|
114
|
+
hasVideo: Boolean,
|
|
115
|
+
) {
|
|
116
|
+
val name = displayName ?: "Unknown"
|
|
117
|
+
|
|
118
|
+
findViewById<TextView>(R.id.expo_callkit_telecom_avatar_text).text =
|
|
119
|
+
name.firstOrNull()?.uppercase() ?: "?"
|
|
120
|
+
|
|
121
|
+
findViewById<TextView>(R.id.expo_callkit_telecom_caller_name).text = name
|
|
122
|
+
|
|
123
|
+
findViewById<TextView>(R.id.expo_callkit_telecom_subtitle).text =
|
|
124
|
+
if (hasVideo) "Incoming video call" else "Incoming call"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Loads the caller's avatar from [avatarUrl] on a background thread.
|
|
129
|
+
* On success, displays a circular-cropped image and hides the initial letter.
|
|
130
|
+
* On failure, silently keeps the initial letter fallback.
|
|
131
|
+
*/
|
|
132
|
+
private fun loadAvatar(avatarUrl: String?) {
|
|
133
|
+
if (avatarUrl.isNullOrBlank()) return
|
|
134
|
+
|
|
135
|
+
val avatarImage = findViewById<ImageView>(R.id.expo_callkit_telecom_avatar_image)
|
|
136
|
+
val avatarText = findViewById<TextView>(R.id.expo_callkit_telecom_avatar_text)
|
|
137
|
+
|
|
138
|
+
scope.launch {
|
|
139
|
+
val drawable =
|
|
140
|
+
withContext(Dispatchers.IO) {
|
|
141
|
+
try {
|
|
142
|
+
val connection = URL(avatarUrl).openConnection() as HttpURLConnection
|
|
143
|
+
connection.connectTimeout = 5_000
|
|
144
|
+
connection.readTimeout = 5_000
|
|
145
|
+
val bitmap =
|
|
146
|
+
connection.inputStream.use { stream ->
|
|
147
|
+
BitmapFactory.decodeStream(stream)
|
|
148
|
+
}
|
|
149
|
+
bitmap?.let {
|
|
150
|
+
RoundedBitmapDrawableFactory.create(resources, it).apply {
|
|
151
|
+
isCircular = true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (e: Exception) {
|
|
155
|
+
CallKitTelecomLog.d(TAG) { "Avatar load failed: ${e.message}" }
|
|
156
|
+
null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (drawable != null) {
|
|
161
|
+
avatarImage.setImageDrawable(drawable)
|
|
162
|
+
avatarImage.visibility = View.VISIBLE
|
|
163
|
+
avatarText.visibility = View.GONE
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private fun bindButtons(
|
|
169
|
+
id: UUID,
|
|
170
|
+
hasVideo: Boolean,
|
|
171
|
+
) {
|
|
172
|
+
val answerButton = findViewById<ImageButton>(R.id.expo_callkit_telecom_answer_button)
|
|
173
|
+
if (hasVideo) {
|
|
174
|
+
answerButton.setImageResource(R.drawable.expo_callkit_telecom_ic_videocam)
|
|
175
|
+
}
|
|
176
|
+
answerButton.setOnClickListener { onAnswerTapped(id) }
|
|
177
|
+
|
|
178
|
+
findViewById<ImageButton>(R.id.expo_callkit_telecom_decline_button).setOnClickListener {
|
|
179
|
+
onDeclineTapped(id)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Answers the call directly, then dismisses the keyguard and launches
|
|
185
|
+
* the main Activity so the user transitions into the in-call UI.
|
|
186
|
+
*
|
|
187
|
+
* The call is answered immediately (media connection starts) regardless
|
|
188
|
+
* of whether the keyguard dismissal succeeds. This matches the behavior
|
|
189
|
+
* of iOS CallKit where audio connects before the device is unlocked.
|
|
190
|
+
*/
|
|
191
|
+
private fun onAnswerTapped(id: UUID) {
|
|
192
|
+
if (isAnswering) return
|
|
193
|
+
isAnswering = true
|
|
194
|
+
CallKitTelecomLog.d(TAG) { "Answer tapped - callId: $id" }
|
|
195
|
+
|
|
196
|
+
// Answer immediately — don't wait for keyguard dismissal
|
|
197
|
+
CallManager.shared.answerCall(id)
|
|
198
|
+
|
|
199
|
+
// Dismiss keyguard, then bring the main app to the foreground
|
|
200
|
+
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
|
201
|
+
keyguardManager.requestDismissKeyguard(
|
|
202
|
+
this,
|
|
203
|
+
object : KeyguardManager.KeyguardDismissCallback() {
|
|
204
|
+
override fun onDismissSucceeded() {
|
|
205
|
+
launchMainActivity()
|
|
206
|
+
finish()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
override fun onDismissCancelled() {
|
|
210
|
+
// User cancelled unlock — call is still answered, launch anyway
|
|
211
|
+
launchMainActivity()
|
|
212
|
+
finish()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
override fun onDismissError() {
|
|
216
|
+
launchMainActivity()
|
|
217
|
+
finish()
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private fun launchMainActivity() {
|
|
224
|
+
val intent =
|
|
225
|
+
packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
|
226
|
+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
|
227
|
+
}
|
|
228
|
+
if (intent != null) {
|
|
229
|
+
startActivity(intent)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private fun onDeclineTapped(id: UUID) {
|
|
234
|
+
CallKitTelecomLog.d(TAG) { "Decline tapped - callId: $id" }
|
|
235
|
+
CallManager.shared.endCall(id)
|
|
236
|
+
finish()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Observes session updates to auto-dismiss when the call is no longer ringing
|
|
241
|
+
* (answered elsewhere, timed out, or ended by the remote side).
|
|
242
|
+
*
|
|
243
|
+
* When [isAnswering] is true (user tapped answer locally), only auto-dismiss
|
|
244
|
+
* for ENDED status — the CONNECTING transition is expected and handled by
|
|
245
|
+
* the keyguard dismissal flow.
|
|
246
|
+
*/
|
|
247
|
+
private fun observeSessionChanges(id: UUID) {
|
|
248
|
+
scope.launch {
|
|
249
|
+
CallStore.sessionUpdates(id).collect { session ->
|
|
250
|
+
if (session.status == CallSessionStatus.ENDED) {
|
|
251
|
+
finish()
|
|
252
|
+
} else if (!isAnswering && session.status != CallSessionStatus.RINGING) {
|
|
253
|
+
CallKitTelecomLog.d(TAG) { "Call no longer ringing (${session.status.value}), finishing" }
|
|
254
|
+
finish()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
override fun onResume() {
|
|
261
|
+
super.onResume()
|
|
262
|
+
val id = callId ?: return
|
|
263
|
+
val session = CallStore.session(id)
|
|
264
|
+
if (session == null || session.status == CallSessionStatus.ENDED) {
|
|
265
|
+
finish()
|
|
266
|
+
} else if (!isAnswering && session.status != CallSessionStatus.RINGING) {
|
|
267
|
+
finish()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
override fun onDestroy() {
|
|
272
|
+
super.onDestroy()
|
|
273
|
+
scope.cancel()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
package expo.modules.callkittelecom.events
|
|
2
|
+
|
|
3
|
+
import expo.modules.callkittelecom.utils.CallKitTelecomLog
|
|
4
|
+
import java.time.Instant
|
|
5
|
+
import java.time.format.DateTimeFormatter
|
|
6
|
+
|
|
7
|
+
private data class QueuedEvent(
|
|
8
|
+
val body: Map<String, Any?>,
|
|
9
|
+
val timestamp: Instant,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Event bridge between Android native call state and the Expo JS module.
|
|
14
|
+
*
|
|
15
|
+
* Behavior:
|
|
16
|
+
* - Tracks which individual events are being observed by JS
|
|
17
|
+
* - Queues events that arrive before observers mount
|
|
18
|
+
* - Flushes queued events with `{ meta: { flushed: true } }`
|
|
19
|
+
*
|
|
20
|
+
* All mutable state is guarded by [lock] for thread safety.
|
|
21
|
+
*/
|
|
22
|
+
object CallEventEmitter {
|
|
23
|
+
private const val TAG = "ExpoCallKitTelecom.Emitter"
|
|
24
|
+
|
|
25
|
+
private val lock = Any()
|
|
26
|
+
private val observingEvents = mutableSetOf<String>()
|
|
27
|
+
private val eventQueues = mutableMapOf<String, MutableList<QueuedEvent>>()
|
|
28
|
+
private val queueLimits = mutableMapOf<String, Int?>()
|
|
29
|
+
|
|
30
|
+
var defaultQueueLimit: Int? = 0
|
|
31
|
+
|
|
32
|
+
@Volatile
|
|
33
|
+
private var sender: ((String, Map<String, Any?>) -> Unit)? = null
|
|
34
|
+
|
|
35
|
+
/** Sets or clears the active event sender provided by the Expo module. */
|
|
36
|
+
fun setSender(eventSender: ((String, Map<String, Any?>) -> Unit)?) {
|
|
37
|
+
sender = eventSender
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Configures queue size for a specific event.
|
|
42
|
+
*
|
|
43
|
+
* `null` means unlimited queueing, `0` disables queueing.
|
|
44
|
+
*/
|
|
45
|
+
fun setQueueLimit(
|
|
46
|
+
eventName: String,
|
|
47
|
+
limit: Int?,
|
|
48
|
+
) {
|
|
49
|
+
synchronized(lock) {
|
|
50
|
+
queueLimits[eventName] = limit
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sends an event to JS if it is currently observed, or queues it otherwise.
|
|
56
|
+
*
|
|
57
|
+
* All delivered events are augmented with a `meta` object containing timestamp and flush status.
|
|
58
|
+
*/
|
|
59
|
+
fun send(
|
|
60
|
+
eventName: String,
|
|
61
|
+
body: Map<String, Any?>,
|
|
62
|
+
) {
|
|
63
|
+
val timestamp = Instant.now()
|
|
64
|
+
val senderRef = sender
|
|
65
|
+
val isObserving = synchronized(lock) { observingEvents.contains(eventName) }
|
|
66
|
+
|
|
67
|
+
if (senderRef != null && isObserving) {
|
|
68
|
+
CallKitTelecomLog.d(TAG) { "Sending event to JS - name: $eventName" }
|
|
69
|
+
senderRef(eventName, buildEventBody(body, flushed = false, timestamp = timestamp))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
queueEvent(eventName, body, timestamp)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Marks an event as observed and flushes any pending queue for that event. */
|
|
76
|
+
fun startObserving(eventName: String) {
|
|
77
|
+
val queueCount: Int
|
|
78
|
+
synchronized(lock) {
|
|
79
|
+
queueCount = eventQueues[eventName]?.size ?: 0
|
|
80
|
+
observingEvents.add(eventName)
|
|
81
|
+
}
|
|
82
|
+
CallKitTelecomLog.d(TAG) { "Start observing - event: $eventName, queuedEvents: $queueCount" }
|
|
83
|
+
flushQueue(eventName)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Marks an event as no longer observed by JS. */
|
|
87
|
+
fun stopObserving(eventName: String) {
|
|
88
|
+
CallKitTelecomLog.d(TAG) { "Stop observing - event: $eventName" }
|
|
89
|
+
synchronized(lock) {
|
|
90
|
+
observingEvents.remove(eventName)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Adds native event metadata used by TypeScript event types. */
|
|
95
|
+
private fun buildEventBody(
|
|
96
|
+
body: Map<String, Any?>,
|
|
97
|
+
flushed: Boolean,
|
|
98
|
+
timestamp: Instant,
|
|
99
|
+
): Map<String, Any?> {
|
|
100
|
+
val result = body.toMutableMap()
|
|
101
|
+
result["meta"] =
|
|
102
|
+
mapOf(
|
|
103
|
+
"flushed" to flushed,
|
|
104
|
+
"timestamp" to DateTimeFormatter.ISO_INSTANT.format(timestamp),
|
|
105
|
+
)
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Queues an event and enforces per-event queue limits (drop oldest first). */
|
|
110
|
+
private fun queueEvent(
|
|
111
|
+
name: String,
|
|
112
|
+
body: Map<String, Any?>,
|
|
113
|
+
timestamp: Instant,
|
|
114
|
+
) {
|
|
115
|
+
synchronized(lock) {
|
|
116
|
+
val limit = queueLimits[name] ?: defaultQueueLimit
|
|
117
|
+
if (limit == 0) {
|
|
118
|
+
CallKitTelecomLog.d(TAG) { "Dropping event (queueing disabled) - name: $name" }
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
val queue = eventQueues.getOrPut(name) { mutableListOf() }
|
|
123
|
+
queue += QueuedEvent(body = body, timestamp = timestamp)
|
|
124
|
+
|
|
125
|
+
if (limit != null && queue.size > limit) {
|
|
126
|
+
val dropCount = queue.size - limit
|
|
127
|
+
repeat(dropCount) {
|
|
128
|
+
queue.removeAt(0)
|
|
129
|
+
}
|
|
130
|
+
CallKitTelecomLog.d(TAG) { "Queueing event (dropped $dropCount old) - name: $name, queueSize: ${queue.size}" }
|
|
131
|
+
} else {
|
|
132
|
+
CallKitTelecomLog.d(TAG) { "Queueing event (JS not listening) - name: $name, queueSize: ${queue.size}" }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Flushes all queued events for a single event name. */
|
|
138
|
+
private fun flushQueue(eventName: String) {
|
|
139
|
+
val senderRef = sender ?: return
|
|
140
|
+
val queue = synchronized(lock) { eventQueues.remove(eventName) } ?: return
|
|
141
|
+
if (queue.isEmpty()) return
|
|
142
|
+
|
|
143
|
+
CallKitTelecomLog.d(TAG) { "Flushing event queue - event: $eventName, count: ${queue.size}" }
|
|
144
|
+
queue.forEach { event ->
|
|
145
|
+
senderRef(
|
|
146
|
+
eventName,
|
|
147
|
+
buildEventBody(event.body, flushed = true, timestamp = event.timestamp),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
package expo.modules.callkittelecom.events
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical event names emitted by the Android native call layer.
|
|
5
|
+
*
|
|
6
|
+
* These values must stay aligned with the TypeScript listeners in `Calls.ts`.
|
|
7
|
+
*/
|
|
8
|
+
object CallEvents {
|
|
9
|
+
/** Emitted when a new native call session is created. */
|
|
10
|
+
const val CALL_SESSION_ADDED = "onCallSessionAdded"
|
|
11
|
+
|
|
12
|
+
/** Emitted when an existing native call session changes state. */
|
|
13
|
+
const val CALL_SESSION_UPDATED = "onCallSessionUpdated"
|
|
14
|
+
|
|
15
|
+
/** Emitted when a native call session is removed. */
|
|
16
|
+
const val CALL_SESSION_REMOVED = "onCallSessionRemoved"
|
|
17
|
+
|
|
18
|
+
/** Emitted when call audio has been activated for one or more calls. */
|
|
19
|
+
const val AUDIO_SESSION_ACTIVATED = "onAudioSessionActivated"
|
|
20
|
+
|
|
21
|
+
/** Emitted when call audio has been deactivated after call teardown. */
|
|
22
|
+
const val AUDIO_SESSION_DEACTIVATED = "onAudioSessionDeactivated"
|
|
23
|
+
|
|
24
|
+
/** Emitted when the current input/output route changes. */
|
|
25
|
+
const val AUDIO_ROUTE_CHANGED = "onAudioRouteChanged"
|
|
26
|
+
|
|
27
|
+
/** Emitted after an incoming call is successfully handed to Telecom. */
|
|
28
|
+
const val INCOMING_CALL_REPORTED = "onIncomingCallReported"
|
|
29
|
+
|
|
30
|
+
/** Emitted when an outgoing call has started and JS should connect media. */
|
|
31
|
+
const val OUTGOING_CALL_STARTED = "onOutgoingCallStarted"
|
|
32
|
+
|
|
33
|
+
/** Emitted when an incoming call is answered and media setup should begin. */
|
|
34
|
+
const val CALL_ANSWERED = "onCallAnswered"
|
|
35
|
+
|
|
36
|
+
/** Emitted when the local user/system ends a call. */
|
|
37
|
+
const val CALL_ENDED = "onCallEnded"
|
|
38
|
+
|
|
39
|
+
/** Emitted when the app reports an externally-ended call reason. */
|
|
40
|
+
const val CALL_REPORTED_ENDED = "onCallReportedEnded"
|
|
41
|
+
|
|
42
|
+
/** Emitted when mute state is requested or changed. */
|
|
43
|
+
const val SET_MUTED_ACTION = "onSetMutedAction"
|
|
44
|
+
|
|
45
|
+
/** Emitted when video enabled state changes. */
|
|
46
|
+
const val VIDEO_CHANGED = "onVideoChanged"
|
|
47
|
+
|
|
48
|
+
/** Emitted when hold state is requested or changed. */
|
|
49
|
+
const val SET_HELD_ACTION = "onSetHeldAction"
|
|
50
|
+
|
|
51
|
+
/** Emitted when DTMF digits are requested. */
|
|
52
|
+
const val DTMF = "onDTMF"
|
|
53
|
+
|
|
54
|
+
/** Reserved for call intent integration. */
|
|
55
|
+
const val CALL_INTENT_RECEIVED = "onCallIntentReceived"
|
|
56
|
+
|
|
57
|
+
/** Emitted when the FCM push token is refreshed. */
|
|
58
|
+
const val VOIP_PUSH_TOKEN_UPDATED = "onVoIPPushTokenUpdated"
|
|
59
|
+
}
|