@stream-io/video-react-native-sdk 1.45.0 → 1.45.2

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 (55) hide show
  1. package/CHANGELOG.md +31 -7
  2. package/android/build.gradle +7 -1
  3. package/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt +110 -58
  4. package/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt +247 -0
  5. package/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +16 -5
  6. package/android/src/main/java/com/streamvideo/reactnative/keepalive/StreamCallKeepAliveHeadlessService.kt +112 -26
  7. package/android/src/main/java/com/streamvideo/reactnative/util/CallAlivePermissionsHelper.kt +68 -37
  8. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js +3 -1
  9. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  10. package/dist/commonjs/hooks/useAndroidKeepCallAliveEffect.js +38 -11
  11. package/dist/commonjs/hooks/useAndroidKeepCallAliveEffect.js.map +1 -1
  12. package/dist/commonjs/utils/StreamVideoRN/index.js +23 -0
  13. package/dist/commonjs/utils/StreamVideoRN/index.js.map +1 -1
  14. package/dist/commonjs/utils/internal/callingx/callingx.js +37 -55
  15. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  16. package/dist/commonjs/utils/keepCallAliveHeadlessTask.js +43 -4
  17. package/dist/commonjs/utils/keepCallAliveHeadlessTask.js.map +1 -1
  18. package/dist/commonjs/utils/push/internal/android.js +28 -10
  19. package/dist/commonjs/utils/push/internal/android.js.map +1 -1
  20. package/dist/commonjs/version.js +1 -1
  21. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js +3 -1
  22. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  23. package/dist/module/hooks/useAndroidKeepCallAliveEffect.js +39 -12
  24. package/dist/module/hooks/useAndroidKeepCallAliveEffect.js.map +1 -1
  25. package/dist/module/utils/StreamVideoRN/index.js +23 -0
  26. package/dist/module/utils/StreamVideoRN/index.js.map +1 -1
  27. package/dist/module/utils/internal/callingx/callingx.js +37 -55
  28. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  29. package/dist/module/utils/keepCallAliveHeadlessTask.js +41 -3
  30. package/dist/module/utils/keepCallAliveHeadlessTask.js.map +1 -1
  31. package/dist/module/utils/push/internal/android.js +28 -10
  32. package/dist/module/utils/push/internal/android.js.map +1 -1
  33. package/dist/module/version.js +1 -1
  34. package/dist/typescript/hooks/push/useCallingExpWithCallingStateEffect.d.ts.map +1 -1
  35. package/dist/typescript/hooks/useAndroidKeepCallAliveEffect.d.ts.map +1 -1
  36. package/dist/typescript/utils/StreamVideoRN/index.d.ts +13 -0
  37. package/dist/typescript/utils/StreamVideoRN/index.d.ts.map +1 -1
  38. package/dist/typescript/utils/StreamVideoRN/types.d.ts +1 -2
  39. package/dist/typescript/utils/StreamVideoRN/types.d.ts.map +1 -1
  40. package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
  41. package/dist/typescript/utils/keepCallAliveHeadlessTask.d.ts +9 -0
  42. package/dist/typescript/utils/keepCallAliveHeadlessTask.d.ts.map +1 -1
  43. package/dist/typescript/utils/push/internal/android.d.ts.map +1 -1
  44. package/dist/typescript/version.d.ts +1 -1
  45. package/expo-config-plugin/dist/withAndroidPermissions.js +1 -1
  46. package/package.json +6 -6
  47. package/src/hooks/push/useCallingExpWithCallingStateEffect.ts +13 -6
  48. package/src/hooks/useAndroidKeepCallAliveEffect.ts +59 -21
  49. package/src/modules/call-manager/native-module.d.ts +14 -0
  50. package/src/utils/StreamVideoRN/index.ts +27 -0
  51. package/src/utils/StreamVideoRN/types.ts +1 -2
  52. package/src/utils/internal/callingx/callingx.ts +47 -66
  53. package/src/utils/keepCallAliveHeadlessTask.ts +45 -3
  54. package/src/utils/push/internal/android.ts +31 -10
  55. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,20 +2,44 @@
2
2
 
3
3
  This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
4
4
 
5
+ ## [1.45.2](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-1.45.1...@stream-io/video-react-native-sdk-1.45.2) (2026-09-11)
6
+
7
+ ### Dependency Updates
8
+
9
+ - `@stream-io/noise-cancellation-react-native` updated to version `0.11.1`
10
+ - `@stream-io/react-native-callingx` updated to version `0.11.4`
11
+ - `@stream-io/video-filters-react-native` updated to version `0.16.1`
12
+ - `@stream-io/video-client` updated to version `1.60.0`
13
+ - **client:** identify the SDK and version on SFU RPCs ([#2425](https://github.com/GetStream/stream-video-js/issues/2425)) ([107ff39](https://github.com/GetStream/stream-video-js/commit/107ff390c706beca4417d4ef5e2c7ec9bbbc321d))
14
+ - **client:** poll ring state to reconcile a dropped ring outcome ([#2393](https://github.com/GetStream/stream-video-js/issues/2393)) ([4605ac1](https://github.com/GetStream/stream-video-js/commit/4605ac1daf603382b7d3a6267e4852ac9fbfc0eb)), closes [#2422](https://github.com/GetStream/stream-video-js/issues/2422)
15
+ - `@stream-io/video-react-bindings` updated to version `1.20.3`
16
+
17
+ ### Bug Fixes
18
+
19
+ - **android:** skip explicit Kotlin plugin when AGP registers the kotlin extension ([#2417](https://github.com/GetStream/stream-video-js/issues/2417)) ([6d7ae7f](https://github.com/GetStream/stream-video-js/commit/6d7ae7f15f33dafab5a017597462cba29547187f)), closes [RevenueCat/react-native-purchases#1934](https://github.com/RevenueCat/react-native-purchases/issues/1934)
20
+
21
+ ## [1.45.1](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-1.45.0...@stream-io/video-react-native-sdk-1.45.1) (2026-09-04)
22
+
23
+ ### Dependency Updates
24
+
25
+ - `@stream-io/react-native-callingx` updated to version `0.11.3`
26
+ - `@stream-io/video-client` updated to version `1.59.1`
27
+ - `@stream-io/video-react-bindings` updated to version `1.20.2`
28
+
29
+ ### Bug Fixes
30
+
31
+ - **callingx:** don't tear down concurrent calls on stop service ([#2394](https://github.com/GetStream/stream-video-js/issues/2394)) ([d6e9deb](https://github.com/GetStream/stream-video-js/commit/d6e9debbe98ae91cc349f010eb92f76b35a3f932))
32
+ - **rn:** auto-leave previous callingx calls on new join ([#2402](https://github.com/GetStream/stream-video-js/issues/2402)) ([5a3dfdd](https://github.com/GetStream/stream-video-js/commit/5a3dfddd8ddbfacf6e8ea109656be82da91a3436))
33
+ - **rn:** wrong expo plugin Android call-alive foreground service detection ([#2398](https://github.com/GetStream/stream-video-js/issues/2398)) ([eac10b5](https://github.com/GetStream/stream-video-js/commit/eac10b5a390f6949b25535d2b4c4cf9bc3d5f065))
34
+ - workaround for android non-telecom path MODE_IN_COMMUNICATION reset edge-case ([#2360](https://github.com/GetStream/stream-video-js/issues/2360)) ([65a1fb7](https://github.com/GetStream/stream-video-js/commit/65a1fb766461f95a3b70fe221e9ace0caad6c64b))
35
+
5
36
  ## [1.45.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-1.44.0...@stream-io/video-react-native-sdk-1.45.0) (2026-08-28)
6
37
 
7
38
  ### Dependency Updates
8
39
 
9
40
  - `@stream-io/react-native-callingx` updated to version `0.11.0`
10
41
  - `@stream-io/video-client` updated to version `1.59.0`
11
- - do not end callingx service without call leave ([#2396](https://github.com/GetStream/stream-video-js/issues/2396)) ([15d6abb](https://github.com/GetStream/stream-video-js/commit/15d6abbfa9f4cc2a47c01d8fc8204016a2f8a448))
12
- - **client:** keep devices disabled when querying calls ([#2382](https://github.com/GetStream/stream-video-js/issues/2382)) ([1c69755](https://github.com/GetStream/stream-video-js/commit/1c6975546dd70baf991410300fbf6de5ddfd9c65))
13
- - **client:** Prevent concurrent joins ([#2386](https://github.com/GetStream/stream-video-js/issues/2386)) ([92b2cf6](https://github.com/GetStream/stream-video-js/commit/92b2cf62a719ffb98156250eeffa706841c6781c))
14
- - **client:** refresh live call state on reconnect re-watch ([#2383](https://github.com/GetStream/stream-video-js/issues/2383)) ([d28067e](https://github.com/GetStream/stream-video-js/commit/d28067e543592e9ab8d8219fcaa9250d938dbc70))
15
- - **client:** use generated call reporting types and remove unused fields ([#2377](https://github.com/GetStream/stream-video-js/issues/2377)) ([2eab662](https://github.com/GetStream/stream-video-js/commit/2eab66293c41c8e1d6467f2536525511186862e2))
16
- - missing own capabilities for incoming call ([#2388](https://github.com/GetStream/stream-video-js/issues/2388)) ([0a04d34](https://github.com/GetStream/stream-video-js/commit/0a04d34a258d7ab3702348a1f929f87e62531875))
17
42
  - `@stream-io/video-react-bindings` updated to version `1.20.1`
18
- - emit per-module ESM builds for the React packages ([#2378](https://github.com/GetStream/stream-video-js/issues/2378)) ([9c937f4](https://github.com/GetStream/stream-video-js/commit/9c937f4b5845aa0e8108a218b1c84b7acb249d77))
19
43
 
20
44
  ### Features
21
45
 
@@ -19,7 +19,13 @@ def isNewArchitectureEnabled() {
19
19
  }
20
20
 
21
21
  apply plugin: "com.android.library"
22
- apply plugin: "kotlin-android"
22
+ // AGP 9 ships built-in Kotlin support and registers the `kotlin` extension
23
+ // itself. Applying the Kotlin plugin on top of it fails configuration with
24
+ // "Cannot add extension with name 'kotlin'". Only apply it when nothing has
25
+ // registered that extension yet.
26
+ if (project.extensions.findByName('kotlin') == null) {
27
+ apply plugin: "kotlin-android"
28
+ }
23
29
 
24
30
  if (isNewArchitectureEnabled()) {
25
31
  apply plugin: "com.facebook.react"
@@ -107,6 +107,30 @@ class AudioDeviceManager(
107
107
  */
108
108
  var telecomManagedMode: Boolean = false
109
109
 
110
+ /**
111
+ * Opt-out for the Android 11+ communication-mode keep-alive. Sticky developer preference —
112
+ * intentionally NOT reset in [stop], unlike the per-call fields above.
113
+ */
114
+ var disableCommunicationModeWorkaround: Boolean = false
115
+
116
+ /**
117
+ * True once [stop]/[close] have torn routing down, until the next [start].
118
+ *
119
+ * [updateAudioDeviceState] is enqueued from off-thread sources (Bluetooth receivers,
120
+ * audio-focus and device callbacks), so a task can still land after teardown. Re-routing
121
+ * then would re-assert MODE_IN_COMMUNICATION via AudioManagerUtil, undoing the
122
+ * MODE_NORMAL restore in [stop]. Audio-thread confined.
123
+ */
124
+ private var routingStopped = false
125
+
126
+ /** Keeps MODE_IN_COMMUNICATION owned for the whole Communicator call on Android 11+. */
127
+ private val communicationModeKeepAlive: CommunicationModeKeepAlive =
128
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
129
+ SilentPlaybackKeepAlive(mReactContext)
130
+ } else {
131
+ NoCommunicationModeKeepAlive
132
+ }
133
+
110
134
  val bluetoothManager = BluetoothManager(mReactContext, this)
111
135
 
112
136
  private val proximityManager by lazy { ProximityManager(mReactContext, this) }
@@ -130,53 +154,64 @@ class AudioDeviceManager(
130
154
  audioFocusUtil.setup(callAudioRole, mReactContext)
131
155
  }
132
156
 
157
+ /** Runs inline; callers must already be on the audio thread (see [runInAudioThread]). */
133
158
  fun start(activity: Activity) {
134
- runInAudioThread {
135
- setup()
136
- selectedAudioDeviceEndpoint = null
137
- if (callAudioRole == CallAudioRole.Communicator) {
138
- // Audio routing is manually controlled by the SDK in communication media mode
139
- // and local microphone can be published
140
- activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
141
- if (!telecomManagedMode) {
142
- // Telecom owns routing/focus; only run our own routing when not Telecom-managed.
143
- bluetoothManager.start()
144
- mAudioManager.registerAudioDeviceCallback(this, null)
145
- updateAudioDeviceState()
146
- }
147
- proximityManager.start()
148
- } else {
149
- activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE
150
- }
159
+ routingStopped = false
160
+ setup()
161
+ selectedAudioDeviceEndpoint = null
162
+ if (callAudioRole == CallAudioRole.Communicator) {
163
+ // Audio routing is manually controlled by the SDK in communication media mode
164
+ // and local microphone can be published
165
+ activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
151
166
  if (!telecomManagedMode) {
152
- audioFocusUtil.requestFocus(callAudioRole, mReactContext)
167
+ // Telecom owns routing/focus; only run our own routing when not Telecom-managed.
168
+ bluetoothManager.start()
169
+ mAudioManager.registerAudioDeviceCallback(this, null)
170
+ updateAudioDeviceState()
153
171
  }
172
+ proximityManager.start()
173
+ } else {
174
+ activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE
175
+ }
176
+ if (!telecomManagedMode) {
177
+ audioFocusUtil.requestFocus(callAudioRole, mReactContext)
178
+ }
179
+ // Started last, after focus/routing, so the silent track uses the correct route.
180
+ if (callAudioRole == CallAudioRole.Communicator &&
181
+ !telecomManagedMode &&
182
+ !disableCommunicationModeWorkaround
183
+ ) {
184
+ communicationModeKeepAlive.start()
154
185
  }
155
186
  }
156
187
 
157
- fun stop(activity: Activity) {
158
- runInAudioThread {
159
- if (callAudioRole == CallAudioRole.Communicator) {
160
- if (!telecomManagedMode) {
161
- // Only tear down what we set up ourselves; Telecom owns its own teardown.
162
- if (Build.VERSION.SDK_INT >= 31) {
163
- mAudioManager.clearCommunicationDevice()
164
- } else {
165
- mAudioManager.setSpeakerphoneOn(false)
166
- }
167
- bluetoothManager.stop()
168
- }
169
- callAudioRole = CallAudioRole.Communicator
170
- enableStereo = false
171
- defaultAudioDevice = AudioDeviceEndpoint.TYPE_SPEAKER
172
- proximityManager.stop()
173
- }
174
- activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE
188
+ /** Runs inline; callers must already be on the audio thread (see [runInAudioThread]). */
189
+ fun stop(activity: Activity?) {
190
+ routingStopped = true
191
+ communicationModeKeepAlive.stop()
192
+ if (callAudioRole == CallAudioRole.Communicator) {
175
193
  if (!telecomManagedMode) {
176
- audioFocusUtil.abandonFocus()
194
+ // Only tear down what we set up ourselves; Telecom owns its own teardown.
195
+ if (Build.VERSION.SDK_INT >= 31) {
196
+ mAudioManager.clearCommunicationDevice()
197
+ } else {
198
+ mAudioManager.setSpeakerphoneOn(false)
199
+ }
200
+ bluetoothManager.stop()
201
+ // Restore the mode set in setup(); it was previously left at
202
+ // MODE_IN_COMMUNICATION, holding the device in in-call routing.
203
+ mAudioManager.mode = AudioManager.MODE_NORMAL
177
204
  }
178
- telecomManagedMode = false
205
+ callAudioRole = CallAudioRole.Communicator
206
+ enableStereo = false
207
+ defaultAudioDevice = AudioDeviceEndpoint.TYPE_SPEAKER
208
+ proximityManager.stop()
179
209
  }
210
+ activity?.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE
211
+ if (!telecomManagedMode) {
212
+ audioFocusUtil.abandonFocus()
213
+ }
214
+ telecomManagedMode = false
180
215
  }
181
216
 
182
217
  fun setMicrophoneMute(enable: Boolean) {
@@ -228,6 +263,9 @@ class AudioDeviceManager(
228
263
  }
229
264
 
230
265
  private fun switchDeviceEndpointType(@EndpointType deviceType: Int) {
266
+ // Sole sink for routing changes, and AudioManagerUtil re-asserts MODE_IN_COMMUNICATION
267
+ // here — so late work of any origin must not reach it after teardown.
268
+ if (routingStopped) return
231
269
  val newDevice = AudioManagerUtil.switchDeviceEndpointType(
232
270
  deviceType,
233
271
  mEndpointMaps,
@@ -237,38 +275,49 @@ class AudioDeviceManager(
237
275
  this.selectedAudioDeviceEndpoint = newDevice
238
276
  }
239
277
 
278
+ /** Runs inline; callers must already be on the audio thread (see [runInAudioThread]). */
240
279
  fun switchDeviceById(
241
280
  deviceId: String
242
281
  ) {
243
282
  Log.d(TAG, "switchDeviceById: deviceId = $deviceId")
244
- runInAudioThread {
245
- val btDevice = mEndpointMaps.bluetoothEndpoints[deviceId]
246
- if (btDevice != null) {
247
- if (Build.VERSION.SDK_INT >= 31) {
248
- mAudioManager.setCommunicationDevice(btDevice.deviceInfo)
249
- bluetoothManager.updateDevice()
250
- this.selectedAudioDeviceEndpoint = btDevice
251
- } else {
252
- switchDeviceEndpointType(
253
- AudioDeviceEndpoint.TYPE_BLUETOOTH
254
- )
255
- }
283
+ // Guarded here too: the branch below sets the communication device directly rather
284
+ // than going through switchDeviceEndpointType().
285
+ if (routingStopped) return
286
+ val btDevice = mEndpointMaps.bluetoothEndpoints[deviceId]
287
+ if (btDevice != null) {
288
+ if (Build.VERSION.SDK_INT >= 31) {
289
+ mAudioManager.setCommunicationDevice(btDevice.deviceInfo)
290
+ bluetoothManager.updateDevice()
291
+ this.selectedAudioDeviceEndpoint = btDevice
256
292
  } else {
257
- val endpoint = nonBluetoothEndpointById(deviceId)
258
- if (endpoint != null) {
259
- switchDeviceEndpointType(endpoint.type)
260
- } else {
261
- Log.e(TAG, "switchDeviceById: no endpoint found for id $deviceId")
262
- }
293
+ switchDeviceEndpointType(
294
+ AudioDeviceEndpoint.TYPE_BLUETOOTH
295
+ )
296
+ }
297
+ } else {
298
+ val endpoint = nonBluetoothEndpointById(deviceId)
299
+ if (endpoint != null) {
300
+ switchDeviceEndpointType(endpoint.type)
301
+ } else {
302
+ Log.e(TAG, "switchDeviceById: no endpoint found for id $deviceId")
263
303
  }
264
304
  }
265
305
  }
266
306
 
267
307
  override fun close() {
268
- mAudioManager.unregisterAudioDeviceCallback(this)
269
- proximityManager.onDestroy()
308
+ // Unlike start()/stop(), this is called straight from the module, so it must be queued
309
+ // to land after any start/stop already pending on the audio thread.
310
+ runInAudioThread {
311
+ routingStopped = true
312
+ communicationModeKeepAlive.release()
313
+ mAudioManager.unregisterAudioDeviceCallback(this)
314
+ proximityManager.onDestroy()
315
+ }
270
316
  }
271
317
 
318
+ /** Short description of the keep-alive state, for the audio debug log. */
319
+ fun communicationModeKeepAliveState(): String = communicationModeKeepAlive.describeState()
320
+
272
321
  override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
273
322
  if (addedDevices != null) {
274
323
  runInAudioThread {
@@ -423,6 +472,9 @@ class AudioDeviceManager(
423
472
  */
424
473
  fun updateAudioDeviceState() {
425
474
  runInAudioThread {
475
+ // Bail early: on API < 31 bluetoothState survives stop(), so a late pass could
476
+ // still start SCO after the call ended.
477
+ if (routingStopped) return@runInAudioThread
426
478
  if (telecomManagedMode) {
427
479
  // Telecom is the single source of truth for routing/endpoints in this mode.
428
480
  return@runInAudioThread
@@ -0,0 +1,247 @@
1
+ package com.streamvideo.reactnative.audio
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import android.content.Context.AUDIO_SERVICE
6
+ import android.media.AudioAttributes
7
+ import android.media.AudioFormat
8
+ import android.media.AudioManager
9
+ import android.media.AudioTrack
10
+ import android.os.Build
11
+ import android.util.Log
12
+ import androidx.annotation.RequiresApi
13
+ import com.streamvideo.reactnative.callmanager.StreamInCallManagerModule
14
+ import java.nio.ByteBuffer
15
+ import java.util.concurrent.Executors
16
+ import java.util.concurrent.ScheduledExecutorService
17
+ import java.util.concurrent.TimeUnit
18
+
19
+ /**
20
+ * Holds the audio system in [AudioManager.MODE_IN_COMMUNICATION] for the duration
21
+ * of a communicator-role call.
22
+ *
23
+ * Why this is needed: from Android 11 (API 30) the platform demotes the
24
+ * audio mode back to `MODE_NORMAL` about six seconds after `setMode()` unless the
25
+ * call is actively playing or recording on the voice-communication path.
26
+ * During a call whose microphone is muted and that has no inbound audio yet,
27
+ * neither condition is met, so routing silently falls back to the media path and
28
+ * hardware echo cancellation is switched off.
29
+ * Details: https://issuetracker.google.com/issues/209493718
30
+ *
31
+ * We satisfy the "actively playing" condition by continuously looping an inaudible
32
+ * audo track on the voice-communication stream, which keeps the platform counting us
33
+ * as an active player and therefore keeps the mode in place.
34
+ */
35
+ interface CommunicationModeKeepAlive {
36
+ /** Begin holding the communication mode. Idempotent. */
37
+ fun start()
38
+
39
+ /** Stop holding the communication mode. Idempotent; the instance can be started again. */
40
+ fun stop()
41
+
42
+ /** Permanently release all resources. The instance must not be used afterwards. */
43
+ fun release()
44
+
45
+ /** Short human-readable state, surfaced in the audio-state debug log. */
46
+ fun describeState(): String
47
+ }
48
+
49
+ /** No-op variant used on platforms where the mode reset does not occur (Android API < 30). */
50
+ object NoCommunicationModeKeepAlive : CommunicationModeKeepAlive {
51
+ override fun start() {}
52
+ override fun stop() {}
53
+ override fun release() {}
54
+ override fun describeState(): String = "disabled (android platform API < 30)"
55
+ }
56
+
57
+ /**
58
+ * Default implementation backed by a silent, looping voice-communication
59
+ * [AudioTrack]. If the silent player cannot be created on a given device, it
60
+ * degrades to a lightweight poller that re-applies the mode whenever the platform
61
+ * has reset it.
62
+ *
63
+ * All lifecycle transitions and every access to [track] are serialized on [gate],
64
+ * so a play/pause can never overlap a release. The poller only ever touches the
65
+ * audio mode (never the track), so it runs lock-free.
66
+ *
67
+ * @suppress
68
+ */
69
+ @RequiresApi(Build.VERSION_CODES.R)
70
+ internal class SilentPlaybackKeepAlive(
71
+ private val context: Context,
72
+ ) : CommunicationModeKeepAlive {
73
+
74
+ private val gate = Any()
75
+
76
+ private var track: AudioTrack? = null
77
+
78
+ /** Whether we currently intend the silent player to be running. */
79
+ private var engaged = false
80
+
81
+ /** Read by the poller (off-gate) to drop ticks that were queued before we stopped. */
82
+ @Volatile
83
+ private var live = false
84
+
85
+ private var modePoller: ScheduledExecutorService? = null
86
+
87
+ override fun start(): Unit = synchronized(gate) {
88
+ live = true
89
+ if (engaged) return
90
+ engaged = true
91
+
92
+ val player = track ?: createSilentTrack()?.also { track = it }
93
+ if (player != null && player.state == AudioTrack.STATE_INITIALIZED && play(player)) {
94
+ return
95
+ }
96
+ // No usable silent player on this device — fall back to reactive repair.
97
+ player?.release()
98
+ track = null
99
+ startModePoller()
100
+ }
101
+
102
+ /**
103
+ * Starts playback, reporting whether the track actually entered the playing state.
104
+ * `play()` can throw or leave the track stopped even from STATE_INITIALIZED; treating
105
+ * that as success would strand the keep-alive with neither a track nor the poller.
106
+ */
107
+ private fun play(player: AudioTrack): Boolean =
108
+ try {
109
+ player.play()
110
+ player.playState == AudioTrack.PLAYSTATE_PLAYING
111
+ } catch (e: IllegalStateException) {
112
+ Log.w(TAG, "Silent keep-alive track refused to start.", e)
113
+ false
114
+ }
115
+
116
+ override fun stop() = synchronized(gate) {
117
+ live = false
118
+ if (engaged) {
119
+ engaged = false
120
+ track?.takeIf { it.state == AudioTrack.STATE_INITIALIZED }?.pause()
121
+ }
122
+ stopModePoller()
123
+ }
124
+
125
+ override fun release() = synchronized(gate) {
126
+ live = false
127
+ engaged = false
128
+ stopModePoller()
129
+ track?.let {
130
+ if (it.state == AudioTrack.STATE_INITIALIZED) it.pause()
131
+ it.release()
132
+ }
133
+ track = null
134
+ }
135
+
136
+ override fun describeState(): String = synchronized(gate) {
137
+ val playing = track?.playState == AudioTrack.PLAYSTATE_PLAYING
138
+ "enabled, engaged=$engaged, built=${track != null}, playing=$playing, " +
139
+ "modePoller=${modePoller != null}"
140
+ }
141
+
142
+ @SuppressLint("Range")
143
+ private fun createSilentTrack(): AudioTrack? {
144
+ return try {
145
+ val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager
146
+ val sampleRate = positiveOrDefault(
147
+ audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toIntOrNull(),
148
+ DEFAULT_SAMPLE_RATE,
149
+ )
150
+ // One buffer worth of frames is enough — we loop it forever.
151
+ val frameCount = positiveOrDefault(
152
+ audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)
153
+ ?.toIntOrNull(),
154
+ sampleRate / 100, // ~10 ms
155
+ )
156
+ val bufferBytes = frameCount * BYTES_PER_FRAME
157
+ // A freshly allocated direct buffer is zero-filled, i.e. pure silence.
158
+ val silence = ByteBuffer.allocateDirect(bufferBytes)
159
+
160
+ val player = AudioTrack.Builder()
161
+ .setAudioAttributes(
162
+ AudioAttributes.Builder()
163
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
164
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
165
+ .build(),
166
+ )
167
+ .setAudioFormat(
168
+ AudioFormat.Builder()
169
+ .setEncoding(ENCODING)
170
+ .setSampleRate(sampleRate)
171
+ .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
172
+ .build(),
173
+ )
174
+ .setBufferSizeInBytes(bufferBytes)
175
+ .setTransferMode(AudioTrack.MODE_STATIC)
176
+ .setSessionId(AudioManager.AUDIO_SESSION_ID_GENERATE)
177
+ .build()
178
+
179
+ val written = player.write(silence, silence.remaining(), AudioTrack.WRITE_BLOCKING)
180
+ val loopResult = player.setLoopPoints(0, frameCount - 1, -1)
181
+ val ready = player.state == AudioTrack.STATE_INITIALIZED &&
182
+ written >= 0 &&
183
+ loopResult == AudioTrack.SUCCESS
184
+ if (ready) {
185
+ player
186
+ } else {
187
+ Log.w(
188
+ TAG,
189
+ "Silent keep-alive track not usable " +
190
+ "(state=${player.state}, written=$written, loop=$loopResult); discarding.",
191
+ )
192
+ player.release()
193
+ null
194
+ }
195
+ } catch (e: Exception) {
196
+ Log.w(TAG, "Could not create silent keep-alive track.", e)
197
+ null
198
+ }
199
+ }
200
+
201
+ private fun startModePoller() {
202
+ if (modePoller != null) return
203
+ modePoller = Executors.newSingleThreadScheduledExecutor().also { poller ->
204
+ poller.scheduleWithFixedDelay(
205
+ ::reapplyModeIfReset,
206
+ MODE_POLL_INTERVAL_MS,
207
+ MODE_POLL_INTERVAL_MS,
208
+ TimeUnit.MILLISECONDS,
209
+ )
210
+ }
211
+ }
212
+
213
+ private fun reapplyModeIfReset() {
214
+ // Read/write the mode on the shared audio thread so it can't race routing changes.
215
+ AudioDeviceManager.runInAudioThread {
216
+ if (!live) return@runInAudioThread
217
+ val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager
218
+ if (audioManager.mode != AudioManager.MODE_IN_COMMUNICATION) {
219
+ Log.d(TAG, "Re-applying MODE_IN_COMMUNICATION after a platform reset.")
220
+ audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
221
+ }
222
+ }
223
+ }
224
+
225
+ private fun stopModePoller() {
226
+ modePoller?.shutdownNow()
227
+ modePoller = null
228
+ }
229
+
230
+ private fun positiveOrDefault(value: Int?, default: Int): Int =
231
+ if (value != null && value > 0) value else default
232
+
233
+ companion object {
234
+ private const val TAG = StreamInCallManagerModule.TAG
235
+ private const val DEFAULT_SAMPLE_RATE = 16000
236
+ private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
237
+
238
+ // We always emit mono 16-bit PCM, so a frame is exactly 2 bytes.
239
+ private const val BYTES_PER_FRAME = 2
240
+
241
+ // Fallback poll cadence. Deliberately shorter than AOSP's ~6s reset window: the
242
+ // interval bounds only the worst-case wrong-routing gap (<= interval), while the
243
+ // real setMode/HAL transition rate stays governed by the OS re-arm, so polling
244
+ // faster only costs cheap reads and shortens the recovery latency.
245
+ private const val MODE_POLL_INTERVAL_MS = 2000L
246
+ }
247
+ }
@@ -75,6 +75,18 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
75
75
  }
76
76
  }
77
77
 
78
+ @ReactMethod
79
+ fun setDisableCommunicationModeWorkaround(disabled: Boolean) {
80
+ AudioDeviceManager.runInAudioThread {
81
+ if (audioManagerActivated) {
82
+ Log.e(TAG, "setDisableCommunicationModeWorkaround(): AudioManager is already activated and so it cannot be changed")
83
+ return@runInAudioThread
84
+ }
85
+ Log.d(TAG, "setDisableCommunicationModeWorkaround(): $disabled")
86
+ mAudioDeviceManager.disableCommunicationModeWorkaround = disabled
87
+ }
88
+ }
89
+
78
90
  @ReactMethod
79
91
  fun setDefaultAudioDeviceEndpointType(endpointDeviceTypeName: String) {
80
92
  AudioDeviceManager.runInAudioThread {
@@ -129,10 +141,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
129
141
  AudioDeviceManager.runInAudioThread {
130
142
  if (audioManagerActivated) {
131
143
  Log.d(TAG, "stop() mAudioDeviceManager")
132
- reactApplicationContext.currentActivity?.let {
133
- mAudioDeviceManager.stop(it)
134
- audioManagerActivated = false
135
- }
144
+ mAudioDeviceManager.stop(reactApplicationContext.currentActivity)
145
+ audioManagerActivated = false
136
146
  setMicrophoneMute(false)
137
147
  setKeepScreenOn(false)
138
148
  }
@@ -185,7 +195,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) :
185
195
 
186
196
  @ReactMethod(isBlockingSynchronousMethod = true)
187
197
  fun getAudioStateLog(): String {
188
- return WebRtcAudioUtils.getAudioStateLog(reactApplicationContext)
198
+ return WebRtcAudioUtils.getAudioStateLog(reactApplicationContext) +
199
+ "Communication mode keep-alive: ${mAudioDeviceManager.communicationModeKeepAliveState()}\n"
189
200
  }
190
201
 
191
202
  @Suppress("unused")