Skip to content
Draft
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/workflows/ios_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,43 @@

jobs:
ios-compile:
runs-on: macos-latest
runs-on: macos-15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

- name: Cache cocoapods
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ./examples/GumTestApp/ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-

- name: Install node modules
run: npm install

- name: Install example node modules
working-directory: ./examples/GumTestApp/
run: npm install

- name: Pod install
working-directory: ./examples/GumTestApp/ios
run: pod install

- name: Compile iOS Example
working-directory: ./examples/GumTestApp/ios
run: |
set -o pipefail && \
xcodebuild -workspace GumTestApp.xcworkspace/ \
-scheme GumTestApp \
-destination generic/platform=iOS \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_ENTITLEMENTS="" \
CODE_SIGNING_ALLOWED="NO" clean build \
| xcpretty

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
21 changes: 21 additions & 0 deletions ios/RCTWebRTC/AudioDeviceModuleObserver.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@ NS_ASSUME_NONNULL_BEGIN
@property(atomic, assign) BOOL isDidDisableEngineActive;
@property(atomic, assign) BOOL isWillReleaseEngineActive;

// Native default audio-session configuration policy. When set (non-nil) and no
// custom JS handler is registered for willEnable/didDisable, the observer
// configures the AVAudioSession natively on the worker thread instead of doing a
// JS round trip. Pushed once from JS, and nil disables it. Reassigning or
// clearing the policy is safe: activation state is tracked against
// RTCAudioSession itself, not against the policy, and a hold orphaned by
// clearing is released at the next full stop. One consequence: clearing a
// deactivateOnStop:NO policy while the engine is already stopped keeps the
// session activation held (and the OS session active) until the next engine
// cycle, because no callback fires in between. Callers who need an immediate
// release should stop under a deactivateOnStop:YES policy before clearing.
//
// Precedence is evaluated per hook, so custom willEnable and didDisable JS
// handlers must be registered or cleared as a pair while a policy is set. A JS
// handler owning one hook while the native policy owns the other can activate a
// session that the owning regime never releases. Shape:
// @{ @"recording": <cfg>, @"playout": <cfg>, @"deactivateOnStop": @(BOOL) }
// where <cfg> is @{ @"audioCategory": str, @"audioMode": str,
// @"audioCategoryOptions": @[str...] }.
@property(atomic, copy, nullable) NSDictionary *automaticAudioSessionConfig;

// Methods to receive results from JS. requestId echoes the id sent with the
// corresponding event so stale responses from timed-out rounds can be dropped.
- (void)resolveEngineCreatedWithRequestId:(NSInteger)requestId result:(NSInteger)result;
Expand Down
236 changes: 235 additions & 1 deletion ios/RCTWebRTC/AudioDeviceModuleObserver.m
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
// outcome is deliberately preferred over the unrecoverable deadlock.
static const int64_t kJSResponseTimeoutSeconds = 2;

// Returned from willEnable/didDisable when native automatic audio-session
// configuration fails, so libwebrtc rolls the engine operation back. Mirrors the
// SDK's kAudioEngineErrorFailedToConfigureAudioSession value.
static const NSInteger kAutomaticAudioSessionConfigError = -4100;

static os_log_t ADMObserverLog(void) {
static os_log_t log;
static dispatch_once_t onceToken;
Expand Down Expand Up @@ -55,6 +60,15 @@ @interface AudioDeviceModuleObserver ()
@property(nonatomic, assign) NSInteger requestIdSeq;
@property(nonatomic, assign) NSInteger awaitingRequestId;

// Whether the native auto-config path currently holds an
// RTCAudioSession activation. RTCAudioSession reference-counts activations
// (setActive:YES on an already-active session only bumps the count, and
// setActive:NO only deactivates at count 1), so the observer must activate at
// most once per hold and release exactly what it took, or the OS session leaks
// active forever. Only touched on the serial delegate (worker) thread, and kept
// atomic as cheap insurance against future cross-thread reads.
@property(atomic, assign) BOOL autoSessionHoldsActivation;

@end

@implementation AudioDeviceModuleObserver
Expand Down Expand Up @@ -110,7 +124,7 @@ - (NSInteger)sendEventAndWaitWithName:(NSString *)eventName
if (!isActive) {
// No handler registered, proceed immediately without JS round trip.
// This avoids the deadlock window entirely.
os_log_debug(ADMObserverLog(), "Skipping JS round-trip for %{public}@ (no handler registered)", eventName);
os_log(ADMObserverLog(), "Skipping JS round-trip for %{public}@ (no handler registered)", eventName);
return 0;
}

Expand Down Expand Up @@ -185,6 +199,14 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule
willEnableEngine:(AVAudioEngine *)engine
isPlayoutEnabled:(BOOL)isPlayoutEnabled
isRecordingEnabled:(BOOL)isRecordingEnabled {
// With no custom JS handler registered, configure the audio session natively
// here instead of doing a JS round trip. This is the default path and avoids
// parking the audio worker thread on the JS thread entirely. A custom handler
// (isWillEnableEngineActive == YES) falls through to the bounded round trip.
if (!self.isWillEnableEngineActive && self.automaticAudioSessionConfig != nil) {
return [self applyAutomaticAudioSessionConfigForPlayout:isPlayoutEnabled recording:isRecordingEnabled];
}

BOOL isActive = self.isWillEnableEngineActive;

if (isActive) {
Expand Down Expand Up @@ -276,6 +298,29 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule
didDisableEngine:(AVAudioEngine *)engine
isPlayoutEnabled:(BOOL)isPlayoutEnabled
isRecordingEnabled:(BOOL)isRecordingEnabled {
// Counterpart of willEnable - apply the native session policy (here the
// deactivate-on-stop edge) without a JS round trip when no custom handler
// is registered. Also entered with a nil policy while an activation hold is
// still outstanding, so a hold orphaned by clearing the policy is released
// at the next full stop instead of leaking.
if (!self.isDidDisableEngineActive &&
(self.automaticAudioSessionConfig != nil || self.autoSessionHoldsActivation)) {
NSInteger result = [self applyAutomaticAudioSessionConfigForPlayout:isPlayoutEnabled
recording:isRecordingEnabled];
if (result != 0) {
// didDisable fires after the engine's disable work has already run, so
// libwebrtc cannot roll this operation back. Propagating an error here
// (e.g. a failed reconfigure on a duplex to playout transition) would
// only desync its logical engine state from hardware that has already
// changed. Log and report the operation as completed. Only willEnable
// propagates configuration and activation errors, where the enable has
// not happened yet and a rollback is still meaningful.
os_log_error(
ADMObserverLog(), "Native auto-config: error %ld in didDisable treated as completed", (long)result);
}
return 0;
}

BOOL isActive = self.isDidDisableEngineActive;

if (isActive) {
Expand Down Expand Up @@ -419,6 +464,195 @@ - (void)resolveWillReleaseEngineWithRequestId:(NSInteger)requestId result:(NSInt
semaphore:self.willReleaseEngineSemaphore];
}

#pragma mark - Native automatic audio session configuration

// Applies the pushed audio-session policy on the worker thread with no JS round
// trip: configures on every active state, activates when the session is not
// already active, optionally deactivates on stop, and returns a non-zero error
// code on failure so libwebrtc rolls the operation back.
//
// Activation is decided against RTCAudioSession's own state rather than a mirror
// of past engine states, so a policy re-push mid-call (setupIOSAudioManagement
// called again), a path switch, or an interruption cannot desync it:
// - session already active (previous hold, custom JS path, or the app) ->
// reconfigure only, never a second refcounted activation.
// - session inactive (fresh start, or the custom path deactivated it before a
// switch back to the native path) -> activate and take the hold.
- (NSInteger)applyAutomaticAudioSessionConfigForPlayout:(BOOL)isPlayoutEnabled recording:(BOOL)isRecordingEnabled {
NSDictionary *policy = self.automaticAudioSessionConfig;
BOOL nowActive = isPlayoutEnabled || isRecordingEnabled;

if (policy == nil) {
// The policy was cleared while we still hold an activation (a setup was
// torn down mid-call, or a deactivateOnStop:NO policy was abandoned).
// Release the orphaned hold at the next full stop so the shared
// activation count stays balanced. No configuration is applied because
// the native path is disarmed.
if (!nowActive && self.autoSessionHoldsActivation) {
os_log(ADMObserverLog(), "Native auto-config: releasing orphaned activation hold");
RTCAudioSession *session = [RTCAudioSession sharedInstance];
[session lockForConfiguration];
NSError *releaseError = nil;
[session setActive:NO error:&releaseError];
self.autoSessionHoldsActivation = NO;
[session unlockForConfiguration];
if (releaseError != nil) {
os_log_error(ADMObserverLog(),
"Native auto-config: orphaned hold release failed (continuing): %{public}@",
releaseError.localizedDescription);
}
}
return 0;
}

RTCAudioSession *session = [RTCAudioSession sharedInstance];
[session lockForConfiguration];

NSError *error = nil;
if (!nowActive) {
if (self.autoSessionHoldsActivation && [policy[@"deactivateOnStop"] boolValue]) {
os_log(ADMObserverLog(), "Native auto-config: deactivating audio session");
NSError *deactivateError = nil;
[session setActive:NO error:&deactivateError];
// RTCAudioSession decrements its activation count even when the OS
// session is already inactive (e.g. an interruption cleared it) or the
// call fails, so the hold is released in every outcome.
self.autoSessionHoldsActivation = NO;
if (deactivateError != nil) {
// Deliberately not propagated. By the time didDisable fires the
// engine's disable work is already done and the activation count is
// already released, so libwebrtc has nothing it could roll back. A
// non-zero return would only desync its logical engine state from
// hardware that is already stopped.
os_log_error(ADMObserverLog(),
"Native auto-config: deactivation failed (continuing): %{public}@",
deactivateError.localizedDescription);
}
}
} else {
// Recording uses the duplex (playAndRecord) config, while playout-only uses
// the playback config. Both are supplied by the SDK in automaticAudioSessionConfig.
NSDictionary *cfg = isRecordingEnabled ? policy[@"recording"] : policy[@"playout"];
RTCAudioSessionConfiguration *rtcConfig = [RTCAudioSessionConfiguration webRTCConfiguration];
if (cfg[@"audioCategory"] != nil) {
rtcConfig.category = [self avAudioSessionCategoryFromString:cfg[@"audioCategory"]];
}
if (cfg[@"audioMode"] != nil) {
rtcConfig.mode = [self avAudioSessionModeFromString:cfg[@"audioMode"]];
}
if (cfg[@"audioCategoryOptions"] != nil) {
rtcConfig.categoryOptions = [self avAudioSessionCategoryOptionsFromStrings:cfg[@"audioCategoryOptions"]];
}
os_log(ADMObserverLog(), "Native auto-config: setting category %{public}@", rtcConfig.category);
[session setConfiguration:rtcConfig error:&error];
if (error == nil && !session.isActive) {
BOOL hadHold = self.autoSessionHoldsActivation;
os_log(ADMObserverLog(), "Native auto-config: activating audio session");
[session setActive:YES error:&error];
if (error == nil) {
if (hadHold) {
// An interruption or an external deactivation (e.g. CallKit)
// cleared isActive while our activation count was still held, so
// the successful reactivation above stacked a second count onto
// the same hold. Drop the extra count. The drop is a pure
// decrement while the count sits above one, so the session stays
// active.
//
// Ordered activate-first deliberately. Releasing before
// reactivating would zero the count whenever the reactivation
// fails, and RTCAudioSession's interruption-end recovery
// deactivates outright at count zero. A failure must leave the
// prior hold untouched for that recovery to restore it.
os_log(ADMObserverLog(), "Native auto-config: dropping extra count after reactivating");
NSError *dropError = nil;
[session setActive:NO error:&dropError];
if (!session.isActive) {
// The drop deactivated, which means the held count had
// already been consumed by an external unmatched release and
// the drop just gave back the count the reactivation took.
// Reactivate and keep that single fresh count as the hold,
// so even a stale hold converges to a balanced state.
os_log(ADMObserverLog(), "Native auto-config: reactivating after dropping a stale hold");
[session setActive:YES error:&error];
}
}
if (error == nil) {
self.autoSessionHoldsActivation = YES;
}
}
}
}

[session unlockForConfiguration];

if (error != nil) {
os_log_error(ADMObserverLog(), "Native auto-config failed: %{public}@", error.localizedDescription);
return kAutomaticAudioSessionConfigError;
}

return 0;
}

// TODO: this friendly-string -> AVAudioSession mapping mirrors AudioUtils in the
// LiveKit React Native SDK. Keep the accepted values in sync, or consolidate by
// pushing raw AVAudioSession values across the bridge instead.
- (NSString *)avAudioSessionCategoryFromString:(NSString *)value {
static NSDictionary<NSString *, NSString *> *map;
static dispatch_once_t once;
dispatch_once(&once, ^{
map = @{
@"ambient" : AVAudioSessionCategoryAmbient,
@"soloAmbient" : AVAudioSessionCategorySoloAmbient,
@"playback" : AVAudioSessionCategoryPlayback,
@"record" : AVAudioSessionCategoryRecord,
@"playAndRecord" : AVAudioSessionCategoryPlayAndRecord,
@"multiRoute" : AVAudioSessionCategoryMultiRoute,
};
});
return map[value] ?: AVAudioSessionCategoryPlayAndRecord;
}

- (NSString *)avAudioSessionModeFromString:(NSString *)value {
static NSDictionary<NSString *, NSString *> *map;
static dispatch_once_t once;
dispatch_once(&once, ^{
map = @{
@"default" : AVAudioSessionModeDefault,
@"voiceChat" : AVAudioSessionModeVoiceChat,
@"videoChat" : AVAudioSessionModeVideoChat,
@"gameChat" : AVAudioSessionModeGameChat,
@"videoRecording" : AVAudioSessionModeVideoRecording,
@"measurement" : AVAudioSessionModeMeasurement,
@"moviePlayback" : AVAudioSessionModeMoviePlayback,
@"spokenAudio" : AVAudioSessionModeSpokenAudio,
@"voicePrompt" : AVAudioSessionModeVoicePrompt,
};
});
return map[value] ?: AVAudioSessionModeDefault;
}

- (AVAudioSessionCategoryOptions)avAudioSessionCategoryOptionsFromStrings:(NSArray<NSString *> *)options {
AVAudioSessionCategoryOptions result = 0;
for (NSString *option in options) {
if ([option isEqualToString:@"mixWithOthers"]) {
result |= AVAudioSessionCategoryOptionMixWithOthers;
} else if ([option isEqualToString:@"duckOthers"]) {
result |= AVAudioSessionCategoryOptionDuckOthers;
} else if ([option isEqualToString:@"allowBluetooth"]) {
result |= AVAudioSessionCategoryOptionAllowBluetooth;
} else if ([option isEqualToString:@"allowBluetoothA2DP"]) {
result |= AVAudioSessionCategoryOptionAllowBluetoothA2DP;
} else if ([option isEqualToString:@"allowAirPlay"]) {
result |= AVAudioSessionCategoryOptionAllowAirPlay;
} else if ([option isEqualToString:@"defaultToSpeaker"]) {
result |= AVAudioSessionCategoryOptionDefaultToSpeaker;
} else if ([option isEqualToString:@"interruptSpokenAudioAndMixWithOthers"]) {
result |= AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers;
}
}
return result;
}

@end

NS_ASSUME_NONNULL_END
11 changes: 11 additions & 0 deletions ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -303,4 +303,15 @@ @implementation WebRTCModule (RTCAudioDeviceModule)
return nil;
}

#pragma mark - Automatic Audio Session Configuration

// Pushes the native default audio-session policy (or nil to disable). When set,
// the observer configures the session natively in willEnable/didDisable instead
// of doing a JS round trip - removing the JS round trip from the default path.
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetAutomaticAudioSessionConfiguration
: (nullable NSDictionary *)config) {
self.audioDeviceModuleObserver.automaticAudioSessionConfig = config;
return nil;
}

@end
Loading