react-native-telecom 0.0.0-stage → 0.1.1

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 (57) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +321 -2
  3. package/Telecom.podspec +20 -0
  4. package/android/build.gradle +58 -0
  5. package/android/src/main/AndroidManifest.xml +43 -0
  6. package/android/src/main/java/com/telecom/CallSession.kt +37 -0
  7. package/android/src/main/java/com/telecom/CallSessionManager.kt +486 -0
  8. package/android/src/main/java/com/telecom/TelecomAudioRouter.kt +227 -0
  9. package/android/src/main/java/com/telecom/TelecomAvatarLoader.kt +77 -0
  10. package/android/src/main/java/com/telecom/TelecomCallService.kt +395 -0
  11. package/android/src/main/java/com/telecom/TelecomCallUi.kt +65 -0
  12. package/android/src/main/java/com/telecom/TelecomInCallActivity.kt +285 -0
  13. package/android/src/main/java/com/telecom/TelecomIncomingCallActivity.kt +171 -0
  14. package/android/src/main/java/com/telecom/TelecomModule.kt +189 -0
  15. package/android/src/main/java/com/telecom/TelecomPackage.kt +31 -0
  16. package/android/src/main/java/com/telecom/TelecomPushBridge.kt +82 -0
  17. package/android/src/main/java/com/telecom/TelecomRingtonePlayer.kt +109 -0
  18. package/android/src/main/java/com/telecom/TelecomUiConfig.kt +27 -0
  19. package/android/src/main/res/drawable/rn_telecom_action_pill.xml +6 -0
  20. package/android/src/main/res/drawable/rn_telecom_answer_circle.xml +8 -0
  21. package/android/src/main/res/drawable/rn_telecom_answer_circle_green.xml +8 -0
  22. package/android/src/main/res/drawable/rn_telecom_avatar_bg.xml +6 -0
  23. package/android/src/main/res/drawable/rn_telecom_avatar_circle.xml +12 -0
  24. package/android/src/main/res/drawable/rn_telecom_btn_answer.xml +6 -0
  25. package/android/src/main/res/drawable/rn_telecom_btn_decline.xml +6 -0
  26. package/android/src/main/res/drawable/rn_telecom_btn_message.xml +9 -0
  27. package/android/src/main/res/drawable/rn_telecom_btn_secondary.xml +6 -0
  28. package/android/src/main/res/drawable/rn_telecom_control_circle.xml +8 -0
  29. package/android/src/main/res/drawable/rn_telecom_control_circle_on.xml +8 -0
  30. package/android/src/main/res/drawable/rn_telecom_decline_circle.xml +8 -0
  31. package/android/src/main/res/drawable/rn_telecom_ic_call.xml +10 -0
  32. package/android/src/main/res/drawable/rn_telecom_ic_call_end.xml +16 -0
  33. package/android/src/main/res/drawable/rn_telecom_ic_call_white.xml +10 -0
  34. package/android/src/main/res/drawable/rn_telecom_ic_dialpad.xml +13 -0
  35. package/android/src/main/res/drawable/rn_telecom_ic_hold.xml +10 -0
  36. package/android/src/main/res/drawable/rn_telecom_ic_message.xml +10 -0
  37. package/android/src/main/res/drawable/rn_telecom_ic_mic.xml +10 -0
  38. package/android/src/main/res/drawable/rn_telecom_ic_mic_off.xml +10 -0
  39. package/android/src/main/res/drawable/rn_telecom_ic_speaker.xml +10 -0
  40. package/android/src/main/res/layout/rn_telecom_control_item.xml +36 -0
  41. package/android/src/main/res/layout/rn_telecom_incall.xml +168 -0
  42. package/android/src/main/res/layout/rn_telecom_incoming_call.xml +188 -0
  43. package/ios/Telecom.h +5 -0
  44. package/ios/Telecom.mm +66 -0
  45. package/lib/module/NativeTelecom.js +5 -0
  46. package/lib/module/NativeTelecom.js.map +1 -0
  47. package/lib/module/index.js +102 -0
  48. package/lib/module/index.js.map +1 -0
  49. package/lib/module/package.json +1 -0
  50. package/lib/typescript/package.json +1 -0
  51. package/lib/typescript/src/NativeTelecom.d.ts +40 -0
  52. package/lib/typescript/src/NativeTelecom.d.ts.map +1 -0
  53. package/lib/typescript/src/index.d.ts +104 -0
  54. package/lib/typescript/src/index.d.ts.map +1 -0
  55. package/package.json +154 -4
  56. package/src/NativeTelecom.ts +55 -0
  57. package/src/index.tsx +217 -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
+ }