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