@voltkit-rtc/react-native 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 (225) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1608 -2
  4. package/android/build.gradle +70 -0
  5. package/android/consumer-rules.pro +21 -0
  6. package/android/src/main/AndroidManifest.xml +25 -0
  7. package/android/src/main/java/com/voltkit/rtc/reactnative/AppVisibility.kt +67 -0
  8. package/android/src/main/java/com/voltkit/rtc/reactnative/AudioDevices.kt +67 -0
  9. package/android/src/main/java/com/voltkit/rtc/reactnative/AudioRouter.kt +487 -0
  10. package/android/src/main/java/com/voltkit/rtc/reactnative/CallServiceTypes.kt +120 -0
  11. package/android/src/main/java/com/voltkit/rtc/reactnative/NativeTimers.kt +106 -0
  12. package/android/src/main/java/com/voltkit/rtc/reactnative/NetworkWatch.kt +120 -0
  13. package/android/src/main/java/com/voltkit/rtc/reactnative/OutputChoice.kt +65 -0
  14. package/android/src/main/java/com/voltkit/rtc/reactnative/RtcConfig.kt +83 -0
  15. package/android/src/main/java/com/voltkit/rtc/reactnative/ScoLink.kt +61 -0
  16. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitCallService.kt +287 -0
  17. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcInitProvider.kt +215 -0
  18. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcModule.kt +313 -0
  19. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcPackage.kt +30 -0
  20. package/android/src/main/res/drawable/voltkit_rtc_default_call_icon.xml +15 -0
  21. package/android/src/main/res/raw/voltkit_rtc_keep.xml +7 -0
  22. package/app.plugin.js +17 -0
  23. package/lib/commonjs/audio.js +215 -0
  24. package/lib/commonjs/audio.js.map +1 -0
  25. package/lib/commonjs/components.js +207 -0
  26. package/lib/commonjs/components.js.map +1 -0
  27. package/lib/commonjs/create-client.js +47 -0
  28. package/lib/commonjs/create-client.js.map +1 -0
  29. package/lib/commonjs/errors.js +99 -0
  30. package/lib/commonjs/errors.js.map +1 -0
  31. package/lib/commonjs/globals.js +61 -0
  32. package/lib/commonjs/globals.js.map +1 -0
  33. package/lib/commonjs/index.js +234 -0
  34. package/lib/commonjs/index.js.map +1 -0
  35. package/lib/commonjs/local-media.js +392 -0
  36. package/lib/commonjs/local-media.js.map +1 -0
  37. package/lib/commonjs/native-session.js +365 -0
  38. package/lib/commonjs/native-session.js.map +1 -0
  39. package/lib/commonjs/native.js +99 -0
  40. package/lib/commonjs/native.js.map +1 -0
  41. package/lib/commonjs/package.json +1 -0
  42. package/lib/commonjs/permissions.js +166 -0
  43. package/lib/commonjs/permissions.js.map +1 -0
  44. package/lib/commonjs/platform/android.js +126 -0
  45. package/lib/commonjs/platform/android.js.map +1 -0
  46. package/lib/commonjs/platform/common.js +138 -0
  47. package/lib/commonjs/platform/common.js.map +1 -0
  48. package/lib/commonjs/platform/devices.js +151 -0
  49. package/lib/commonjs/platform/devices.js.map +1 -0
  50. package/lib/commonjs/platform/index.js +35 -0
  51. package/lib/commonjs/platform/index.js.map +1 -0
  52. package/lib/commonjs/platform/ios.js +54 -0
  53. package/lib/commonjs/platform/ios.js.map +1 -0
  54. package/lib/commonjs/platform/streams.js +273 -0
  55. package/lib/commonjs/platform/streams.js.map +1 -0
  56. package/lib/commonjs/platform/timers.js +71 -0
  57. package/lib/commonjs/platform/timers.js.map +1 -0
  58. package/lib/commonjs/platform/visibility.js +60 -0
  59. package/lib/commonjs/platform/visibility.js.map +1 -0
  60. package/lib/commonjs/preflight.js +77 -0
  61. package/lib/commonjs/preflight.js.map +1 -0
  62. package/lib/commonjs/provider.js +75 -0
  63. package/lib/commonjs/provider.js.map +1 -0
  64. package/lib/commonjs/specs/NativeVoltkitRtc.js +29 -0
  65. package/lib/commonjs/specs/NativeVoltkitRtc.js.map +1 -0
  66. package/lib/module/audio.js +207 -0
  67. package/lib/module/audio.js.map +1 -0
  68. package/lib/module/components.js +200 -0
  69. package/lib/module/components.js.map +1 -0
  70. package/lib/module/create-client.js +42 -0
  71. package/lib/module/create-client.js.map +1 -0
  72. package/lib/module/errors.js +87 -0
  73. package/lib/module/errors.js.map +1 -0
  74. package/lib/module/globals.js +53 -0
  75. package/lib/module/globals.js.map +1 -0
  76. package/lib/module/index.js +33 -0
  77. package/lib/module/index.js.map +1 -0
  78. package/lib/module/local-media.js +387 -0
  79. package/lib/module/local-media.js.map +1 -0
  80. package/lib/module/native-session.js +360 -0
  81. package/lib/module/native-session.js.map +1 -0
  82. package/lib/module/native.js +87 -0
  83. package/lib/module/native.js.map +1 -0
  84. package/lib/module/package.json +1 -0
  85. package/lib/module/permissions.js +160 -0
  86. package/lib/module/permissions.js.map +1 -0
  87. package/lib/module/platform/android.js +122 -0
  88. package/lib/module/platform/android.js.map +1 -0
  89. package/lib/module/platform/common.js +128 -0
  90. package/lib/module/platform/common.js.map +1 -0
  91. package/lib/module/platform/devices.js +142 -0
  92. package/lib/module/platform/devices.js.map +1 -0
  93. package/lib/module/platform/index.js +24 -0
  94. package/lib/module/platform/index.js.map +1 -0
  95. package/lib/module/platform/ios.js +49 -0
  96. package/lib/module/platform/ios.js.map +1 -0
  97. package/lib/module/platform/streams.js +268 -0
  98. package/lib/module/platform/streams.js.map +1 -0
  99. package/lib/module/platform/timers.js +67 -0
  100. package/lib/module/platform/timers.js.map +1 -0
  101. package/lib/module/platform/visibility.js +54 -0
  102. package/lib/module/platform/visibility.js.map +1 -0
  103. package/lib/module/preflight.js +72 -0
  104. package/lib/module/preflight.js.map +1 -0
  105. package/lib/module/provider.js +70 -0
  106. package/lib/module/provider.js.map +1 -0
  107. package/lib/module/specs/NativeVoltkitRtc.js +37 -0
  108. package/lib/module/specs/NativeVoltkitRtc.js.map +1 -0
  109. package/lib/typescript/commonjs/audio.d.ts +58 -0
  110. package/lib/typescript/commonjs/audio.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/components.d.ts +58 -0
  112. package/lib/typescript/commonjs/components.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/create-client.d.ts +35 -0
  114. package/lib/typescript/commonjs/create-client.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/errors.d.ts +34 -0
  116. package/lib/typescript/commonjs/errors.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/globals.d.ts +16 -0
  118. package/lib/typescript/commonjs/globals.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/index.d.ts +24 -0
  120. package/lib/typescript/commonjs/index.d.ts.map +1 -0
  121. package/lib/typescript/commonjs/local-media.d.ts +86 -0
  122. package/lib/typescript/commonjs/local-media.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/native-session.d.ts +38 -0
  124. package/lib/typescript/commonjs/native-session.d.ts.map +1 -0
  125. package/lib/typescript/commonjs/native.d.ts +18 -0
  126. package/lib/typescript/commonjs/native.d.ts.map +1 -0
  127. package/lib/typescript/commonjs/package.json +1 -0
  128. package/lib/typescript/commonjs/permissions.d.ts +30 -0
  129. package/lib/typescript/commonjs/permissions.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/platform/android.d.ts +4 -0
  131. package/lib/typescript/commonjs/platform/android.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/platform/common.d.ts +101 -0
  133. package/lib/typescript/commonjs/platform/common.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/platform/devices.d.ts +42 -0
  135. package/lib/typescript/commonjs/platform/devices.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/platform/index.d.ts +6 -0
  137. package/lib/typescript/commonjs/platform/index.d.ts.map +1 -0
  138. package/lib/typescript/commonjs/platform/ios.d.ts +4 -0
  139. package/lib/typescript/commonjs/platform/ios.d.ts.map +1 -0
  140. package/lib/typescript/commonjs/platform/streams.d.ts +82 -0
  141. package/lib/typescript/commonjs/platform/streams.d.ts.map +1 -0
  142. package/lib/typescript/commonjs/platform/timers.d.ts +19 -0
  143. package/lib/typescript/commonjs/platform/timers.d.ts.map +1 -0
  144. package/lib/typescript/commonjs/platform/visibility.d.ts +11 -0
  145. package/lib/typescript/commonjs/platform/visibility.d.ts.map +1 -0
  146. package/lib/typescript/commonjs/preflight.d.ts +19 -0
  147. package/lib/typescript/commonjs/preflight.d.ts.map +1 -0
  148. package/lib/typescript/commonjs/provider.d.ts +35 -0
  149. package/lib/typescript/commonjs/provider.d.ts.map +1 -0
  150. package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts +157 -0
  151. package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts.map +1 -0
  152. package/lib/typescript/module/audio.d.ts +58 -0
  153. package/lib/typescript/module/audio.d.ts.map +1 -0
  154. package/lib/typescript/module/components.d.ts +58 -0
  155. package/lib/typescript/module/components.d.ts.map +1 -0
  156. package/lib/typescript/module/create-client.d.ts +35 -0
  157. package/lib/typescript/module/create-client.d.ts.map +1 -0
  158. package/lib/typescript/module/errors.d.ts +34 -0
  159. package/lib/typescript/module/errors.d.ts.map +1 -0
  160. package/lib/typescript/module/globals.d.ts +16 -0
  161. package/lib/typescript/module/globals.d.ts.map +1 -0
  162. package/lib/typescript/module/index.d.ts +24 -0
  163. package/lib/typescript/module/index.d.ts.map +1 -0
  164. package/lib/typescript/module/local-media.d.ts +86 -0
  165. package/lib/typescript/module/local-media.d.ts.map +1 -0
  166. package/lib/typescript/module/native-session.d.ts +38 -0
  167. package/lib/typescript/module/native-session.d.ts.map +1 -0
  168. package/lib/typescript/module/native.d.ts +18 -0
  169. package/lib/typescript/module/native.d.ts.map +1 -0
  170. package/lib/typescript/module/package.json +1 -0
  171. package/lib/typescript/module/permissions.d.ts +30 -0
  172. package/lib/typescript/module/permissions.d.ts.map +1 -0
  173. package/lib/typescript/module/platform/android.d.ts +4 -0
  174. package/lib/typescript/module/platform/android.d.ts.map +1 -0
  175. package/lib/typescript/module/platform/common.d.ts +101 -0
  176. package/lib/typescript/module/platform/common.d.ts.map +1 -0
  177. package/lib/typescript/module/platform/devices.d.ts +42 -0
  178. package/lib/typescript/module/platform/devices.d.ts.map +1 -0
  179. package/lib/typescript/module/platform/index.d.ts +6 -0
  180. package/lib/typescript/module/platform/index.d.ts.map +1 -0
  181. package/lib/typescript/module/platform/ios.d.ts +4 -0
  182. package/lib/typescript/module/platform/ios.d.ts.map +1 -0
  183. package/lib/typescript/module/platform/streams.d.ts +82 -0
  184. package/lib/typescript/module/platform/streams.d.ts.map +1 -0
  185. package/lib/typescript/module/platform/timers.d.ts +19 -0
  186. package/lib/typescript/module/platform/timers.d.ts.map +1 -0
  187. package/lib/typescript/module/platform/visibility.d.ts +11 -0
  188. package/lib/typescript/module/platform/visibility.d.ts.map +1 -0
  189. package/lib/typescript/module/preflight.d.ts +19 -0
  190. package/lib/typescript/module/preflight.d.ts.map +1 -0
  191. package/lib/typescript/module/provider.d.ts +35 -0
  192. package/lib/typescript/module/provider.d.ts.map +1 -0
  193. package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts +157 -0
  194. package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts.map +1 -0
  195. package/package.json +140 -4
  196. package/plugin/build/android.d.ts +36 -0
  197. package/plugin/build/android.js +265 -0
  198. package/plugin/build/index.d.ts +9 -0
  199. package/plugin/build/index.js +54 -0
  200. package/plugin/build/ios.d.ts +4 -0
  201. package/plugin/build/ios.js +43 -0
  202. package/plugin/build/options.d.ts +93 -0
  203. package/plugin/build/options.js +140 -0
  204. package/react-native.config.js +11 -0
  205. package/src/audio.ts +221 -0
  206. package/src/components.tsx +233 -0
  207. package/src/create-client.ts +53 -0
  208. package/src/errors.ts +81 -0
  209. package/src/globals.ts +54 -0
  210. package/src/index.ts +95 -0
  211. package/src/local-media.ts +420 -0
  212. package/src/native-session.tsx +384 -0
  213. package/src/native.ts +84 -0
  214. package/src/permissions.ts +173 -0
  215. package/src/platform/android.ts +128 -0
  216. package/src/platform/common.ts +157 -0
  217. package/src/platform/devices.ts +148 -0
  218. package/src/platform/index.ts +22 -0
  219. package/src/platform/ios.ts +48 -0
  220. package/src/platform/streams.ts +285 -0
  221. package/src/platform/timers.ts +72 -0
  222. package/src/platform/visibility.ts +59 -0
  223. package/src/preflight.ts +60 -0
  224. package/src/provider.tsx +88 -0
  225. package/src/specs/NativeVoltkitRtc.ts +180 -0
@@ -0,0 +1,487 @@
1
+ package com.voltkit.rtc.reactnative
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.BroadcastReceiver
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.IntentFilter
8
+ import android.media.AudioAttributes
9
+ import android.media.AudioDeviceCallback
10
+ import android.media.AudioDeviceInfo
11
+ import android.media.AudioFocusRequest
12
+ import android.media.AudioManager
13
+ import android.os.Build
14
+ import android.os.Handler
15
+ import android.os.HandlerThread
16
+ import android.os.Looper
17
+ import android.util.Log
18
+ import java.util.concurrent.CopyOnWriteArraySet
19
+
20
+ /**
21
+ * The device's audio for calls: the audio mode, audio focus and where sound plays, shared by every client in the
22
+ * process because `AudioManager` is process-wide. Each connected client that manages audio holds it; the last to let
23
+ * go puts the device back as it found it. The routing rules are those of Twilio's AudioSwitch, which LiveKit and
24
+ * flutter_webrtc use: a selected device while it is connected, else a Bluetooth headset, else a wired or USB headset,
25
+ * else the loudspeaker or the earpiece as preferred. Everything runs on its own thread. No Bluetooth permission is
26
+ * needed (communication devices and SCO are AudioManager's).
27
+ *
28
+ * A line-by-line port of the Android SDK's AudioRouter (android-sdk/voltkit-rtc/src/main/kotlin/com/voltkit/rtc/
29
+ * AudioRouter.kt), React Native's differences being only these:
30
+ * - a holder is a token (one per JavaScript client, `audioAcquire`), and listening is separate: the module listens once;
31
+ * - answers come back through callbacks (the module's promises), not coroutines;
32
+ * - audio focus changes are told to the listeners, so a phone call can be reported as taking the microphone;
33
+ * - the speaker preference given at acquire may be null, keeping the one `setSpeakerphoneOn` set before any client.
34
+ */
35
+ internal class AudioRouter private constructor(context: Context, private val mode: AudioMode) {
36
+ interface Listener {
37
+ fun onDevicesChanged(change: DevicesChanged)
38
+ fun onOutputChanged(device: MediaDevice?)
39
+ fun onFocusChanged(focus: String)
40
+ }
41
+
42
+ private class Saved(val mode: Int, val speakerphone: Boolean, val micMute: Boolean)
43
+
44
+ private val app = context.applicationContext
45
+ private val am = app.getSystemService(Context.AUDIO_SERVICE) as AudioManager
46
+ private val thread = HandlerThread("VoltKitAudio").apply { start() }
47
+ private val handler = Handler(thread.looper)
48
+ private val listeners = CopyOnWriteArraySet<Listener>()
49
+
50
+ // Touched only on the router thread.
51
+ private val holders = HashSet<Any>()
52
+ private var saved: Saved? = null
53
+ private var focusRequest: AudioFocusRequest? = null
54
+ private val choice = OutputChoice()
55
+ private val sco = ScoLink()
56
+ private var watching = false
57
+ private var scanned = false
58
+ private val infos = HashMap<String, AudioDeviceInfo>()
59
+
60
+ /** Every audio device now; refreshed as they come and go while held. */
61
+ @Volatile
62
+ var devices: List<MediaDevice> = emptyList()
63
+ private set
64
+
65
+ /** Where call audio plays now, when the SDK routes it. */
66
+ @Volatile
67
+ var output: MediaDevice? = null
68
+ private set
69
+
70
+ val isRouting: Boolean get() = mode == AudioMode.communication
71
+
72
+ fun addListener(listener: Listener) {
73
+ listeners.add(listener)
74
+ }
75
+
76
+ fun removeListener(listener: Listener) {
77
+ listeners.remove(listener)
78
+ }
79
+
80
+ // --- the clients' side ------------------------------------------------------------------------
81
+
82
+ /**
83
+ * A client connected and manages audio: hold the call audio set up while it is. [speakerphone] sets the preference
84
+ * (null keeps it). [done] runs on the router thread once the audio is set up, with where it plays.
85
+ */
86
+ fun acquire(holder: Any, speakerphone: Boolean?, done: (MediaDevice?) -> Unit = {}) = post {
87
+ if (holders.add(holder)) {
88
+ if (speakerphone != null) choice.speakerPreferred = speakerphone
89
+ if (holders.size == 1) activate() else refresh(route = true)
90
+ }
91
+ done(output)
92
+ }
93
+
94
+ /** That client left; the last one out restores the device's audio. */
95
+ fun release(holder: Any) = post {
96
+ if (!holders.remove(holder)) return@post
97
+ if (holders.isEmpty()) deactivate()
98
+ }
99
+
100
+ /** Loudspeaker (true) or earpiece (false) when no headset is connected; a headset still wins. */
101
+ fun setSpeakerphoneOn(on: Boolean) = post {
102
+ choice.preferSpeaker(on)
103
+ if (holders.isNotEmpty()) refresh(route = true)
104
+ }
105
+
106
+ /** Play call audio on [deviceId] while it stays connected. False when there is no such output. */
107
+ fun selectOutput(deviceId: String, done: (Result<Boolean>) -> Unit) = call(done) {
108
+ if (devices.isEmpty() || devices.none { it.deviceId == deviceId }) refresh(route = false)
109
+ if (devices.none { it.kind == DeviceKind.audiooutput && it.deviceId == deviceId }) return@call false
110
+ choice.pick(deviceId)
111
+ if (holders.isNotEmpty()) refresh(route = true)
112
+ true
113
+ }
114
+
115
+ /** The microphone to record from; null when there is no such input. */
116
+ fun inputInfo(deviceId: String, done: (Result<AudioDeviceInfo?>) -> Unit) = call(done) {
117
+ if (devices.none { it.deviceId == deviceId }) refresh(route = false)
118
+ infos[deviceId]?.takeIf { devices.any { d -> d.kind == DeviceKind.audioinput && d.deviceId == deviceId } }
119
+ }
120
+
121
+ /** The audio devices now (scanned on demand when nobody holds the router). */
122
+ fun list(done: (Result<List<MediaDevice>>) -> Unit) = call(done) {
123
+ if (holders.isEmpty() || devices.isEmpty()) refresh(route = false)
124
+ devices
125
+ }
126
+
127
+ // --- activation -------------------------------------------------------------------------------
128
+
129
+ private fun activate() {
130
+ saved = Saved(am.mode, isSpeakerphoneOnCompat(), am.isMicrophoneMute)
131
+ requestFocus()
132
+ if (mode == AudioMode.communication) {
133
+ try {
134
+ am.mode = AudioManager.MODE_IN_COMMUNICATION
135
+ } catch (t: Throwable) {
136
+ Log.w(TAG, "could not set the call audio mode: ${t.message}")
137
+ }
138
+ }
139
+ // Another app may have left the microphone muted for the whole device.
140
+ if (am.isMicrophoneMute) am.isMicrophoneMute = false
141
+ watch(true)
142
+ refresh(route = true)
143
+ }
144
+
145
+ private fun deactivate() {
146
+ watch(false)
147
+ stopSco()
148
+ if (mode == AudioMode.communication && Build.VERSION.SDK_INT >= 31) {
149
+ runCatching { am.clearCommunicationDevice() }
150
+ }
151
+ saved?.let { s ->
152
+ runCatching { am.mode = s.mode }
153
+ if (Build.VERSION.SDK_INT < 31) setSpeakerphoneCompat(s.speakerphone)
154
+ runCatching { am.isMicrophoneMute = s.micMute }
155
+ }
156
+ saved = null
157
+ abandonFocus()
158
+ choice.reset()
159
+ setOutput(null)
160
+ }
161
+
162
+ private fun requestFocus() {
163
+ val usage = if (mode == AudioMode.communication) AudioAttributes.USAGE_VOICE_COMMUNICATION else AudioAttributes.USAGE_MEDIA
164
+ val content = if (mode == AudioMode.communication) AudioAttributes.CONTENT_TYPE_SPEECH else AudioAttributes.CONTENT_TYPE_MUSIC
165
+ try {
166
+ if (Build.VERSION.SDK_INT >= 26) {
167
+ val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
168
+ .setAudioAttributes(AudioAttributes.Builder().setUsage(usage).setContentType(content).build())
169
+ .setAcceptsDelayedFocusGain(false)
170
+ .setOnAudioFocusChangeListener(focusListener, handler)
171
+ .build()
172
+ focusRequest = request
173
+ am.requestAudioFocus(request)
174
+ } else {
175
+ @Suppress("DEPRECATION")
176
+ am.requestAudioFocus(
177
+ focusListener,
178
+ if (mode == AudioMode.communication) AudioManager.STREAM_VOICE_CALL else AudioManager.STREAM_MUSIC,
179
+ AudioManager.AUDIOFOCUS_GAIN,
180
+ )
181
+ }
182
+ } catch (t: Throwable) {
183
+ Log.w(TAG, "audio focus request failed: ${t.message}")
184
+ }
185
+ }
186
+
187
+ private fun abandonFocus() {
188
+ try {
189
+ if (Build.VERSION.SDK_INT >= 26) {
190
+ focusRequest?.let { am.abandonAudioFocusRequest(it) }
191
+ } else {
192
+ @Suppress("DEPRECATION")
193
+ am.abandonAudioFocus(focusListener)
194
+ }
195
+ } catch (_: Throwable) {
196
+ }
197
+ focusRequest = null
198
+ }
199
+
200
+ // A call keeps its audio when a notification or another app's sound takes focus: nothing to change here. The
201
+ // listeners hear it, so a phone call taking the microphone can be reported.
202
+ private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
203
+ Log.d(TAG, "audio focus changed: $change")
204
+ val focus = focusName(change) ?: return@OnAudioFocusChangeListener
205
+ listeners.forEach { runCatching { it.onFocusChanged(focus) } }
206
+ }
207
+
208
+ // --- watching devices -------------------------------------------------------------------------
209
+
210
+ private val deviceCallback = object : AudioDeviceCallback() {
211
+ override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
212
+ choice.devicesAdded()
213
+ refresh(route = holders.isNotEmpty())
214
+ }
215
+
216
+ override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) = refresh(route = holders.isNotEmpty())
217
+ }
218
+
219
+ private val commDeviceListener: Any? =
220
+ if (Build.VERSION.SDK_INT >= 31) {
221
+ AudioManager.OnCommunicationDeviceChangedListener { info ->
222
+ handler.post { if (holders.isNotEmpty()) setOutput(info?.let { outputOf(it) }) }
223
+ }
224
+ } else {
225
+ null
226
+ }
227
+
228
+ private val scoReceiver = object : BroadcastReceiver() {
229
+ override fun onReceive(context: Context, intent: Intent) {
230
+ // The broadcast is sticky: registering replays the last call's state
231
+ // (usually DISCONNECTED), which says nothing about the link just asked for.
232
+ if (isInitialStickyBroadcast) return
233
+ val state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR)
234
+ if (sco.failed(state)) scoFailed()
235
+ }
236
+ }
237
+
238
+ /** The headset refused the call link: route elsewhere until devices change. */
239
+ private fun scoFailed() {
240
+ choice.refusedBy(output?.deviceId)
241
+ Log.w(TAG, "the Bluetooth headset did not open its call audio; routing elsewhere")
242
+ stopSco()
243
+ refresh(route = holders.isNotEmpty())
244
+ }
245
+
246
+ @SuppressLint("UnspecifiedRegisterReceiverFlag")
247
+ private fun watch(on: Boolean) {
248
+ if (on == watching) return
249
+ watching = on
250
+ try {
251
+ if (on) {
252
+ am.registerAudioDeviceCallback(deviceCallback, handler)
253
+ // Only call audio has a communication device. In media mode sound follows the system's media
254
+ // routing, and the call's device (often the earpiece) is not where it plays (SR-3).
255
+ if (Build.VERSION.SDK_INT >= 31 && mode == AudioMode.communication) {
256
+ am.addOnCommunicationDeviceChangedListener(
257
+ { r -> handler.post(r) },
258
+ commDeviceListener as AudioManager.OnCommunicationDeviceChangedListener,
259
+ )
260
+ } else if (Build.VERSION.SDK_INT < 31 && mode == AudioMode.communication) {
261
+ app.registerReceiver(scoReceiver, IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED), null, handler)
262
+ }
263
+ } else {
264
+ am.unregisterAudioDeviceCallback(deviceCallback)
265
+ if (Build.VERSION.SDK_INT >= 31 && mode == AudioMode.communication) {
266
+ am.removeOnCommunicationDeviceChangedListener(commDeviceListener as AudioManager.OnCommunicationDeviceChangedListener)
267
+ } else if (Build.VERSION.SDK_INT < 31 && mode == AudioMode.communication) {
268
+ runCatching { app.unregisterReceiver(scoReceiver) }
269
+ }
270
+ }
271
+ } catch (t: Throwable) {
272
+ Log.w(TAG, "audio device watch ${if (on) "start" else "stop"} failed: ${t.message}")
273
+ }
274
+ }
275
+
276
+ // --- the device list and routing ---------------------------------------------------------------
277
+
278
+ private fun refresh(route: Boolean) {
279
+ val before = devices
280
+ val now = scan()
281
+ devices = now
282
+ // The first scan is the starting list, not a change.
283
+ val first = !scanned
284
+ scanned = true
285
+ if (!first && before != now) {
286
+ val change = DevicesChanged.between(before, now)
287
+ if (change.added.isNotEmpty() || change.removed.isNotEmpty()) listeners.forEach { runCatching { it.onDevicesChanged(change) } }
288
+ }
289
+ val outputs = now.filter { it.kind == DeviceKind.audiooutput }
290
+ choice.outputsNow(outputs)
291
+ if (route && holders.isNotEmpty()) applyRoute(choice.choose(outputs))
292
+ }
293
+
294
+ private fun scan(): List<MediaDevice> {
295
+ infos.clear()
296
+ val outs: List<AudioDeviceInfo> = try {
297
+ if (Build.VERSION.SDK_INT >= 31 && mode == AudioMode.communication) {
298
+ am.availableCommunicationDevices
299
+ } else {
300
+ am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
301
+ }
302
+ } catch (_: Throwable) {
303
+ emptyList()
304
+ }
305
+ val ins: List<AudioDeviceInfo> = try {
306
+ am.getDevices(AudioManager.GET_DEVICES_INPUTS).toList()
307
+ } catch (_: Throwable) {
308
+ emptyList()
309
+ }
310
+ val outputs = LinkedHashMap<String, MediaDevice>()
311
+ for (info in outs) {
312
+ val d = outputOf(info) ?: continue
313
+ if (d.deviceId !in outputs) {
314
+ outputs[d.deviceId] = d
315
+ infos[d.deviceId] = info
316
+ }
317
+ }
318
+ // Before Android 12 the earpiece cannot be chosen over a wired headset: it is not offered then.
319
+ if (Build.VERSION.SDK_INT < 31 && outputs.values.any { it.type == AudioDeviceType.wiredHeadset }) outputs.remove(EARPIECE)
320
+ val inputs = LinkedHashMap<String, MediaDevice>()
321
+ for (info in ins) {
322
+ val d = inputOf(info) ?: continue
323
+ if (d.deviceId !in inputs) {
324
+ inputs[d.deviceId] = d
325
+ infos[d.deviceId] = info
326
+ }
327
+ }
328
+ val order = listOf(AudioDeviceType.bluetooth, AudioDeviceType.wiredHeadset, AudioDeviceType.usb, AudioDeviceType.earpiece, AudioDeviceType.speaker)
329
+ return outputs.values.sortedBy { order.indexOf(it.type).let { i -> if (i < 0) order.size else i } } + inputs.values
330
+ }
331
+
332
+ private fun outputOf(info: AudioDeviceInfo): MediaDevice? {
333
+ val name = info.productName?.toString()?.trim().orEmpty()
334
+ return when (info.type) {
335
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> MediaDevice(SPEAKER, DeviceKind.audiooutput, "Speaker", AudioDeviceType.speaker)
336
+ AudioDeviceInfo.TYPE_BUILTIN_EARPIECE ->
337
+ if (mode == AudioMode.communication) MediaDevice(EARPIECE, DeviceKind.audiooutput, "Phone", AudioDeviceType.earpiece) else null
338
+ AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_WIRED_HEADPHONES ->
339
+ MediaDevice("wired", DeviceKind.audiooutput, "Wired headset", AudioDeviceType.wiredHeadset, groupId = "wired")
340
+ AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_DEVICE ->
341
+ MediaDevice("usb:${info.id}", DeviceKind.audiooutput, name.ifEmpty { "USB audio" }, AudioDeviceType.usb, groupId = "usb:$name")
342
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO ->
343
+ if (mode == AudioMode.communication) bluetooth(info, name) else null
344
+ // In media mode a Bluetooth headset plays in its high-quality (A2DP) profile.
345
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ->
346
+ if (mode == AudioMode.media) bluetooth(info, name) else null
347
+ else -> when {
348
+ Build.VERSION.SDK_INT >= 31 && (info.type == AudioDeviceInfo.TYPE_BLE_HEADSET || info.type == AudioDeviceInfo.TYPE_BLE_SPEAKER) -> bluetooth(info, name)
349
+ Build.VERSION.SDK_INT >= 28 && info.type == AudioDeviceInfo.TYPE_HEARING_AID -> bluetooth(info, name.ifEmpty { "Hearing aid" })
350
+ else -> null
351
+ }
352
+ }
353
+ }
354
+
355
+ private fun bluetooth(info: AudioDeviceInfo, name: String) =
356
+ MediaDevice("bluetooth:${info.id}", DeviceKind.audiooutput, name.ifEmpty { "Bluetooth" }, AudioDeviceType.bluetooth, groupId = "bluetooth:$name")
357
+
358
+ private fun inputOf(info: AudioDeviceInfo): MediaDevice? {
359
+ val name = info.productName?.toString()?.trim().orEmpty()
360
+ return when (info.type) {
361
+ AudioDeviceInfo.TYPE_BUILTIN_MIC -> MediaDevice(MIC, DeviceKind.audioinput, "Phone microphone", AudioDeviceType.builtinMic)
362
+ AudioDeviceInfo.TYPE_WIRED_HEADSET ->
363
+ MediaDevice("wired-mic", DeviceKind.audioinput, "Headset microphone", AudioDeviceType.wiredHeadset, groupId = "wired")
364
+ AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_DEVICE ->
365
+ MediaDevice("usb-mic:${info.id}", DeviceKind.audioinput, name.ifEmpty { "USB microphone" }, AudioDeviceType.usb, groupId = "usb:$name")
366
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO ->
367
+ MediaDevice("bluetooth-mic:${info.id}", DeviceKind.audioinput, name.ifEmpty { "Bluetooth" }, AudioDeviceType.bluetooth, groupId = "bluetooth:$name")
368
+ else -> if (Build.VERSION.SDK_INT >= 31 && info.type == AudioDeviceInfo.TYPE_BLE_HEADSET) {
369
+ MediaDevice("bluetooth-mic:${info.id}", DeviceKind.audioinput, name.ifEmpty { "Bluetooth" }, AudioDeviceType.bluetooth, groupId = "bluetooth:$name")
370
+ } else {
371
+ null
372
+ }
373
+ }
374
+ }
375
+
376
+ private fun applyRoute(target: MediaDevice?) {
377
+ if (target == null) return
378
+ if (mode != AudioMode.communication) {
379
+ // Media playback follows the system's own routing.
380
+ setOutput(target)
381
+ return
382
+ }
383
+ try {
384
+ if (Build.VERSION.SDK_INT >= 31) {
385
+ val info = infos[target.deviceId] ?: return
386
+ if (am.communicationDevice?.id != info.id && !am.setCommunicationDevice(info)) {
387
+ Log.w(TAG, "could not route call audio to ${target.label}")
388
+ return
389
+ }
390
+ } else {
391
+ when (target.type) {
392
+ AudioDeviceType.bluetooth -> {
393
+ setSpeakerphoneCompat(false)
394
+ startSco()
395
+ }
396
+ AudioDeviceType.speaker -> {
397
+ stopSco()
398
+ setSpeakerphoneCompat(true)
399
+ }
400
+ else -> {
401
+ stopSco()
402
+ setSpeakerphoneCompat(false)
403
+ }
404
+ }
405
+ }
406
+ } catch (t: Throwable) {
407
+ Log.w(TAG, "routing call audio to ${target.label} failed: ${t.message}")
408
+ return
409
+ }
410
+ setOutput(target)
411
+ }
412
+
413
+ @Suppress("DEPRECATION")
414
+ private fun startSco() {
415
+ if (sco.wanted) return
416
+ val start = sco.start()
417
+ runCatching {
418
+ am.startBluetoothSco()
419
+ am.isBluetoothScoOn = true
420
+ }.onFailure { Log.w(TAG, "Bluetooth call audio could not start: ${it.message}") }
421
+ // A refusal can say only DISCONNECTED, which ScoLink takes for the link stopped before: silence is a refusal too.
422
+ handler.postDelayed({ if (sco.silent(start)) scoFailed() }, ScoLink.START_TIMEOUT_MILLIS)
423
+ }
424
+
425
+ @Suppress("DEPRECATION")
426
+ private fun stopSco() {
427
+ if (!sco.wanted) return
428
+ sco.stop()
429
+ runCatching {
430
+ am.isBluetoothScoOn = false
431
+ am.stopBluetoothSco()
432
+ }
433
+ }
434
+
435
+ @Suppress("DEPRECATION")
436
+ private fun isSpeakerphoneOnCompat(): Boolean = runCatching { am.isSpeakerphoneOn }.getOrDefault(false)
437
+
438
+ @Suppress("DEPRECATION")
439
+ private fun setSpeakerphoneCompat(on: Boolean) {
440
+ runCatching { if (am.isSpeakerphoneOn != on) am.isSpeakerphoneOn = on }
441
+ }
442
+
443
+ private fun setOutput(device: MediaDevice?) {
444
+ if (output == device) return
445
+ output = device
446
+ listeners.forEach { runCatching { it.onOutputChanged(device) } }
447
+ }
448
+
449
+ // --- threading --------------------------------------------------------------------------------
450
+
451
+ private fun post(block: () -> Unit) {
452
+ if (Looper.myLooper() == thread.looper) block() else handler.post(block)
453
+ }
454
+
455
+ /** Run [block] on the router thread and hand its result (or what it threw) to [done], on that thread. */
456
+ private fun <T> call(done: (Result<T>) -> Unit, block: () -> T) {
457
+ val run = Runnable { done(runCatching(block)) }
458
+ if (Looper.myLooper() == thread.looper) run.run() else handler.post(run)
459
+ }
460
+
461
+ companion object {
462
+ private const val TAG = "VoltKitRtc"
463
+ const val SPEAKER = "speaker"
464
+ const val EARPIECE = "earpiece"
465
+ const val MIC = "mic"
466
+
467
+ @Volatile
468
+ private var shared: AudioRouter? = null
469
+
470
+ /** The process's router. Its audio mode is the process's ([RtcConfig.audioMode]): the first caller's wins. */
471
+ fun get(context: Context, mode: AudioMode): AudioRouter =
472
+ shared ?: synchronized(this) { shared ?: AudioRouter(context.applicationContext, mode).also { shared = it } }
473
+
474
+ /** The router, if one was made. */
475
+ fun existing(): AudioRouter? = shared
476
+
477
+ /** AudioManager.AUDIOFOCUS_* by the names JavaScript receives. */
478
+ fun focusName(change: Int): String? = when (change) {
479
+ AudioManager.AUDIOFOCUS_GAIN, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
480
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> "gain"
481
+ AudioManager.AUDIOFOCUS_LOSS -> "loss"
482
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> "lossTransient"
483
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> "lossTransientCanDuck"
484
+ else -> null
485
+ }
486
+ }
487
+ }
@@ -0,0 +1,120 @@
1
+ package com.voltkit.rtc.reactnative
2
+
3
+ import android.Manifest
4
+ import android.content.pm.ServiceInfo
5
+
6
+ /**
7
+ * Which foreground service types the call service runs with (plan §5.4, PLAN_UPDATES 5): a pure function of what the
8
+ * app's manifest declares, what is granted, what the call publishes and the audio mode, so every Android version's
9
+ * rules are tested off a device (CallServiceTypesTest). Android's rules
10
+ * (developer.android.com/develop/background-work/services/fgs/service-types):
11
+ * - API 28+: FOREGROUND_SERVICE, for any foreground service.
12
+ * - API 29: the types exist, but microphone and camera only come in API 30.
13
+ * - API 30+: a service started while the app is visible keeps the microphone (camera) in the background only with the
14
+ * microphone (camera) type.
15
+ * - API 34+: each type needs its FOREGROUND_SERVICE_<TYPE> permission; microphone needs RECORD_AUDIO granted, camera
16
+ * CAMERA; mediaProjection the user's consent to a share; and a service must name at least one type.
17
+ *
18
+ * The choice: microphone when RECORD_AUDIO is granted, otherwise mediaPlayback (a listener); a listen-only app (media
19
+ * mode) plays media unless it publishes the microphone. Camera only when the app keeps the camera in the background
20
+ * (pauseCamera false) and publishes it. mediaProjection only while a share is live. The types only ever widen while
21
+ * the app is visible: the JavaScript side starts and updates the service then.
22
+ */
23
+ internal object CallServiceTypes {
24
+ const val MEDIA_PLAYBACK = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
25
+ const val MEDIA_PROJECTION = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
26
+ const val CAMERA = ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
27
+ const val MICROPHONE = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
28
+
29
+ data class Input(
30
+ val sdkInt: Int,
31
+ /** The service's android:foregroundServiceType (0 below API 29, or when it declares none). */
32
+ val declaredTypes: Int,
33
+ /** The permissions granted now: runtime ones the user allowed, and normal ones the manifest declares. */
34
+ val granted: Set<String>,
35
+ val audioMode: AudioMode,
36
+ val micPublished: Boolean,
37
+ val cameraPublished: Boolean,
38
+ /** The provider's background.pauseCamera: the camera stops in the background, so no camera type. */
39
+ val pauseCamera: Boolean,
40
+ /** A screen share is live. */
41
+ val sharing: Boolean,
42
+ )
43
+
44
+ data class Decision(
45
+ /** For startForeground: 0 below API 29, where there are no types. */
46
+ val types: Int,
47
+ val canStart: Boolean,
48
+ /** Why it cannot start; null when it can. */
49
+ val reason: String?,
50
+ )
51
+
52
+ fun decide(input: Input): Decision {
53
+ val granted = input.granted
54
+ val sdk = input.sdkInt
55
+ if (sdk >= 28 && Manifest.permission.FOREGROUND_SERVICE !in granted) {
56
+ return Decision(0, false, "the app's manifest does not declare android.permission.FOREGROUND_SERVICE")
57
+ }
58
+ if (sdk < 29) return Decision(0, true, null)
59
+
60
+ fun declared(type: Int) = input.declaredTypes and type == type
61
+ fun permitted(permission: String) = sdk < 34 || permission in granted
62
+
63
+ val mic = sdk >= 30 && declared(MICROPHONE) && Manifest.permission.RECORD_AUDIO in granted &&
64
+ permitted(Manifest.permission.FOREGROUND_SERVICE_MICROPHONE)
65
+ val playback = declared(MEDIA_PLAYBACK) && permitted(Manifest.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK)
66
+ val camera = sdk >= 30 && !input.pauseCamera && input.cameraPublished && declared(CAMERA) &&
67
+ Manifest.permission.CAMERA in granted && permitted(Manifest.permission.FOREGROUND_SERVICE_CAMERA)
68
+ val projection = input.sharing && declared(MEDIA_PROJECTION) &&
69
+ permitted(Manifest.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION)
70
+
71
+ val audio = when (input.audioMode) {
72
+ AudioMode.communication -> if (mic) MICROPHONE else if (playback) MEDIA_PLAYBACK else 0
73
+ AudioMode.media -> if (mic && input.micPublished) MICROPHONE else if (playback) MEDIA_PLAYBACK else if (mic) MICROPHONE else 0
74
+ }
75
+ val types = audio or (if (camera) CAMERA else 0) or (if (projection) MEDIA_PROJECTION else 0)
76
+ // From Android 14 a foreground service must name a type; below, none keeps the process and its network.
77
+ if (types == 0 && sdk >= 34) return Decision(0, false, missing(input))
78
+ return Decision(types, true, null)
79
+ }
80
+
81
+ /** What keeps the service from running on Android 14 and later, in the words of the manifest. */
82
+ private fun missing(input: Input): String {
83
+ val granted = input.granted
84
+ val wantsMic = input.declaredTypes and MICROPHONE != 0
85
+ val wantsPlayback = input.declaredTypes and MEDIA_PLAYBACK != 0
86
+ if (!wantsMic && !wantsPlayback) {
87
+ return "VoltkitCallService's android:foregroundServiceType declares neither microphone nor mediaPlayback"
88
+ }
89
+ val needs = ArrayList<String>()
90
+ if (wantsMic) {
91
+ if (Manifest.permission.RECORD_AUDIO !in granted) needs += "android.permission.RECORD_AUDIO granted"
92
+ if (Manifest.permission.FOREGROUND_SERVICE_MICROPHONE !in granted) needs += "android.permission.FOREGROUND_SERVICE_MICROPHONE declared"
93
+ }
94
+ if (wantsPlayback && Manifest.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK !in granted) {
95
+ needs += "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK declared"
96
+ }
97
+ return "the call service can run as neither microphone nor mediaPlayback; it needs " + needs.joinToString(" or ")
98
+ }
99
+
100
+ /** Every ServiceInfo.FOREGROUND_SERVICE_TYPE_* by the name the manifest gives it, lowest bit first. */
101
+ private val NAMES = listOf(
102
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC to "dataSync",
103
+ MEDIA_PLAYBACK to "mediaPlayback",
104
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL to "phoneCall",
105
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION to "location",
106
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE to "connectedDevice",
107
+ MEDIA_PROJECTION to "mediaProjection",
108
+ CAMERA to "camera",
109
+ MICROPHONE to "microphone",
110
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH to "health",
111
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING to "remoteMessaging",
112
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED to "systemExempted",
113
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE to "shortService",
114
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING to "mediaProcessing",
115
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE to "specialUse",
116
+ )
117
+
118
+ /** The manifest's names for the types in [mask]. */
119
+ fun names(mask: Int): List<String> = NAMES.filter { (type, _) -> mask and type == type && type != 0 }.map { it.second }
120
+ }