diff --git a/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts b/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts index 1373c314cf..d72162ea9d 100644 --- a/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts +++ b/packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts @@ -13,6 +13,7 @@ const makeNativeManager = () => ({ setTelecomManagedMode: jest.fn(), setAudioRole: jest.fn(), setDefaultAudioDeviceEndpointType: jest.fn(), + setDisableCommunicationModeWorkaround: jest.fn(), start: jest.fn(), stop: jest.fn(), setup: jest.fn(), @@ -233,3 +234,156 @@ describe('CallManager Android Telecom branch', () => { }); }); }); + +describe('CallManager communication-mode workaround opt-out', () => { + afterEach(() => jest.resetModules()); + + it('start(): classic communicator call does not touch the sticky preference by default', () => { + const nativeManager = makeNativeManager(); + const { CallManager } = loadCallManager({ + os: 'android', + nativeManager, + callingx: undefined, + }); + new CallManager().start({ audioRole: 'communicator' }); + + expect(nativeManager.setTelecomManagedMode).toHaveBeenCalledWith(false); + // Not forwarded unless explicitly set, so a sticky opt-out is never clobbered. + expect( + nativeManager.setDisableCommunicationModeWorkaround, + ).not.toHaveBeenCalled(); + expect(nativeManager.start).toHaveBeenCalled(); + }); + + it('start(): forwards an explicit disable=true for a classic communicator call', () => { + const nativeManager = makeNativeManager(); + const { CallManager } = loadCallManager({ + os: 'android', + nativeManager, + callingx: undefined, + }); + new CallManager().start({ + audioRole: 'communicator', + disableCommunicationModeWorkaround: true, + }); + + expect( + nativeManager.setDisableCommunicationModeWorkaround, + ).toHaveBeenCalledWith(true); + }); + + it('start(): forwards an explicit disable=false for a classic communicator call', () => { + const nativeManager = makeNativeManager(); + const { CallManager } = loadCallManager({ + os: 'android', + nativeManager, + callingx: undefined, + }); + new CallManager().start({ + audioRole: 'communicator', + disableCommunicationModeWorkaround: false, + }); + + expect( + nativeManager.setDisableCommunicationModeWorkaround, + ).toHaveBeenCalledWith(false); + }); + + it('start(): listener role never forwards the workaround flag', () => { + const nativeManager = makeNativeManager(); + const { CallManager } = loadCallManager({ + os: 'android', + nativeManager, + callingx: undefined, + }); + new CallManager().start({ audioRole: 'listener' }); + + expect( + nativeManager.setDisableCommunicationModeWorkaround, + ).not.toHaveBeenCalled(); + }); + + it('start(): iOS never forwards the workaround flag', () => { + const nativeManager = makeNativeManager(); + const { CallManager } = loadCallManager({ + os: 'ios', + nativeManager, + callingx: undefined, + }); + new CallManager().start({ + audioRole: 'communicator', + disableCommunicationModeWorkaround: true, + }); + + expect( + nativeManager.setDisableCommunicationModeWorkaround, + ).not.toHaveBeenCalled(); + }); + + it('start(): Telecom-managed calls never forward the workaround flag', () => { + const nativeManager = makeNativeManager(); + const callingx = makeCallingx(); + const { CallManager } = loadCallManager({ + os: 'android', + nativeManager, + callingx, + }); + new CallManager().start({ + audioRole: 'communicator', + disableCommunicationModeWorkaround: true, + }); + + expect(nativeManager.setTelecomManagedMode).toHaveBeenCalledWith(true); + expect( + nativeManager.setDisableCommunicationModeWorkaround, + ).not.toHaveBeenCalled(); + }); + + it('setDisableCommunicationModeWorkaround: sets the sticky preference on Android', () => { + const nativeManager = makeNativeManager(); + const { CallManager } = loadCallManager({ + os: 'android', + nativeManager, + callingx: undefined, + }); + new CallManager().setDisableCommunicationModeWorkaround(true); + + expect( + nativeManager.setDisableCommunicationModeWorkaround, + ).toHaveBeenCalledWith(true); + }); + + it('setDisableCommunicationModeWorkaround: no-op on iOS', () => { + const nativeManager = makeNativeManager(); + const { CallManager } = loadCallManager({ + os: 'ios', + nativeManager, + callingx: undefined, + }); + new CallManager().setDisableCommunicationModeWorkaround(true); + + expect( + nativeManager.setDisableCommunicationModeWorkaround, + ).not.toHaveBeenCalled(); + }); + + it('start(): survives a native module missing the workaround method (version skew)', () => { + const nativeManager = makeNativeManager(); + // Simulate an older native binary that predates the method. + delete (nativeManager as Partial) + .setDisableCommunicationModeWorkaround; + const { CallManager } = loadCallManager({ + os: 'android', + nativeManager, + callingx: undefined, + }); + + expect(() => + new CallManager().start({ + audioRole: 'communicator', + disableCommunicationModeWorkaround: true, + }), + ).not.toThrow(); + expect(nativeManager.start).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt index c4a8d67fa8..2a806e2c44 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt @@ -107,6 +107,25 @@ class AudioDeviceManager( */ var telecomManagedMode: Boolean = false + /** + * Opt-out for the Android 11+ communication-mode keep-alive + * (see [CommunicationModeKeepAlive]). Sticky developer preference — intentionally + * NOT reset in [stop] (unlike role/stereo/defaultDevice, which are per-call). + */ + var disableCommunicationModeWorkaround: Boolean = false + + /** + * Keeps MODE_IN_COMMUNICATION owned for the whole Communicator call on Android 11+. + * Instantiated once; a no-op below R (SDK-version split), with per-call gating + * (role / telecom / opt-out) applied at [start]. + */ + private val communicationModeKeepAlive: CommunicationModeKeepAlive = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + SilentPlaybackKeepAlive(mReactContext) + } else { + NoCommunicationModeKeepAlive + } + val bluetoothManager = BluetoothManager(mReactContext, this) private val proximityManager by lazy { ProximityManager(mReactContext, this) } @@ -151,11 +170,20 @@ class AudioDeviceManager( if (!telecomManagedMode) { audioFocusUtil.requestFocus(callAudioRole, mReactContext) } + // Keep MODE_IN_COMMUNICATION owned for the whole call (Android 11+). + // Built last, after focus/routing, so the silent track picks up the correct route. + if (callAudioRole == CallAudioRole.Communicator && + !telecomManagedMode && + !disableCommunicationModeWorkaround + ) { + communicationModeKeepAlive.start() + } } } - fun stop(activity: Activity) { + fun stop(activity: Activity?) { runInAudioThread { + communicationModeKeepAlive.stop() if (callAudioRole == CallAudioRole.Communicator) { if (!telecomManagedMode) { // Only tear down what we set up ourselves; Telecom owns its own teardown. @@ -165,13 +193,17 @@ class AudioDeviceManager( mAudioManager.setSpeakerphoneOn(false) } bluetoothManager.stop() + // Restore the global audio mode set in setup(). It was previously left at + // MODE_IN_COMMUNICATION after a call, holding the device in in-call routing. + // Safe here: the keep-alive was stopped above. + mAudioManager.mode = AudioManager.MODE_NORMAL } callAudioRole = CallAudioRole.Communicator enableStereo = false defaultAudioDevice = AudioDeviceEndpoint.TYPE_SPEAKER proximityManager.stop() } - activity.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE + activity?.volumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE if (!telecomManagedMode) { audioFocusUtil.abandonFocus() } @@ -265,10 +297,19 @@ class AudioDeviceManager( } override fun close() { - mAudioManager.unregisterAudioDeviceCallback(this) - proximityManager.onDestroy() + // Queue teardown on the same single-thread audio executor as start()/stop() so it + // is serialized after any pending audio work and can't race a queued start() that + // would otherwise touch the keep-alive's track/poller after release. + runInAudioThread { + communicationModeKeepAlive.release() + mAudioManager.unregisterAudioDeviceCallback(this) + proximityManager.onDestroy() + } } + /** Short description of the keep-alive state, for the audio debug log. */ + fun communicationModeKeepAliveState(): String = communicationModeKeepAlive.describeState() + override fun onAudioDevicesAdded(addedDevices: Array?) { if (addedDevices != null) { runInAudioThread { diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt new file mode 100644 index 0000000000..74be8e3a92 --- /dev/null +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/audio/CommunicationModeKeepAlive.kt @@ -0,0 +1,236 @@ +package com.streamvideo.reactnative.audio + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Context.AUDIO_SERVICE +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.streamvideo.reactnative.callmanager.StreamInCallManagerModule +import java.nio.ByteBuffer +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +/** + * Holds the audio system in [AudioManager.MODE_IN_COMMUNICATION] for the duration + * of a communicator-role call. + * + * Why this is needed: from Android 11 (API 30) the platform demotes the + * audio mode back to `MODE_NORMAL` about six seconds after `setMode()` unless the + * call is actively playing or recording on the voice-communication path. + * During a call whose microphone is muted and that has no inbound audio yet, + * neither condition is met, so routing silently falls back to the media path and + * hardware echo cancellation is switched off. + * Details: https://issuetracker.google.com/issues/209493718 + * + * We satisfy the "actively playing" condition by continuously looping an inaudible + * audo track on the voice-communication stream, which keeps the platform counting us + * as an active player and therefore keeps the mode in place. + */ +interface CommunicationModeKeepAlive { + /** Begin holding the communication mode. Idempotent. */ + fun start() + + /** Stop holding the communication mode. Idempotent; the instance can be started again. */ + fun stop() + + /** Permanently release all resources. The instance must not be used afterwards. */ + fun release() + + /** Short human-readable state, surfaced in the audio-state debug log. */ + fun describeState(): String +} + +/** No-op variant used on platforms where the mode reset does not occur (Android API < 30). */ +object NoCommunicationModeKeepAlive : CommunicationModeKeepAlive { + override fun start() {} + override fun stop() {} + override fun release() {} + override fun describeState(): String = "disabled (android platform API < 30)" +} + +/** + * Default implementation backed by a silent, looping voice-communication + * [AudioTrack]. If the silent player cannot be created on a given device, it + * degrades to a lightweight poller that re-applies the mode whenever the platform + * has reset it. + * + * All lifecycle transitions and every access to [track] are serialized on [gate], + * so a play/pause can never overlap a release. The poller only ever touches the + * audio mode (never the track), so it runs lock-free. + * + * @suppress + */ +@RequiresApi(Build.VERSION_CODES.R) +internal class SilentPlaybackKeepAlive( + private val context: Context, +) : CommunicationModeKeepAlive { + + private val gate = Any() + + private var track: AudioTrack? = null + + /** Whether we currently intend the silent player to be running. */ + private var engaged = false + + /** Read by the poller (off-gate) to drop ticks that were queued before we stopped. */ + @Volatile + private var live = false + + private var modePoller: ScheduledExecutorService? = null + + override fun start(): Unit = synchronized(gate) { + live = true + if (engaged) return + engaged = true + + val player = track ?: createSilentTrack()?.also { track = it } + if (player == null) { + // Silent player unavailable on this device — fall back to reactive repair. + startModePoller() + return + } + if (player.state == AudioTrack.STATE_INITIALIZED) { + player.play() + } else { + player.release() + track = null + startModePoller() + } + } + + override fun stop() = synchronized(gate) { + live = false + if (engaged) { + engaged = false + track?.takeIf { it.state == AudioTrack.STATE_INITIALIZED }?.pause() + } + stopModePoller() + } + + override fun release() = synchronized(gate) { + live = false + engaged = false + stopModePoller() + track?.let { + if (it.state == AudioTrack.STATE_INITIALIZED) it.pause() + it.release() + } + track = null + } + + override fun describeState(): String = synchronized(gate) { + "enabled, built=${track != null}, playing=$engaged, modePoller=${modePoller != null}" + } + + @SuppressLint("Range") + private fun createSilentTrack(): AudioTrack? { + return try { + val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager + val sampleRate = positiveOrDefault( + audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toIntOrNull(), + DEFAULT_SAMPLE_RATE, + ) + // One buffer worth of frames is enough — we loop it forever. + val frameCount = positiveOrDefault( + audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER) + ?.toIntOrNull(), + sampleRate / 100, // ~10 ms + ) + val bufferBytes = frameCount * BYTES_PER_FRAME + // A freshly allocated direct buffer is zero-filled, i.e. pure silence. + val silence = ByteBuffer.allocateDirect(bufferBytes) + + val player = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(ENCODING) + .setSampleRate(sampleRate) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build(), + ) + .setBufferSizeInBytes(bufferBytes) + .setTransferMode(AudioTrack.MODE_STATIC) + .setSessionId(AudioManager.AUDIO_SESSION_ID_GENERATE) + .build() + + val written = player.write(silence, silence.remaining(), AudioTrack.WRITE_BLOCKING) + val loopResult = player.setLoopPoints(0, frameCount - 1, -1) + val ready = player.state == AudioTrack.STATE_INITIALIZED && + written >= 0 && + loopResult == AudioTrack.SUCCESS + if (ready) { + player + } else { + Log.w( + TAG, + "Silent keep-alive track not usable " + + "(state=${player.state}, written=$written, loop=$loopResult); discarding.", + ) + player.release() + null + } + } catch (e: Exception) { + Log.w(TAG, "Could not create silent keep-alive track.", e) + null + } + } + + private fun startModePoller() { + if (modePoller != null) return + modePoller = Executors.newSingleThreadScheduledExecutor().also { poller -> + poller.scheduleWithFixedDelay( + ::reapplyModeIfReset, + MODE_POLL_INTERVAL_MS, + MODE_POLL_INTERVAL_MS, + TimeUnit.MILLISECONDS, + ) + } + } + + private fun reapplyModeIfReset() { + // Read/write the mode on the shared audio thread so it can't race routing changes. + AudioDeviceManager.runInAudioThread { + if (!live) return@runInAudioThread + val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager + if (audioManager.mode != AudioManager.MODE_IN_COMMUNICATION) { + Log.d(TAG, "Re-applying MODE_IN_COMMUNICATION after a platform reset.") + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + } + } + } + + private fun stopModePoller() { + modePoller?.shutdownNow() + modePoller = null + } + + private fun positiveOrDefault(value: Int?, default: Int): Int = + if (value != null && value > 0) value else default + + companion object { + private const val TAG = StreamInCallManagerModule.TAG + private const val DEFAULT_SAMPLE_RATE = 16000 + private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT + + // We always emit mono 16-bit PCM, so a frame is exactly 2 bytes. + private const val BYTES_PER_FRAME = 2 + + // Fallback poll cadence. Deliberately shorter than AOSP's ~6s reset window: the + // interval bounds only the worst-case wrong-routing gap (<= interval), while the + // real setMode/HAL transition rate stays governed by the OS re-arm, so polling + // faster only costs cheap reads and shortens the recovery latency. + private const val MODE_POLL_INTERVAL_MS = 2000L + } +} diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt index 0c9c669e2a..d4a11af62c 100644 --- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt @@ -75,6 +75,18 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : } } + @ReactMethod + fun setDisableCommunicationModeWorkaround(disabled: Boolean) { + AudioDeviceManager.runInAudioThread { + if (audioManagerActivated) { + Log.e(TAG, "setDisableCommunicationModeWorkaround(): AudioManager is already activated and so it cannot be changed") + return@runInAudioThread + } + Log.d(TAG, "setDisableCommunicationModeWorkaround(): $disabled") + mAudioDeviceManager.disableCommunicationModeWorkaround = disabled + } + } + @ReactMethod fun setDefaultAudioDeviceEndpointType(endpointDeviceTypeName: String) { AudioDeviceManager.runInAudioThread { @@ -129,10 +141,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : AudioDeviceManager.runInAudioThread { if (audioManagerActivated) { Log.d(TAG, "stop() mAudioDeviceManager") - reactApplicationContext.currentActivity?.let { - mAudioDeviceManager.stop(it) - audioManagerActivated = false - } + mAudioDeviceManager.stop(reactApplicationContext.currentActivity) + audioManagerActivated = false setMicrophoneMute(false) setKeepScreenOn(false) } @@ -185,7 +195,8 @@ class StreamInCallManagerModule(reactContext: ReactApplicationContext) : @ReactMethod(isBlockingSynchronousMethod = true) fun getAudioStateLog(): String { - return WebRtcAudioUtils.getAudioStateLog(reactApplicationContext) + return WebRtcAudioUtils.getAudioStateLog(reactApplicationContext) + + "Communication mode keep-alive: ${mAudioDeviceManager.communicationModeKeepAliveState()}\n" } @Suppress("unused") diff --git a/packages/react-native-sdk/src/modules/call-manager/CallManager.ts b/packages/react-native-sdk/src/modules/call-manager/CallManager.ts index d562ad4f92..44f43057d9 100644 --- a/packages/react-native-sdk/src/modules/call-manager/CallManager.ts +++ b/packages/react-native-sdk/src/modules/call-manager/CallManager.ts @@ -372,6 +372,18 @@ export class CallManager { if (config?.audioRole === 'communicator') { const type = config.deviceEndpointType ?? 'speaker'; NativeManager.setDefaultAudioDeviceEndpointType(type); + // Only forward when explicitly set, so this per-call config never clobbers a + // sticky preference set via setDisableCommunicationModeWorkaround(). + if ( + Platform.OS === 'android' && + config.disableCommunicationModeWorkaround !== undefined + ) { + safeNativeCall('setDisableCommunicationModeWorkaround', () => + NativeManager.setDisableCommunicationModeWorkaround( + config.disableCommunicationModeWorkaround ?? false, + ), + ); + } } if (config?.audioRole === 'listener' && config.enableStereoAudioOutput) { NativeManager.setEnableStereoAudioOutput(true); @@ -392,6 +404,23 @@ export class CallManager { NativeManager.stop(); }; + /** + * Android only. Opt out of the Android 11+ communication-mode keep-alive workaround. + * + * The SDK plays a silent voice-communication track during communicator-role calls to + * stop Android from resetting `MODE_IN_COMMUNICATION` (which breaks routing/AEC). + * No-op on iOS and on Android below API 30. + * See {@link https://issuetracker.google.com/issues/209493718} + */ + setDisableCommunicationModeWorkaround = (disabled: boolean): void => { + if (Platform.OS !== 'android') { + return; + } + safeNativeCall('setDisableCommunicationModeWorkaround', () => + NativeManager.setDisableCommunicationModeWorkaround(disabled), + ); + }; + /** * For debugging purposes, will emit a log event with the current audio state. * in the native layer. diff --git a/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts b/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts index 0c8fad4456..11d1bb34a3 100644 --- a/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts +++ b/packages/react-native-sdk/src/modules/call-manager/native-module.d.ts @@ -32,6 +32,20 @@ export interface CallManager extends NativeModule { */ setTelecomManagedMode: (enabled: boolean) => void; + /** + * Opt out of the Android 11+ communication-mode keep-alive workaround. + * + * On Android 11+ (API 30) the OS resets `MODE_IN_COMMUNICATION` ~6s after it is + * set when there is no active voice-communication playback/recording, which + * breaks audio routing and AEC. By default the SDK plays a silent + * voice-communication track for the duration of a communicator-role call to + * prevent this. Pass `true` to disable it. Sticky for the process lifetime. + * + * No-op on iOS and on Android below API 30. See + * https://issuetracker.google.com/issues/209493718 + */ + setDisableCommunicationModeWorkaround: (disabled: boolean) => void; + /** * Choose an audio device endpoint by its stable id. * @param deviceId - The id of the audio device to choose (`AudioDevice.id`). diff --git a/packages/react-native-sdk/src/modules/call-manager/types.ts b/packages/react-native-sdk/src/modules/call-manager/types.ts index eeeb7a565d..c9b0e02b4a 100644 --- a/packages/react-native-sdk/src/modules/call-manager/types.ts +++ b/packages/react-native-sdk/src/modules/call-manager/types.ts @@ -53,6 +53,12 @@ export type StreamInCallManagerConfig = | { audioRole: 'communicator'; deviceEndpointType?: DeviceEndpointType; + /** + * Android only. Opt out of the Android 11+ communication-mode keep-alive + * workaround for this call. + * See {@link https://issuetracker.google.com/issues/209493718} + */ + disableCommunicationModeWorkaround?: boolean; } | { audioRole: 'listener';