Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions packages/react-native-sdk/__tests__/call-manager/CallManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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<typeof nativeManager>)
.setDisableCommunicationModeWorkaround;
const { CallManager } = loadCallManager({
os: 'android',
nativeManager,
callingx: undefined,
});

expect(() =>
new CallManager().start({
audioRole: 'communicator',
disableCommunicationModeWorkaround: true,
}),
).not.toThrow();
expect(nativeManager.start).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

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.
Expand All @@ -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()
}
Expand Down Expand Up @@ -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()
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Short description of the keep-alive state, for the audio debug log. */
fun communicationModeKeepAliveState(): String = communicationModeKeepAlive.describeState()

override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
if (addedDevices != null) {
runInAudioThread {
Expand Down
Loading