Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/actions/rn-bootstrap/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ runs:

- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1
ruby-version: 3.4
working-directory: sample-apps/react-native/dogfood
bundler-cache: true

Expand Down
12 changes: 7 additions & 5 deletions packages/client/src/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,9 @@ export class Call {
this.cancelAutoDrop();
this.clientStore.unregisterCall(this);

globalThis.streamRNVideoSDK?.callManager.stop();
globalThis.streamRNVideoSDK?.callManager.stop({
isRingingTypeCall: this.ringing,
});

this.camera.dispose();
this.microphone.dispose();
Expand Down Expand Up @@ -1117,7 +1119,9 @@ export class Call {
// re-apply them on later reconnections or server-side data fetches
if (!this.deviceSettingsAppliedOnce && this.state.settings) {
await this.applyDeviceConfig(this.state.settings, true);
globalThis.streamRNVideoSDK?.callManager.start();
globalThis.streamRNVideoSDK?.callManager.start({
isRingingTypeCall: this.ringing,
});
this.deviceSettingsAppliedOnce = true;
}

Expand Down Expand Up @@ -2694,9 +2698,7 @@ export class Call {
settings: CallSettingsResponse,
publish: boolean,
) => {
globalThis.streamRNVideoSDK?.callManager.setup({
default_device: settings.audio.default_device,
});
this.speaker.apply(settings);
await this.camera.apply(settings.video, publish).catch((err) => {
this.logger.warn('Camera init failed', err);
});
Expand Down
41 changes: 41 additions & 0 deletions packages/client/src/devices/SpeakerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,60 @@ import { Call } from '../Call';
import { isReactNative } from '../helpers/platforms';
import { SpeakerState } from './SpeakerState';
import { deviceIds$, getAudioOutputDevices } from './devices';
import {
CallSettingsResponse,
AudioSettingsRequestDefaultDeviceEnum,
} from '../gen/coordinator';

export class SpeakerManager {
readonly state: SpeakerState;
private subscriptions: Subscription[] = [];
private areSubscriptionsSetUp = false;
private readonly call: Call;
private defaultDevice?: AudioSettingsRequestDefaultDeviceEnum;

constructor(call: Call) {
this.call = call;
this.state = new SpeakerState(call.tracer);
this.setup();
}

apply(settings: CallSettingsResponse) {
if (!isReactNative()) {
return;
}
/// Determines if the speaker should be enabled based on a priority hierarchy of
/// settings.
///
/// The priority order is as follows:
/// 1. If video camera is set to be on by default, speaker is enabled
/// 2. If audio speaker is set to be on by default, speaker is enabled
/// 3. If the default audio device is set to speaker, speaker is enabled
///
/// This ensures that the speaker state aligns with the most important user
/// preference or system requirement.
const speakerOnWithSettingsPriority =
settings.video.camera_default_on ||
settings.audio.speaker_default_on ||
settings.audio.default_device ===
AudioSettingsRequestDefaultDeviceEnum.SPEAKER;

const defaultDevice = speakerOnWithSettingsPriority
? AudioSettingsRequestDefaultDeviceEnum.SPEAKER
: AudioSettingsRequestDefaultDeviceEnum.EARPIECE;

if (this.defaultDevice !== defaultDevice) {
this.call.logger.debug('SpeakerManager: setting default device', {
defaultDevice,
});
this.defaultDevice = defaultDevice;
globalThis.streamRNVideoSDK?.callManager.setup({
defaultDevice,
isRingingTypeCall: this.call.ringing,
});
}
}

setup() {
if (this.areSubscriptionsSetUp) {
return;
Expand Down
22 changes: 16 additions & 6 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,26 +345,36 @@ export type StartCallRecordingFnType = {
): Promise<StartRecordingResponse>;
};

type StreamRNVideoSDKCallManagerRingingParams = {
isRingingTypeCall: boolean;
};

type StreamRNVideoSDKCallManagerSetupParams =
StreamRNVideoSDKCallManagerRingingParams & {
defaultDevice: AudioSettingsRequestDefaultDeviceEnum;
};

export type StreamRNVideoSDKGlobals = {
callManager: {
/**
* Sets up the in call manager.
*/
setup({
default_device,
}: {
default_device: AudioSettingsRequestDefaultDeviceEnum;
}): void;
defaultDevice,
isRingingTypeCall,
}: StreamRNVideoSDKCallManagerSetupParams): void;

/**
* Starts the in call manager.
*/
start(): void;
start({
isRingingTypeCall,
}: StreamRNVideoSDKCallManagerRingingParams): void;

/**
* Stops the in call manager.
*/
stop(): void;
stop({ isRingingTypeCall }: StreamRNVideoSDKCallManagerRingingParams): void;
};
};

Expand Down
2 changes: 2 additions & 0 deletions packages/react-native-callingx/Callingx.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ Pod::Spec.new do |s|
s.public_header_files = "ios/CallingxPublic.h"
s.swift_version = "5.0"

s.dependency "stream-react-native-webrtc"

install_modules_dependencies(s)
end
39 changes: 19 additions & 20 deletions packages/react-native-callingx/ios/AudioSessionManager.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import AVFoundation
import stream_react_native_webrtc

@objcMembers public class AudioSessionManager: NSObject {

Expand All @@ -8,36 +9,34 @@ import AVFoundation
print("[Callingx][createAudioSessionIfNeeded] Creating audio session")
#endif

var categoryOptions: AVAudioSession.CategoryOptions
let categoryOptions: AVAudioSession.CategoryOptions
#if compiler(>=6.2) // For Xcode 26.0+
categoryOptions = [.allowBluetoothHFP, .defaultToSpeaker]
#else
categoryOptions = [.allowBluetooth, .defaultToSpeaker]
#endif
var mode: AVAudioSession.Mode = .videoChat
let mode: AVAudioSession.Mode = .videoChat

let settings = Settings.getSettings()

if let audioSessionSettings = settings["audioSession"] as? [String: Any] {
if let options = audioSessionSettings["categoryOptions"] as? UInt {
categoryOptions = AVAudioSession.CategoryOptions(rawValue: options)
}
// Configure RTCAudioSessionConfiguration to match our intended settings
// This ensures WebRTC's internal state stays consistent during interruptions/route changes
let rtcConfig = RTCAudioSessionConfiguration.webRTC()
rtcConfig.category = AVAudioSession.Category.playAndRecord.rawValue
rtcConfig.mode = mode.rawValue
rtcConfig.categoryOptions = categoryOptions
RTCAudioSessionConfiguration.setWebRTC(rtcConfig)

if let modeString = audioSessionSettings["mode"] as? String {
mode = AVAudioSession.Mode(rawValue: modeString) ?? .default
}
}
// Apply settings via RTCAudioSession (with lock) to keep WebRTC internal state consistent
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }

let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playAndRecord, options: categoryOptions)
try audioSession.setMode(mode)

let sampleRate: Double = 44100.0
try? audioSession.setPreferredSampleRate(sampleRate)
try rtcSession.setCategory(.playAndRecord, mode: mode, options: categoryOptions)

let bufferDuration: TimeInterval = 0.005
try? audioSession.setPreferredIOBufferDuration(bufferDuration)
// Apply sample rate and IO buffer duration from WebRTC's config (source of truth)
// This keeps CallKit setup aligned with WebRTC's intended tuning (e.g. 48kHz and ~20ms by default)
try rtcSession.setPreferredSampleRate(rtcConfig.sampleRate)
try rtcSession.setPreferredIOBufferDuration(rtcConfig.ioBufferDuration)
} catch {
#if DEBUG
print("[Callingx][createAudioSessionIfNeeded] Error configuring audio session: \(error)")
Expand Down
26 changes: 22 additions & 4 deletions packages/react-native-callingx/ios/CallingxImpl.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Foundation
import CallKit
import AVFoundation
import UIKit
import stream_react_native_webrtc

// MARK: - Event Names
@objcMembers public class CallingxEvents: NSObject {
Expand Down Expand Up @@ -695,17 +697,33 @@ import AVFoundation

public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
#if DEBUG
print("[Callingx][CXProviderDelegate][provider:didActivateAudioSession]")
print("[Callingx][CXProviderDelegate][provider:didActivateAudioSession] category=\(audioSession.category) mode=\(audioSession.mode)")
#endif



// When CallKit activates the AVAudioSession, inform WebRTC as well.
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)

// Enable wake lock to keep the device awake during the call
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = true
}

sendEvent(CallingxEvents.didActivateAudioSession, body: nil)
}

public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
#if DEBUG
print("[Callingx][CXProviderDelegate][provider:didDeactivateAudioSession]")
print("[Callingx][CXProviderDelegate][provider:didDeactivateAudioSession] category=\(audioSession.category) mode=\(audioSession.mode)")
#endif

// When CallKit deactivates the AVAudioSession, inform WebRTC as well.
RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)

// Disable wake lock when the call ends
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = false
}

sendEvent(CallingxEvents.didDeactivateAudioSession, body: nil)
}

Expand Down
2 changes: 2 additions & 0 deletions packages/react-native-callingx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"devDependencies": {
"@react-native-community/cli": "20.0.1",
"@react-native/babel-preset": "^0.81.5",
"@stream-io/react-native-webrtc": "137.1.0",
"@types/react": "^19.1.0",
"del-cli": "^6.0.0",
"react": "19.1.0",
Expand All @@ -66,6 +67,7 @@
"typescript": "^5.9.2"
},
"peerDependencies": {
"@stream-io/react-native-webrtc": ">=137.1.0",
"react": "*",
"react-native": "*"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native-sdk/ios/StreamInCallManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ class StreamInCallManager: RCTEventEmitter {
rtcConfig.category = intendedCategory.rawValue
rtcConfig.mode = intendedMode.rawValue
rtcConfig.categoryOptions = intendedOptions
// This ensures WebRTC's internal state stays consistent during interruptions/route changes
RTCAudioSessionConfiguration.setWebRTC(rtcConfig)

let session = RTCAudioSession.sharedInstance()
Expand All @@ -141,6 +142,9 @@ class StreamInCallManager: RCTEventEmitter {
}
do {
try session.setCategory(intendedCategory, mode: intendedMode, options: intendedOptions)
// Apply sample rate and IO buffer duration from WebRTC's config
try session.setPreferredSampleRate(rtcConfig.sampleRate)
try session.setPreferredIOBufferDuration(rtcConfig.ioBufferDuration)
if (wasRecording) {
try adm.setRecording(wasRecording)
}
Expand Down
30 changes: 30 additions & 0 deletions packages/react-native-sdk/src/modules/call-manager/CallManager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
import { AudioDeviceStatus, StreamInCallManagerConfig } from './types';
import { getCallingxLibIfAvailable } from '../../utils/push/libs/callingx';
import { videoLoggerSystem } from '@stream-io/video-client';

const NativeManager = NativeModules.StreamInCallManager;
const CallingxModule = getCallingxLibIfAvailable();

const invariant = (condition: boolean, message: string) => {
if (!condition) throw new Error(message);
Expand Down Expand Up @@ -72,6 +75,19 @@ class SpeakerManager {
};
}

const shouldBypassForCallKit = (): boolean => {
if (Platform.OS !== 'ios') {
return false;
}
if (!CallingxModule) {
return false;
}
return (
CallingxModule.isSetup &&
(CallingxModule.hasRegisteredCall() || CallingxModule.isOngoingCallsEnabled)
);
};

export class CallManager {
android = new AndroidCallManager();
ios = new IOSCallManager();
Expand All @@ -95,6 +111,14 @@ export class CallManager {
* @param config.enableStereoAudioOutput Whether to enable stereo audio output. Only supported for listener audio role.
*/
start = (config?: StreamInCallManagerConfig): void => {
if (shouldBypassForCallKit()) {
videoLoggerSystem
.getLogger('CallManager')
.debug(
'start: skipping start as callkit is handling the audio session',
);
return;
}
NativeManager.setAudioRole(config?.audioRole ?? 'communicator');
if (config?.audioRole === 'communicator') {
const type = config.deviceEndpointType ?? 'speaker';
Expand All @@ -110,6 +134,12 @@ export class CallManager {
* Stops the in call manager.
*/
stop = (): void => {
if (shouldBypassForCallKit()) {
videoLoggerSystem
.getLogger('CallManager')
.debug('stop: skipping stop as callkit is handling the audio session');
return;
}
NativeManager.stop();
};

Expand Down
Loading
Loading