react-native-telecom 0.0.0-stage → 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 +20 -0
- package/README.md +309 -2
- package/Telecom.podspec +20 -0
- package/android/build.gradle +58 -0
- package/android/src/main/AndroidManifest.xml +43 -0
- package/android/src/main/java/com/telecom/CallSession.kt +37 -0
- package/android/src/main/java/com/telecom/CallSessionManager.kt +486 -0
- package/android/src/main/java/com/telecom/TelecomAudioRouter.kt +227 -0
- package/android/src/main/java/com/telecom/TelecomAvatarLoader.kt +77 -0
- package/android/src/main/java/com/telecom/TelecomCallService.kt +395 -0
- package/android/src/main/java/com/telecom/TelecomCallUi.kt +65 -0
- package/android/src/main/java/com/telecom/TelecomInCallActivity.kt +285 -0
- package/android/src/main/java/com/telecom/TelecomIncomingCallActivity.kt +171 -0
- package/android/src/main/java/com/telecom/TelecomModule.kt +189 -0
- package/android/src/main/java/com/telecom/TelecomPackage.kt +31 -0
- package/android/src/main/java/com/telecom/TelecomPushBridge.kt +82 -0
- package/android/src/main/java/com/telecom/TelecomRingtonePlayer.kt +109 -0
- package/android/src/main/java/com/telecom/TelecomUiConfig.kt +27 -0
- package/android/src/main/res/drawable/rn_telecom_action_pill.xml +6 -0
- package/android/src/main/res/drawable/rn_telecom_answer_circle.xml +8 -0
- package/android/src/main/res/drawable/rn_telecom_answer_circle_green.xml +8 -0
- package/android/src/main/res/drawable/rn_telecom_avatar_bg.xml +6 -0
- package/android/src/main/res/drawable/rn_telecom_avatar_circle.xml +12 -0
- package/android/src/main/res/drawable/rn_telecom_btn_answer.xml +6 -0
- package/android/src/main/res/drawable/rn_telecom_btn_decline.xml +6 -0
- package/android/src/main/res/drawable/rn_telecom_btn_message.xml +9 -0
- package/android/src/main/res/drawable/rn_telecom_btn_secondary.xml +6 -0
- package/android/src/main/res/drawable/rn_telecom_control_circle.xml +8 -0
- package/android/src/main/res/drawable/rn_telecom_control_circle_on.xml +8 -0
- package/android/src/main/res/drawable/rn_telecom_decline_circle.xml +8 -0
- package/android/src/main/res/drawable/rn_telecom_ic_call.xml +10 -0
- package/android/src/main/res/drawable/rn_telecom_ic_call_end.xml +16 -0
- package/android/src/main/res/drawable/rn_telecom_ic_call_white.xml +10 -0
- package/android/src/main/res/drawable/rn_telecom_ic_dialpad.xml +13 -0
- package/android/src/main/res/drawable/rn_telecom_ic_hold.xml +10 -0
- package/android/src/main/res/drawable/rn_telecom_ic_message.xml +10 -0
- package/android/src/main/res/drawable/rn_telecom_ic_mic.xml +10 -0
- package/android/src/main/res/drawable/rn_telecom_ic_mic_off.xml +10 -0
- package/android/src/main/res/drawable/rn_telecom_ic_speaker.xml +10 -0
- package/android/src/main/res/layout/rn_telecom_control_item.xml +36 -0
- package/android/src/main/res/layout/rn_telecom_incall.xml +168 -0
- package/android/src/main/res/layout/rn_telecom_incoming_call.xml +188 -0
- package/ios/Telecom.h +5 -0
- package/ios/Telecom.mm +66 -0
- package/lib/module/NativeTelecom.js +5 -0
- package/lib/module/NativeTelecom.js.map +1 -0
- package/lib/module/index.js +102 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeTelecom.d.ts +40 -0
- package/lib/typescript/src/NativeTelecom.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +104 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/package.json +153 -4
- package/src/NativeTelecom.ts +55 -0
- package/src/index.tsx +220 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
package com.telecom
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.media.AudioManager
|
|
7
|
+
import android.media.ToneGenerator
|
|
8
|
+
import android.net.Uri
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.telecom.DisconnectCause
|
|
11
|
+
import androidx.core.content.ContextCompat
|
|
12
|
+
import androidx.core.telecom.CallAttributesCompat
|
|
13
|
+
import androidx.core.telecom.CallsManager
|
|
14
|
+
import com.facebook.react.bridge.Arguments
|
|
15
|
+
import com.facebook.react.bridge.WritableArray
|
|
16
|
+
import com.facebook.react.bridge.WritableMap
|
|
17
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
18
|
+
import kotlinx.coroutines.CoroutineScope
|
|
19
|
+
import kotlinx.coroutines.Dispatchers
|
|
20
|
+
import kotlinx.coroutines.SupervisorJob
|
|
21
|
+
import kotlinx.coroutines.delay
|
|
22
|
+
import kotlinx.coroutines.launch
|
|
23
|
+
|
|
24
|
+
internal class CallSessionManager(
|
|
25
|
+
private val context: Context,
|
|
26
|
+
private var emit: (eventName: String, params: WritableMap) -> Unit,
|
|
27
|
+
) {
|
|
28
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
29
|
+
private val callsManager = CallsManager(context)
|
|
30
|
+
private val sessions = ConcurrentHashMap<String, CallSession>()
|
|
31
|
+
private val starting = ConcurrentHashMap.newKeySet<String>()
|
|
32
|
+
private val pendingAutoAnswer = ConcurrentHashMap.newKeySet<String>()
|
|
33
|
+
private var registered = false
|
|
34
|
+
private val audioRouter = TelecomAudioRouter(context) { name, params -> emit(name, params) }
|
|
35
|
+
|
|
36
|
+
init {
|
|
37
|
+
instance = this
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fun rebindEmitter(next: (eventName: String, params: WritableMap) -> Unit) {
|
|
41
|
+
emit = next
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fun setup(supportsVideo: Boolean, ui: TelecomUiConfig? = null): Boolean {
|
|
45
|
+
if (ui != null) {
|
|
46
|
+
TelecomUiConfig.current = ui
|
|
47
|
+
}
|
|
48
|
+
ensureManageOwnCallsPermission()
|
|
49
|
+
|
|
50
|
+
var capabilities = CallsManager.CAPABILITY_BASELINE
|
|
51
|
+
if (supportsVideo) {
|
|
52
|
+
capabilities = capabilities or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
callsManager.registerAppWithTelecom(capabilities)
|
|
56
|
+
registered = true
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fun displayIncomingCall(
|
|
61
|
+
uuid: String,
|
|
62
|
+
handle: String,
|
|
63
|
+
callerName: String,
|
|
64
|
+
hasVideo: Boolean,
|
|
65
|
+
autoAnswer: Boolean,
|
|
66
|
+
avatarUrl: String = "",
|
|
67
|
+
) {
|
|
68
|
+
if (autoAnswer) {
|
|
69
|
+
pendingAutoAnswer.add(uuid)
|
|
70
|
+
}
|
|
71
|
+
addCall(
|
|
72
|
+
uuid = uuid,
|
|
73
|
+
handle = handle,
|
|
74
|
+
callerName = callerName,
|
|
75
|
+
hasVideo = hasVideo,
|
|
76
|
+
direction = CallAttributesCompat.DIRECTION_INCOMING,
|
|
77
|
+
avatarUrl = avatarUrl,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fun startCall(
|
|
82
|
+
uuid: String,
|
|
83
|
+
handle: String,
|
|
84
|
+
callerName: String,
|
|
85
|
+
hasVideo: Boolean,
|
|
86
|
+
avatarUrl: String = "",
|
|
87
|
+
) {
|
|
88
|
+
addCall(
|
|
89
|
+
uuid = uuid,
|
|
90
|
+
handle = handle,
|
|
91
|
+
callerName = callerName,
|
|
92
|
+
hasVideo = hasVideo,
|
|
93
|
+
direction = CallAttributesCompat.DIRECTION_OUTGOING,
|
|
94
|
+
avatarUrl = avatarUrl,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fun answerCall(uuid: String) {
|
|
99
|
+
scope.launch {
|
|
100
|
+
val session = sessions[uuid]
|
|
101
|
+
if (session == null) {
|
|
102
|
+
android.util.Log.w(TAG, "answerCall ignored — no session for $uuid")
|
|
103
|
+
return@launch
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
TelecomRingtonePlayer.stop()
|
|
107
|
+
TelecomCallUi.finishIncoming(context, uuid)
|
|
108
|
+
|
|
109
|
+
val result = session.answer()
|
|
110
|
+
android.util.Log.i(TAG, "answerCall uuid=$uuid result=$result")
|
|
111
|
+
audioRouter.activate(uuid)
|
|
112
|
+
emit(
|
|
113
|
+
EVENT_ANSWER_CALL,
|
|
114
|
+
Arguments.createMap().apply {
|
|
115
|
+
putString("callUUID", uuid)
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
TelecomCallService.updateOngoing(
|
|
120
|
+
context = context,
|
|
121
|
+
uuid = uuid,
|
|
122
|
+
callerName = session.displayName,
|
|
123
|
+
handle = session.handle,
|
|
124
|
+
avatarUrl = session.avatarUrl,
|
|
125
|
+
)
|
|
126
|
+
TelecomCallService.openInCall(
|
|
127
|
+
context = context,
|
|
128
|
+
uuid = uuid,
|
|
129
|
+
callerName = session.displayName,
|
|
130
|
+
handle = session.handle,
|
|
131
|
+
direction = "incoming",
|
|
132
|
+
avatarUrl = session.avatarUrl,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fun endCall(uuid: String) {
|
|
138
|
+
scope.launch {
|
|
139
|
+
TelecomRingtonePlayer.stop()
|
|
140
|
+
TelecomCallUi.finishAll(context, uuid)
|
|
141
|
+
val session = sessions.remove(uuid)
|
|
142
|
+
starting.remove(uuid)
|
|
143
|
+
pendingAutoAnswer.remove(uuid)
|
|
144
|
+
if (session != null) {
|
|
145
|
+
session.disconnect(DisconnectCause(DisconnectCause.LOCAL))
|
|
146
|
+
}
|
|
147
|
+
if (sessions.isEmpty()) {
|
|
148
|
+
audioRouter.deactivate(uuid)
|
|
149
|
+
}
|
|
150
|
+
maybeStopCallUi()
|
|
151
|
+
emit(
|
|
152
|
+
EVENT_END_CALL,
|
|
153
|
+
Arguments.createMap().apply {
|
|
154
|
+
putString("callUUID", uuid)
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fun endAllCalls() {
|
|
161
|
+
scope.launch {
|
|
162
|
+
TelecomRingtonePlayer.stop()
|
|
163
|
+
TelecomCallUi.finishAll(context)
|
|
164
|
+
sessions.keys.toList().forEach { uuid ->
|
|
165
|
+
val session = sessions.remove(uuid)
|
|
166
|
+
starting.remove(uuid)
|
|
167
|
+
pendingAutoAnswer.remove(uuid)
|
|
168
|
+
session?.disconnect(DisconnectCause(DisconnectCause.LOCAL))
|
|
169
|
+
emit(
|
|
170
|
+
EVENT_END_CALL,
|
|
171
|
+
Arguments.createMap().apply {
|
|
172
|
+
putString("callUUID", uuid)
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
audioRouter.deactivate()
|
|
177
|
+
TelecomCallService.stop(context)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fun setMuted(uuid: String, muted: Boolean) {
|
|
182
|
+
if (!sessions.containsKey(uuid)) {
|
|
183
|
+
android.util.Log.w(TAG, "setMuted ignored — no active session for $uuid")
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
188
|
+
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
189
|
+
audioManager.isMicrophoneMute = muted
|
|
190
|
+
android.util.Log.i(TAG, "setMuted uuid=$uuid muted=$muted actual=${audioManager.isMicrophoneMute}")
|
|
191
|
+
|
|
192
|
+
emit(
|
|
193
|
+
EVENT_DID_CHANGE_MUTE_STATE,
|
|
194
|
+
Arguments.createMap().apply {
|
|
195
|
+
putString("callUUID", uuid)
|
|
196
|
+
putBoolean("muted", muted)
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fun setAudioRoute(uuid: String, route: String) {
|
|
202
|
+
if (!sessions.containsKey(uuid)) return
|
|
203
|
+
audioRouter.setRoute(uuid, route)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fun getAvailableAudioRoutes(): WritableArray = audioRouter.getAvailableRoutes()
|
|
207
|
+
|
|
208
|
+
fun cycleAudioRoute(uuid: String): String {
|
|
209
|
+
if (!sessions.containsKey(uuid)) return audioRouter.currentRouteId()
|
|
210
|
+
return audioRouter.cycleRoute(uuid)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
fun currentAudioRoute(): String = audioRouter.currentRouteId()
|
|
214
|
+
|
|
215
|
+
fun sendDtmf(uuid: String, digits: String) {
|
|
216
|
+
if (!sessions.containsKey(uuid)) return
|
|
217
|
+
scope.launch(Dispatchers.Default) {
|
|
218
|
+
var tone: ToneGenerator? = null
|
|
219
|
+
try {
|
|
220
|
+
tone = ToneGenerator(AudioManager.STREAM_DTMF, 80)
|
|
221
|
+
digits.forEach { ch ->
|
|
222
|
+
val digit = ch.toString()
|
|
223
|
+
val toneType = dtmfTone(digit) ?: return@forEach
|
|
224
|
+
try {
|
|
225
|
+
tone.startTone(toneType, 160)
|
|
226
|
+
} catch (_: Exception) {
|
|
227
|
+
}
|
|
228
|
+
emit(
|
|
229
|
+
EVENT_DTMF,
|
|
230
|
+
Arguments.createMap().apply {
|
|
231
|
+
putString("callUUID", uuid)
|
|
232
|
+
putString("digit", digit)
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
delay(180)
|
|
236
|
+
}
|
|
237
|
+
} catch (_: Exception) {
|
|
238
|
+
} finally {
|
|
239
|
+
tone?.release()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
fun emitSpeakerChanged(uuid: String, speakerOn: Boolean) {
|
|
245
|
+
setAudioRoute(uuid, if (speakerOn) "speaker" else "earpiece")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fun emitDtmf(uuid: String, digit: String) {
|
|
249
|
+
emit(
|
|
250
|
+
EVENT_DTMF,
|
|
251
|
+
Arguments.createMap().apply {
|
|
252
|
+
putString("callUUID", uuid)
|
|
253
|
+
putString("digit", digit)
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fun setOnHold(uuid: String, hold: Boolean) {
|
|
259
|
+
scope.launch {
|
|
260
|
+
val session = sessions[uuid]
|
|
261
|
+
if (session == null) {
|
|
262
|
+
android.util.Log.w(TAG, "setOnHold ignored — no active session for $uuid")
|
|
263
|
+
return@launch
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
val result =
|
|
267
|
+
if (hold) {
|
|
268
|
+
session.setInactive()
|
|
269
|
+
} else {
|
|
270
|
+
session.setActive()
|
|
271
|
+
}
|
|
272
|
+
android.util.Log.i(TAG, "setOnHold uuid=$uuid hold=$hold result=$result")
|
|
273
|
+
|
|
274
|
+
emit(
|
|
275
|
+
EVENT_DID_CHANGE_HOLD_STATE,
|
|
276
|
+
Arguments.createMap().apply {
|
|
277
|
+
putString("callUUID", uuid)
|
|
278
|
+
putBoolean("hold", hold)
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private fun addCall(
|
|
285
|
+
uuid: String,
|
|
286
|
+
handle: String,
|
|
287
|
+
callerName: String,
|
|
288
|
+
hasVideo: Boolean,
|
|
289
|
+
direction: Int,
|
|
290
|
+
avatarUrl: String,
|
|
291
|
+
) {
|
|
292
|
+
if (!registered) {
|
|
293
|
+
setup(supportsVideo = true)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
ensureManageOwnCallsPermission()
|
|
297
|
+
|
|
298
|
+
if (sessions.containsKey(uuid) || !starting.add(uuid)) {
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
val address = Uri.parse("tel:${handle.ifBlank { "unknown" }}")
|
|
303
|
+
val callType =
|
|
304
|
+
if (hasVideo) {
|
|
305
|
+
CallAttributesCompat.CALL_TYPE_VIDEO_CALL
|
|
306
|
+
} else {
|
|
307
|
+
CallAttributesCompat.CALL_TYPE_AUDIO_CALL
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
val attributes =
|
|
311
|
+
CallAttributesCompat(
|
|
312
|
+
displayName = callerName.ifBlank { handle }.ifBlank { "Unknown" },
|
|
313
|
+
address = address,
|
|
314
|
+
direction = direction,
|
|
315
|
+
callType = callType,
|
|
316
|
+
callCapabilities = CallAttributesCompat.SUPPORTS_SET_INACTIVE,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
val directionLabel =
|
|
320
|
+
if (direction == CallAttributesCompat.DIRECTION_INCOMING) {
|
|
321
|
+
"incoming"
|
|
322
|
+
} else {
|
|
323
|
+
"outgoing"
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
scope.launch {
|
|
327
|
+
try {
|
|
328
|
+
callsManager.addCall(
|
|
329
|
+
callAttributes = attributes,
|
|
330
|
+
onAnswer = { _ ->
|
|
331
|
+
emit(
|
|
332
|
+
EVENT_ANSWER_CALL,
|
|
333
|
+
Arguments.createMap().apply {
|
|
334
|
+
putString("callUUID", uuid)
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
},
|
|
338
|
+
onDisconnect = { _ ->
|
|
339
|
+
sessions.remove(uuid)
|
|
340
|
+
starting.remove(uuid)
|
|
341
|
+
pendingAutoAnswer.remove(uuid)
|
|
342
|
+
TelecomRingtonePlayer.stop()
|
|
343
|
+
TelecomCallUi.finishAll(context, uuid)
|
|
344
|
+
if (sessions.isEmpty()) {
|
|
345
|
+
audioRouter.deactivate(uuid)
|
|
346
|
+
}
|
|
347
|
+
maybeStopCallUi()
|
|
348
|
+
emit(
|
|
349
|
+
EVENT_END_CALL,
|
|
350
|
+
Arguments.createMap().apply {
|
|
351
|
+
putString("callUUID", uuid)
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
},
|
|
355
|
+
onSetActive = {
|
|
356
|
+
emit(
|
|
357
|
+
EVENT_DID_CHANGE_HOLD_STATE,
|
|
358
|
+
Arguments.createMap().apply {
|
|
359
|
+
putString("callUUID", uuid)
|
|
360
|
+
putBoolean("hold", false)
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
},
|
|
364
|
+
onSetInactive = {
|
|
365
|
+
emit(
|
|
366
|
+
EVENT_DID_CHANGE_HOLD_STATE,
|
|
367
|
+
Arguments.createMap().apply {
|
|
368
|
+
putString("callUUID", uuid)
|
|
369
|
+
putBoolean("hold", true)
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
},
|
|
373
|
+
) {
|
|
374
|
+
val session = CallSession(uuid, this, hasVideo, callerName, handle, avatarUrl)
|
|
375
|
+
sessions[uuid] = session
|
|
376
|
+
starting.remove(uuid)
|
|
377
|
+
|
|
378
|
+
TelecomCallService.start(
|
|
379
|
+
context = context,
|
|
380
|
+
uuid = uuid,
|
|
381
|
+
callerName = callerName.ifBlank { handle }.ifBlank { "Unknown" },
|
|
382
|
+
handle = handle,
|
|
383
|
+
direction = directionLabel,
|
|
384
|
+
skipIncomingUi = pendingAutoAnswer.contains(uuid),
|
|
385
|
+
avatarUrl = avatarUrl,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
emit(
|
|
389
|
+
EVENT_DID_DISPLAY_INCOMING_CALL,
|
|
390
|
+
Arguments.createMap().apply {
|
|
391
|
+
putString("callUUID", uuid)
|
|
392
|
+
putString("handle", handle)
|
|
393
|
+
putString("callerName", callerName)
|
|
394
|
+
putBoolean("hasVideo", hasVideo)
|
|
395
|
+
putString("direction", directionLabel)
|
|
396
|
+
putString("avatarUrl", avatarUrl)
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if (direction == CallAttributesCompat.DIRECTION_OUTGOING) {
|
|
401
|
+
launch {
|
|
402
|
+
setActive()
|
|
403
|
+
audioRouter.activate(uuid)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (pendingAutoAnswer.remove(uuid)) {
|
|
408
|
+
launch {
|
|
409
|
+
delay(350)
|
|
410
|
+
answerCall(uuid)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} catch (error: Exception) {
|
|
415
|
+
sessions.remove(uuid)
|
|
416
|
+
starting.remove(uuid)
|
|
417
|
+
pendingAutoAnswer.remove(uuid)
|
|
418
|
+
maybeStopCallUi()
|
|
419
|
+
emit(
|
|
420
|
+
EVENT_END_CALL,
|
|
421
|
+
Arguments.createMap().apply {
|
|
422
|
+
putString("callUUID", uuid)
|
|
423
|
+
putString("error", error.message ?: "Failed to add call")
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private fun maybeStopCallUi() {
|
|
431
|
+
if (sessions.isEmpty()) {
|
|
432
|
+
TelecomCallService.stop(context)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private fun ensureManageOwnCallsPermission() {
|
|
437
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
438
|
+
throw IllegalStateException("react-native-telecom requires Android 8.0 (API 26)+")
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
val granted =
|
|
442
|
+
ContextCompat.checkSelfPermission(
|
|
443
|
+
context,
|
|
444
|
+
Manifest.permission.MANAGE_OWN_CALLS,
|
|
445
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
446
|
+
|
|
447
|
+
if (!granted) {
|
|
448
|
+
throw SecurityException(
|
|
449
|
+
"Missing android.permission.MANAGE_OWN_CALLS. See docs/android-setup.md"
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private fun dtmfTone(digit: String): Int? {
|
|
455
|
+
return when (digit) {
|
|
456
|
+
"0" -> ToneGenerator.TONE_DTMF_0
|
|
457
|
+
"1" -> ToneGenerator.TONE_DTMF_1
|
|
458
|
+
"2" -> ToneGenerator.TONE_DTMF_2
|
|
459
|
+
"3" -> ToneGenerator.TONE_DTMF_3
|
|
460
|
+
"4" -> ToneGenerator.TONE_DTMF_4
|
|
461
|
+
"5" -> ToneGenerator.TONE_DTMF_5
|
|
462
|
+
"6" -> ToneGenerator.TONE_DTMF_6
|
|
463
|
+
"7" -> ToneGenerator.TONE_DTMF_7
|
|
464
|
+
"8" -> ToneGenerator.TONE_DTMF_8
|
|
465
|
+
"9" -> ToneGenerator.TONE_DTMF_9
|
|
466
|
+
"*" -> ToneGenerator.TONE_DTMF_S
|
|
467
|
+
"#" -> ToneGenerator.TONE_DTMF_P
|
|
468
|
+
else -> null
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
companion object {
|
|
473
|
+
private const val TAG = "RNTelecom"
|
|
474
|
+
|
|
475
|
+
@Volatile
|
|
476
|
+
var instance: CallSessionManager? = null
|
|
477
|
+
|
|
478
|
+
const val EVENT_ANSWER_CALL = "answerCall"
|
|
479
|
+
const val EVENT_END_CALL = "endCall"
|
|
480
|
+
const val EVENT_DID_DISPLAY_INCOMING_CALL = "didDisplayIncomingCall"
|
|
481
|
+
const val EVENT_DID_CHANGE_HOLD_STATE = "didChangeHoldState"
|
|
482
|
+
const val EVENT_DID_CHANGE_MUTE_STATE = "didChangeMuteState"
|
|
483
|
+
const val EVENT_DID_CHANGE_SPEAKER = "didChangeSpeaker"
|
|
484
|
+
const val EVENT_DTMF = "dtmfTone"
|
|
485
|
+
}
|
|
486
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
package com.telecom
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.bluetooth.BluetoothAdapter
|
|
5
|
+
import android.bluetooth.BluetoothProfile
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.content.pm.PackageManager
|
|
8
|
+
import android.media.AudioDeviceInfo
|
|
9
|
+
import android.media.AudioManager
|
|
10
|
+
import android.os.Build
|
|
11
|
+
import android.util.Log
|
|
12
|
+
import androidx.core.content.ContextCompat
|
|
13
|
+
import com.facebook.react.bridge.Arguments
|
|
14
|
+
import com.facebook.react.bridge.WritableArray
|
|
15
|
+
import com.facebook.react.bridge.WritableMap
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* VoIP audio routes: earpiece, speaker, bluetooth SCO.
|
|
19
|
+
* Call [activate] when a call becomes active; [deactivate] when all calls end.
|
|
20
|
+
*/
|
|
21
|
+
internal class TelecomAudioRouter(
|
|
22
|
+
private val context: Context,
|
|
23
|
+
private val emit: (eventName: String, params: WritableMap) -> Unit,
|
|
24
|
+
) {
|
|
25
|
+
enum class Route(val id: String) {
|
|
26
|
+
EARPIECE("earpiece"),
|
|
27
|
+
SPEAKER("speaker"),
|
|
28
|
+
BLUETOOTH("bluetooth"),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
32
|
+
private var current: Route = Route.EARPIECE
|
|
33
|
+
private var focusRequest: android.media.AudioFocusRequest? = null
|
|
34
|
+
|
|
35
|
+
fun activate(uuid: String) {
|
|
36
|
+
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
37
|
+
requestFocus()
|
|
38
|
+
applyRoute(current)
|
|
39
|
+
emit(
|
|
40
|
+
EVENT_AUDIO_ACTIVATED,
|
|
41
|
+
Arguments.createMap().apply {
|
|
42
|
+
putString("callUUID", uuid)
|
|
43
|
+
putString("route", current.id)
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
Log.i(TAG, "audio session activated uuid=$uuid route=${current.id}")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fun deactivate(uuid: String? = null) {
|
|
50
|
+
stopBluetoothSco()
|
|
51
|
+
@Suppress("DEPRECATION")
|
|
52
|
+
am.isSpeakerphoneOn = false
|
|
53
|
+
am.mode = AudioManager.MODE_NORMAL
|
|
54
|
+
abandonFocus()
|
|
55
|
+
emit(
|
|
56
|
+
EVENT_AUDIO_DEACTIVATED,
|
|
57
|
+
Arguments.createMap().apply {
|
|
58
|
+
if (!uuid.isNullOrBlank()) putString("callUUID", uuid)
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
Log.i(TAG, "audio session deactivated")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fun setRoute(uuid: String, routeId: String) {
|
|
65
|
+
val route =
|
|
66
|
+
Route.entries.find { it.id == routeId }
|
|
67
|
+
?: run {
|
|
68
|
+
Log.w(TAG, "unknown route=$routeId")
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
current = route
|
|
72
|
+
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
73
|
+
applyRoute(route)
|
|
74
|
+
emit(
|
|
75
|
+
EVENT_DID_CHANGE_AUDIO_ROUTE,
|
|
76
|
+
Arguments.createMap().apply {
|
|
77
|
+
putString("callUUID", uuid)
|
|
78
|
+
putString("route", route.id)
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
// Back-compat for older listeners.
|
|
82
|
+
emit(
|
|
83
|
+
CallSessionManager.EVENT_DID_CHANGE_SPEAKER,
|
|
84
|
+
Arguments.createMap().apply {
|
|
85
|
+
putString("callUUID", uuid)
|
|
86
|
+
putBoolean("speakerOn", route == Route.SPEAKER)
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fun getAvailableRoutes(): WritableArray {
|
|
92
|
+
val list = Arguments.createArray()
|
|
93
|
+
list.pushMap(routeMap(Route.EARPIECE, "Phone", true))
|
|
94
|
+
list.pushMap(routeMap(Route.SPEAKER, "Speaker", true))
|
|
95
|
+
list.pushMap(routeMap(Route.BLUETOOTH, "Bluetooth", isBluetoothAvailable()))
|
|
96
|
+
return list
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fun currentRouteId(): String = current.id
|
|
100
|
+
|
|
101
|
+
fun cycleRoute(uuid: String): String {
|
|
102
|
+
val available =
|
|
103
|
+
listOf(Route.EARPIECE, Route.SPEAKER, Route.BLUETOOTH).filter { route ->
|
|
104
|
+
route != Route.BLUETOOTH || isBluetoothAvailable()
|
|
105
|
+
}
|
|
106
|
+
val idx = available.indexOf(current).let { if (it < 0) 0 else (it + 1) % available.size }
|
|
107
|
+
val next = available[idx]
|
|
108
|
+
setRoute(uuid, next.id)
|
|
109
|
+
return next.id
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private fun applyRoute(route: Route) {
|
|
113
|
+
when (route) {
|
|
114
|
+
Route.SPEAKER -> {
|
|
115
|
+
stopBluetoothSco()
|
|
116
|
+
@Suppress("DEPRECATION")
|
|
117
|
+
am.isSpeakerphoneOn = true
|
|
118
|
+
}
|
|
119
|
+
Route.EARPIECE -> {
|
|
120
|
+
stopBluetoothSco()
|
|
121
|
+
@Suppress("DEPRECATION")
|
|
122
|
+
am.isSpeakerphoneOn = false
|
|
123
|
+
}
|
|
124
|
+
Route.BLUETOOTH -> {
|
|
125
|
+
@Suppress("DEPRECATION")
|
|
126
|
+
am.isSpeakerphoneOn = false
|
|
127
|
+
startBluetoothSco()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private fun startBluetoothSco() {
|
|
133
|
+
try {
|
|
134
|
+
@Suppress("DEPRECATION")
|
|
135
|
+
am.startBluetoothSco()
|
|
136
|
+
@Suppress("DEPRECATION")
|
|
137
|
+
am.isBluetoothScoOn = true
|
|
138
|
+
} catch (error: Exception) {
|
|
139
|
+
Log.w(TAG, "startBluetoothSco failed: ${error.message}")
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun stopBluetoothSco() {
|
|
144
|
+
try {
|
|
145
|
+
@Suppress("DEPRECATION")
|
|
146
|
+
am.isBluetoothScoOn = false
|
|
147
|
+
@Suppress("DEPRECATION")
|
|
148
|
+
am.stopBluetoothSco()
|
|
149
|
+
} catch (_: Exception) {
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private fun isBluetoothAvailable(): Boolean {
|
|
154
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
155
|
+
val granted =
|
|
156
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
|
157
|
+
PackageManager.PERMISSION_GRANTED
|
|
158
|
+
if (!granted) return false
|
|
159
|
+
}
|
|
160
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
161
|
+
val devices = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
|
162
|
+
if (devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP }) {
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return try {
|
|
167
|
+
val adapter = BluetoothAdapter.getDefaultAdapter() ?: return false
|
|
168
|
+
adapter.isEnabled && adapter.getProfileConnectionState(BluetoothProfile.HEADSET) ==
|
|
169
|
+
BluetoothAdapter.STATE_CONNECTED
|
|
170
|
+
} catch (_: SecurityException) {
|
|
171
|
+
false
|
|
172
|
+
} catch (_: Exception) {
|
|
173
|
+
false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun requestFocus() {
|
|
178
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
179
|
+
val attrs =
|
|
180
|
+
android.media.AudioAttributes.Builder()
|
|
181
|
+
.setUsage(android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
|
182
|
+
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
183
|
+
.build()
|
|
184
|
+
val req =
|
|
185
|
+
android.media.AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
186
|
+
.setAudioAttributes(attrs)
|
|
187
|
+
.setAcceptsDelayedFocusGain(true)
|
|
188
|
+
.setOnAudioFocusChangeListener { }
|
|
189
|
+
.build()
|
|
190
|
+
focusRequest = req
|
|
191
|
+
am.requestAudioFocus(req)
|
|
192
|
+
} else {
|
|
193
|
+
@Suppress("DEPRECATION")
|
|
194
|
+
am.requestAudioFocus(
|
|
195
|
+
null,
|
|
196
|
+
AudioManager.STREAM_VOICE_CALL,
|
|
197
|
+
AudioManager.AUDIOFOCUS_GAIN,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private fun abandonFocus() {
|
|
203
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
204
|
+
focusRequest?.let { am.abandonAudioFocusRequest(it) }
|
|
205
|
+
focusRequest = null
|
|
206
|
+
} else {
|
|
207
|
+
@Suppress("DEPRECATION")
|
|
208
|
+
am.abandonAudioFocus(null)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private fun routeMap(route: Route, name: String, available: Boolean): WritableMap {
|
|
213
|
+
return Arguments.createMap().apply {
|
|
214
|
+
putString("id", route.id)
|
|
215
|
+
putString("name", name)
|
|
216
|
+
putBoolean("available", available)
|
|
217
|
+
putBoolean("selected", current == route)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
companion object {
|
|
222
|
+
private const val TAG = "RNTelecomAudio"
|
|
223
|
+
const val EVENT_AUDIO_ACTIVATED = "didActivateAudioSession"
|
|
224
|
+
const val EVENT_AUDIO_DEACTIVATED = "didDeactivateAudioSession"
|
|
225
|
+
const val EVENT_DID_CHANGE_AUDIO_ROUTE = "didChangeAudioRoute"
|
|
226
|
+
}
|
|
227
|
+
}
|