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,445 @@
1
+ package expo.modules.callkittelecom.managers
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.PendingIntent
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.content.pm.PackageManager
10
+ import android.media.AudioAttributes
11
+ import android.media.RingtoneManager
12
+ import android.net.Uri
13
+ import androidx.core.app.NotificationCompat
14
+ import androidx.core.app.NotificationManagerCompat
15
+ import androidx.core.app.Person
16
+ import androidx.core.graphics.drawable.IconCompat
17
+ import expo.modules.callkittelecom.IncomingCallActivity
18
+ import expo.modules.callkittelecom.services.CallNotificationReceiver
19
+ import expo.modules.callkittelecom.utils.CallKitTelecomLog
20
+ import kotlinx.coroutines.CoroutineScope
21
+ import kotlinx.coroutines.Dispatchers
22
+ import kotlinx.coroutines.Job
23
+ import kotlinx.coroutines.SupervisorJob
24
+ import kotlinx.coroutines.delay
25
+ import kotlinx.coroutines.launch
26
+ import java.util.UUID
27
+
28
+ /**
29
+ * Manages call notifications across all call states.
30
+ *
31
+ * Creates CallStyle notifications with full-screen intent for lock screen
32
+ * display and notification shade answer/decline actions. Supports incoming,
33
+ * dialing, ongoing, and ended notification states.
34
+ */
35
+ object CallNotificationManager {
36
+ private const val TAG = "ExpoCallKitTelecom.Notification"
37
+
38
+ private const val CHANNEL_INCOMING_PREFIX = "expo_callkit_telecom_incoming"
39
+ private const val CHANNEL_ONGOING = "expo_callkit_telecom_ongoing"
40
+ private const val NOTIFICATION_ID = 8400
41
+ private const val ENDED_CANCEL_DELAY_MS = 2000L
42
+
43
+ private const val KEY_DEFAULT_RINGTONE = "ExpoCallKitTelecomDefaultRingtone"
44
+ private const val PREFS_NAME = "expo_callkit_telecom_notifications"
45
+ private const val PREF_INCOMING_CHANNEL_ID = "incoming_channel_id"
46
+
47
+ private var isInitialized = false
48
+ private lateinit var appContext: Context
49
+
50
+ /** The active incoming channel ID, derived from the configured ringtone. */
51
+ private lateinit var incomingChannelId: String
52
+
53
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
54
+ private var delayedCancelJob: Job? = null
55
+
56
+ /** Initializes notification channels. Safe to call repeatedly. */
57
+ fun initialize(context: Context) {
58
+ if (isInitialized) return
59
+
60
+ appContext = context.applicationContext
61
+
62
+ val notificationManager =
63
+ appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
64
+
65
+ createIncomingChannel(notificationManager)
66
+ createOngoingChannel(notificationManager)
67
+
68
+ isInitialized = true
69
+ CallKitTelecomLog.d(TAG) { "Initialized notification channels" }
70
+ }
71
+
72
+ /**
73
+ * Creates the incoming call notification channel with the configured ringtone.
74
+ *
75
+ * Android caches channel settings after first creation, so sound changes
76
+ * are ignored on subsequent calls. To handle ringtone config changes between
77
+ * app versions, the channel ID includes a ringtone suffix. When the config
78
+ * changes, a new channel is created and the old one is deleted.
79
+ */
80
+ private fun createIncomingChannel(notificationManager: NotificationManager) {
81
+ val ringtoneConfig = readRingtoneConfig()
82
+ val ringtoneUri = resolveRingtoneUri(ringtoneConfig)
83
+ incomingChannelId = "${CHANNEL_INCOMING_PREFIX}_${ringtoneConfig ?: "default"}"
84
+
85
+ val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
86
+ val previousChannelId = prefs.getString(PREF_INCOMING_CHANNEL_ID, null)
87
+
88
+ // Delete the old channel if the ringtone config changed
89
+ if (previousChannelId != null && previousChannelId != incomingChannelId) {
90
+ notificationManager.deleteNotificationChannel(previousChannelId)
91
+ CallKitTelecomLog.d(TAG) { "Deleted old incoming channel: $previousChannelId" }
92
+ }
93
+
94
+ val ringtoneAttributes =
95
+ AudioAttributes
96
+ .Builder()
97
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
98
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
99
+ .build()
100
+
101
+ val channel =
102
+ NotificationChannel(
103
+ incomingChannelId,
104
+ "Incoming calls",
105
+ NotificationManager.IMPORTANCE_HIGH,
106
+ ).apply {
107
+ description = "Notifications for incoming calls"
108
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
109
+ setSound(ringtoneUri, ringtoneAttributes)
110
+ enableVibration(true)
111
+ vibrationPattern = longArrayOf(0, 1000, 500, 1000)
112
+ }
113
+
114
+ notificationManager.createNotificationChannel(channel)
115
+ prefs.edit().putString(PREF_INCOMING_CHANNEL_ID, incomingChannelId).apply()
116
+
117
+ CallKitTelecomLog.d(TAG) { "Created incoming channel: $incomingChannelId, ringtone: $ringtoneConfig" }
118
+ }
119
+
120
+ private fun createOngoingChannel(notificationManager: NotificationManager) {
121
+ val channel =
122
+ NotificationChannel(
123
+ CHANNEL_ONGOING,
124
+ "Ongoing calls",
125
+ NotificationManager.IMPORTANCE_DEFAULT,
126
+ ).apply {
127
+ description = "Notifications for active calls"
128
+ setSound(null, null)
129
+ enableVibration(false)
130
+ }
131
+
132
+ notificationManager.createNotificationChannel(channel)
133
+ }
134
+
135
+ /** Reads `ExpoCallKitTelecomDefaultRingtone` from AndroidManifest metadata. */
136
+ private fun readRingtoneConfig(): String? =
137
+ try {
138
+ val appInfo =
139
+ appContext.packageManager.getApplicationInfo(
140
+ appContext.packageName,
141
+ PackageManager.GET_META_DATA,
142
+ )
143
+ val value = appInfo.metaData?.getString(KEY_DEFAULT_RINGTONE)
144
+ if (value == "default") null else value
145
+ } catch (_: Throwable) {
146
+ null
147
+ }
148
+
149
+ /**
150
+ * Resolves a ringtone config value to a sound URI.
151
+ *
152
+ * - `null` / "default" → system default ringtone
153
+ * - custom filename → `android.resource://` URI for the raw resource
154
+ */
155
+ private fun resolveRingtoneUri(config: String?): Uri {
156
+ if (config == null) {
157
+ return RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
158
+ }
159
+
160
+ val resId =
161
+ appContext.resources.getIdentifier(config, "raw", appContext.packageName)
162
+ if (resId == 0) {
163
+ CallKitTelecomLog.e(TAG) { "Ringtone raw resource not found: $config, falling back to system default" }
164
+ return RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
165
+ }
166
+
167
+ return Uri.parse("android.resource://${appContext.packageName}/$resId")
168
+ }
169
+
170
+ /** Shows an incoming call notification with answer/decline actions and full-screen intent. */
171
+ fun showIncomingCall(
172
+ context: Context,
173
+ callId: UUID,
174
+ callerName: String?,
175
+ hasVideo: Boolean,
176
+ ) {
177
+ cancelDelayedCancel()
178
+ val ctx = context.applicationContext
179
+ val displayName = callerName ?: "Unknown"
180
+
181
+ // Answer action uses PendingIntent.getActivity() to bring the app to
182
+ // foreground. On Android 12+, BroadcastReceivers cannot start activities
183
+ // from the background, so the answer action must launch the Activity
184
+ // directly. CallNotificationReceiver intercepts the intent via
185
+ // onNewIntent to trigger the actual call answer.
186
+ val answerIntent =
187
+ ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.apply {
188
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or
189
+ Intent.FLAG_ACTIVITY_SINGLE_TOP
190
+ action = CallNotificationReceiver.ACTION_ANSWER
191
+ putExtra(CallNotificationReceiver.EXTRA_CALL_ID, callId.toString())
192
+ } ?: Intent()
193
+ val answerPI =
194
+ PendingIntent.getActivity(
195
+ ctx,
196
+ callId.hashCode(),
197
+ answerIntent,
198
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
199
+ )
200
+
201
+ val declineIntent =
202
+ Intent(ctx, CallNotificationReceiver::class.java).apply {
203
+ action = CallNotificationReceiver.ACTION_DECLINE
204
+ putExtra(CallNotificationReceiver.EXTRA_CALL_ID, callId.toString())
205
+ }
206
+ val declinePI =
207
+ PendingIntent.getBroadcast(
208
+ ctx,
209
+ callId.hashCode() + 1,
210
+ declineIntent,
211
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
212
+ )
213
+
214
+ val fullScreenIntent = buildIncomingCallFullScreenIntent(ctx, callId)
215
+
216
+ val notification =
217
+ buildBase(ctx, incomingChannelId, displayName, if (hasVideo) "Incoming video call" else "Incoming call")
218
+ .setStyle(
219
+ NotificationCompat.CallStyle.forIncomingCall(buildPerson(displayName), declinePI, answerPI),
220
+ ).setFullScreenIntent(fullScreenIntent, true)
221
+ .setOngoing(true)
222
+ .setAutoCancel(false)
223
+ .setPriority(NotificationCompat.PRIORITY_MAX)
224
+ .build()
225
+
226
+ postNotification(ctx, callId, displayName, "incoming call")
227
+ notify(ctx, notification)
228
+ }
229
+
230
+ /** Shows a dialing notification for outgoing calls with a hangup action. */
231
+ fun showDialingCall(
232
+ context: Context,
233
+ callId: UUID,
234
+ callerName: String?,
235
+ ) {
236
+ cancelDelayedCancel()
237
+ val ctx = context.applicationContext
238
+ val displayName = callerName ?: "Unknown"
239
+
240
+ val hangupIntent =
241
+ Intent(ctx, CallNotificationReceiver::class.java).apply {
242
+ action = CallNotificationReceiver.ACTION_DECLINE
243
+ putExtra(CallNotificationReceiver.EXTRA_CALL_ID, callId.toString())
244
+ }
245
+ val hangupPI =
246
+ PendingIntent.getBroadcast(
247
+ ctx,
248
+ callId.hashCode() + 2,
249
+ hangupIntent,
250
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
251
+ )
252
+
253
+ val contentIntent = buildFullScreenIntent(ctx, callId)
254
+
255
+ val notification =
256
+ buildBase(ctx, CHANNEL_ONGOING, displayName, "Dialing...")
257
+ .setStyle(
258
+ NotificationCompat.CallStyle.forOngoingCall(buildPerson(displayName), hangupPI),
259
+ ).setFullScreenIntent(contentIntent, true)
260
+ .setContentIntent(contentIntent)
261
+ .setOngoing(true)
262
+ .setAutoCancel(false)
263
+ .setShowWhen(false)
264
+ .build()
265
+
266
+ postNotification(ctx, callId, displayName, "dialing call")
267
+ notify(ctx, notification)
268
+ }
269
+
270
+ /** Switches the notification to ongoing call style with a call duration timer. */
271
+ fun showOngoingCall(
272
+ context: Context,
273
+ callId: UUID,
274
+ callerName: String?,
275
+ connectedAtMs: Long = System.currentTimeMillis(),
276
+ ) {
277
+ cancelDelayedCancel()
278
+ val ctx = context.applicationContext
279
+ val displayName = callerName ?: "Unknown"
280
+
281
+ val hangupIntent =
282
+ Intent(ctx, CallNotificationReceiver::class.java).apply {
283
+ action = CallNotificationReceiver.ACTION_DECLINE
284
+ putExtra(CallNotificationReceiver.EXTRA_CALL_ID, callId.toString())
285
+ }
286
+ val hangupPI =
287
+ PendingIntent.getBroadcast(
288
+ ctx,
289
+ callId.hashCode() + 2,
290
+ hangupIntent,
291
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
292
+ )
293
+
294
+ val contentIntent = buildFullScreenIntent(ctx, callId)
295
+
296
+ val notification =
297
+ buildBase(ctx, CHANNEL_ONGOING, displayName, "Ongoing call")
298
+ .setStyle(
299
+ NotificationCompat.CallStyle.forOngoingCall(buildPerson(displayName), hangupPI),
300
+ ).setFullScreenIntent(contentIntent, true)
301
+ .setContentIntent(contentIntent)
302
+ .setOngoing(true)
303
+ .setAutoCancel(false)
304
+ .setUsesChronometer(true)
305
+ .setWhen(connectedAtMs)
306
+ .build()
307
+
308
+ postNotification(ctx, callId, displayName, "ongoing call")
309
+ notify(ctx, notification)
310
+ }
311
+
312
+ /** Shows a brief "Call Ended" notification that auto-cancels after ~2 seconds. */
313
+ fun showEndedCall(
314
+ context: Context,
315
+ callId: UUID,
316
+ callerName: String?,
317
+ ) {
318
+ cancelDelayedCancel()
319
+ val ctx = context.applicationContext
320
+ val displayName = callerName ?: "Unknown"
321
+
322
+ val notification =
323
+ buildBase(ctx, CHANNEL_ONGOING, displayName, "Call ended")
324
+ .setOngoing(false)
325
+ .setAutoCancel(true)
326
+ .build()
327
+
328
+ postNotification(ctx, callId, displayName, "ended call")
329
+ notify(ctx, notification)
330
+
331
+ delayedCancelJob =
332
+ scope.launch {
333
+ delay(ENDED_CANCEL_DELAY_MS)
334
+ cancel(ctx)
335
+ }
336
+ }
337
+
338
+ /** Cancels any active call notification. */
339
+ fun cancel(context: Context) {
340
+ cancelDelayedCancel()
341
+ NotificationManagerCompat.from(context.applicationContext).cancel(NOTIFICATION_ID)
342
+ CallKitTelecomLog.d(TAG) { "Cancelled call notification" }
343
+ }
344
+
345
+ /** Cancels any pending delayed-cancel job without cancelling the notification. */
346
+ private fun cancelDelayedCancel() {
347
+ delayedCancelJob?.cancel()
348
+ delayedCancelJob = null
349
+ }
350
+
351
+ /** Creates a Person with an icon for use in CallStyle notifications. */
352
+ private fun buildPerson(displayName: String): Person =
353
+ Person
354
+ .Builder()
355
+ .setName(displayName)
356
+ .setIcon(IconCompat.createWithResource(appContext, android.R.drawable.ic_menu_call))
357
+ .setImportant(true)
358
+ .build()
359
+
360
+ /** Creates a base notification builder with shared configuration. */
361
+ private fun buildBase(
362
+ ctx: Context,
363
+ channelId: String,
364
+ title: String,
365
+ text: String,
366
+ ): NotificationCompat.Builder =
367
+ NotificationCompat
368
+ .Builder(ctx, channelId)
369
+ .setSmallIcon(getAppIconRes(ctx))
370
+ .setContentTitle(title)
371
+ .setContentText(text)
372
+ .setCategory(NotificationCompat.CATEGORY_CALL)
373
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
374
+
375
+ /** Posts notification, logging success or permission errors. */
376
+ private fun postNotification(
377
+ ctx: Context,
378
+ callId: UUID,
379
+ displayName: String,
380
+ label: String,
381
+ ) {
382
+ CallKitTelecomLog.d(TAG) { "Showing $label notification - callId: $callId, caller: $displayName" }
383
+ }
384
+
385
+ /** Notifies via NotificationManagerCompat, catching posting failures. */
386
+ private fun notify(
387
+ ctx: Context,
388
+ notification: Notification,
389
+ ) {
390
+ try {
391
+ NotificationManagerCompat.from(ctx).notify(NOTIFICATION_ID, notification)
392
+ } catch (e: Exception) {
393
+ CallKitTelecomLog.e(TAG) { "Failed to post notification: ${e.message}" }
394
+ }
395
+ }
396
+
397
+ /** Builds a full-screen intent targeting IncomingCallActivity for lock screen display. */
398
+ private fun buildIncomingCallFullScreenIntent(
399
+ context: Context,
400
+ callId: UUID,
401
+ ): PendingIntent {
402
+ val intent =
403
+ Intent(context, IncomingCallActivity::class.java).apply {
404
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
405
+ putExtra(IncomingCallActivity.EXTRA_CALL_ID, callId.toString())
406
+ }
407
+
408
+ return PendingIntent.getActivity(
409
+ context,
410
+ callId.hashCode() + 4,
411
+ intent,
412
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
413
+ )
414
+ }
415
+
416
+ /** Builds a full-screen intent that launches the app's main activity. */
417
+ private fun buildFullScreenIntent(
418
+ context: Context,
419
+ callId: UUID,
420
+ ): PendingIntent {
421
+ val launchIntent =
422
+ context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
423
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or
424
+ Intent.FLAG_ACTIVITY_SINGLE_TOP or
425
+ Intent.FLAG_ACTIVITY_CLEAR_TOP
426
+ putExtra(CallNotificationReceiver.EXTRA_CALL_ID, callId.toString())
427
+ } ?: Intent()
428
+
429
+ return PendingIntent.getActivity(
430
+ context,
431
+ callId.hashCode() + 3,
432
+ launchIntent,
433
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
434
+ )
435
+ }
436
+
437
+ /** Resolves the app's icon resource for the notification small icon. */
438
+ private fun getAppIconRes(context: Context): Int =
439
+ try {
440
+ val appInfo = context.packageManager.getApplicationInfo(context.packageName, 0)
441
+ appInfo.icon.takeIf { it != 0 } ?: android.R.drawable.sym_def_app_icon
442
+ } catch (_: PackageManager.NameNotFoundException) {
443
+ android.R.drawable.sym_def_app_icon
444
+ }
445
+ }
@@ -0,0 +1,27 @@
1
+ package expo.modules.callkittelecom.managers
2
+
3
+ import android.content.Context
4
+ import expo.modules.callkittelecom.utils.PermissionUtils
5
+
6
+ /**
7
+ * Reports capture-related state exposed through the shared JS API.
8
+ *
9
+ * Android parity currently focuses on camera permission status.
10
+ */
11
+ object CaptureSessionManager {
12
+ private lateinit var context: Context
13
+ private var isInitialized = false
14
+
15
+ /** Initializes manager with application context. Safe to call repeatedly. */
16
+ fun initialize(appContext: Context) {
17
+ if (isInitialized) return
18
+ context = appContext.applicationContext
19
+ isInitialized = true
20
+ }
21
+
22
+ /** Returns capture session state matching the TypeScript `CaptureSession` shape. */
23
+ fun getCaptureSessionState(): Map<String, Any?> =
24
+ mapOf(
25
+ "cameraPermission" to PermissionUtils.cameraPermission(context),
26
+ )
27
+ }
@@ -0,0 +1,171 @@
1
+ package expo.modules.callkittelecom.managers
2
+
3
+ import android.content.Context
4
+ import android.content.pm.PackageManager
5
+ import android.media.MediaPlayer
6
+ import expo.modules.callkittelecom.utils.CallKitTelecomLog
7
+ import kotlinx.coroutines.CoroutineScope
8
+ import kotlinx.coroutines.Dispatchers
9
+ import kotlinx.coroutines.Job
10
+ import kotlinx.coroutines.SupervisorJob
11
+ import kotlinx.coroutines.delay
12
+ import kotlinx.coroutines.launch
13
+ import kotlinx.coroutines.sync.Mutex
14
+ import kotlinx.coroutines.sync.withLock
15
+
16
+ /**
17
+ * Plays the dialtone sound during outgoing call connection.
18
+ *
19
+ * Reads the dialtone resource name from AndroidManifest metadata
20
+ * (`ExpoCallKitTelecomDefaultDialtone`) and plays it in a loop with a fade-in
21
+ * until stopped.
22
+ */
23
+ object DialtonePlayer {
24
+ private const val TAG = "ExpoCallKitTelecom.Dialtone"
25
+ private const val KEY_DEFAULT_DIALTONE = "ExpoCallKitTelecomDefaultDialtone"
26
+
27
+ /** Delay before starting playback to let audio session settle (in ms). */
28
+ private const val START_DELAY_MS = 50L
29
+
30
+ /** Duration of volume fade-in (in ms). */
31
+ private const val FADE_IN_DURATION_MS = 100L
32
+ private const val FADE_STEPS = 10
33
+
34
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
35
+ private val mutex = Mutex()
36
+
37
+ private var player: MediaPlayer? = null
38
+ private var fadeJob: Job? = null
39
+ private var rawResourceId: Int = 0
40
+ private var isInitialized = false
41
+
42
+ /** Whether a dialtone resource is configured in the manifest. */
43
+ val hasDialtone: Boolean
44
+ get() = rawResourceId != 0
45
+
46
+ /** Reads dialtone config from AndroidManifest metadata. Safe to call repeatedly. */
47
+ fun initialize(context: Context) {
48
+ if (isInitialized) return
49
+
50
+ val appContext = context.applicationContext
51
+ val dialtoneFilename =
52
+ try {
53
+ val appInfo =
54
+ appContext.packageManager.getApplicationInfo(
55
+ appContext.packageName,
56
+ PackageManager.GET_META_DATA,
57
+ )
58
+ appInfo.metaData?.getString(KEY_DEFAULT_DIALTONE)
59
+ } catch (_: Throwable) {
60
+ null
61
+ }
62
+
63
+ if (dialtoneFilename == null) {
64
+ CallKitTelecomLog.d(TAG) { "No dialtone configured in manifest" }
65
+ isInitialized = true
66
+ return
67
+ }
68
+
69
+ // The plugin writes the sanitized resource name into the manifest,
70
+ // so we can use it directly for the resource lookup.
71
+ rawResourceId = appContext.resources.getIdentifier(dialtoneFilename, "raw", appContext.packageName)
72
+ if (rawResourceId == 0) {
73
+ CallKitTelecomLog.e(TAG) { "Dialtone raw resource not found: $dialtoneFilename" }
74
+ } else {
75
+ CallKitTelecomLog.d(TAG) { "Initialized dialtone: $dialtoneFilename (resId=$rawResourceId)" }
76
+ }
77
+
78
+ isInitialized = true
79
+ }
80
+
81
+ /** Starts playing the dialtone sound in a loop with fade-in. */
82
+ fun play(context: Context) {
83
+ if (!hasDialtone) {
84
+ CallKitTelecomLog.d(TAG) { "No dialtone configured, skipping playback" }
85
+ return
86
+ }
87
+
88
+ if (!CallAudioManager.isActive) {
89
+ CallKitTelecomLog.d(TAG) { "Audio session not active, skipping dialtone" }
90
+ return
91
+ }
92
+
93
+ scope.launch {
94
+ mutex.withLock {
95
+ if (player != null) {
96
+ CallKitTelecomLog.d(TAG) { "Dialtone already playing" }
97
+ return@withLock
98
+ }
99
+
100
+ try {
101
+ val mp = MediaPlayer.create(context.applicationContext, rawResourceId)
102
+ if (mp == null) {
103
+ CallKitTelecomLog.e(TAG) { "Failed to create MediaPlayer for dialtone" }
104
+ return@withLock
105
+ }
106
+
107
+ mp.isLooping = true
108
+ mp.setVolume(0f, 0f)
109
+ player = mp
110
+
111
+ // Brief delay to let audio session settle
112
+ delay(START_DELAY_MS)
113
+
114
+ if (player == mp) {
115
+ mp.start()
116
+ fadeIn()
117
+ CallKitTelecomLog.d(TAG) { "Started playing dialtone" }
118
+ }
119
+ } catch (e: Throwable) {
120
+ CallKitTelecomLog.e(TAG) { "Failed to play dialtone: ${e.localizedMessage}" }
121
+ player?.release()
122
+ player = null
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Fades in the volume from 0 to 1 over FADE_IN_DURATION_MS. */
129
+ private fun fadeIn() {
130
+ fadeJob?.cancel()
131
+ val stepDuration = FADE_IN_DURATION_MS / FADE_STEPS
132
+ val volumeStep = 1.0f / FADE_STEPS
133
+
134
+ fadeJob =
135
+ scope.launch {
136
+ for (step in 1..FADE_STEPS) {
137
+ delay(stepDuration)
138
+ val volume = volumeStep * step
139
+ player?.setVolume(volume, volume)
140
+ }
141
+ fadeJob = null
142
+ }
143
+ }
144
+
145
+ /** Stops playing the dialtone sound. */
146
+ fun stop() {
147
+ scope.launch {
148
+ mutex.withLock {
149
+ fadeJob?.cancel()
150
+ fadeJob = null
151
+
152
+ val mp = player ?: return@withLock
153
+ player = null
154
+
155
+ try {
156
+ if (mp.isPlaying) {
157
+ mp.stop()
158
+ }
159
+ mp.release()
160
+ CallKitTelecomLog.d(TAG) { "Stopped playing dialtone" }
161
+ } catch (e: Throwable) {
162
+ CallKitTelecomLog.e(TAG) { "Error stopping dialtone: ${e.localizedMessage}" }
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ /** Whether the dialtone is currently playing. */
169
+ val isPlaying: Boolean
170
+ get() = player?.isPlaying == true
171
+ }