From 3bb87874d59bb9276969daef356f5fefa01639c5 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 16:38:35 +0200 Subject: [PATCH] fix(ios): keep multitasking camera access across capture sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A capture session is created per getUserMedia call, and a new one starts without multitasking camera access — so toggling the camera off and on lost it, and the camera stopped producing frames once the app was backgrounded. The requested state is now remembered and applied whenever capture starts, including when it was requested before a capture session existed. Adds isIOSMultitaskingCameraAccessSupported, which reports whether the capture session in use supports it, so callers can tell an unsupported device from access that was never asked for. Co-Authored-By: Claude Opus 5 --- .../FlutterRTCMediaStream.m | 6 ++ .../FlutterWebRTCPlugin.m | 55 +++++++++++++++++++ .../FlutterWebRTCPlugin.h | 5 ++ lib/src/helper.dart | 8 +++ lib/src/native/ios/audio_management.dart | 16 ++++++ .../FlutterWebRTCPlugin.m | 6 ++ 6 files changed, 96 insertions(+) diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m index ee2b8ab369..bd0c1dc0f1 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m @@ -628,13 +628,19 @@ - (void)getUserVideo:(NSDictionary*)constraints [videoDevice unlockForConfiguration]; } + __weak FlutterWebRTCPlugin* weakSelf = self; [self.videoCapturer startCaptureWithDevice:videoDevice format:selectedFormat fps:selectedFps completionHandler:^(NSError* error) { if (error) { NSLog(@"Start capture error: %@", [error localizedDescription]); + return; } + + // This is a freshly created capture session, so it + // starts without multitasking camera access. + [weakSelf applyMultitaskingCameraAccessToCaptureSession]; }]; NSString* trackUUID = [[NSUUID UUID] UUIDString]; diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m index cb54ab10c6..872ac375e8 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m @@ -1149,6 +1149,8 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { BOOL enable = [argsMap[@"enable"] boolValue]; [self enableMultitaskingCameraAccess:enable result:result]; + } else if ([@"isIOSMultitaskingCameraAccessSupported" isEqualToString:call.method]) { + [self multitaskingCameraAccessSupported:result]; } else if ([@"mediaStreamTrackHasTorch" isEqualToString:call.method]) { NSDictionary* argsMap = call.arguments; NSString* trackId = argsMap[@"trackId"]; @@ -2416,8 +2418,61 @@ - (void)mediaStreamTrackSetVideoEffects:(nonnull NSString*)trackId } } +/// Applies the remembered multitasking camera access request to the capture +/// session in use. +/// +/// A capture session is created per `getUserMedia` call and starts without +/// multitasking camera access, so the request is applied again every time one +/// is created. +- (void)applyMultitaskingCameraAccessToCaptureSession { +#if !TARGET_OS_OSX + if (!self.multitaskingCameraAccessRequested) { + return; + } + + if (@available(iOS 16.0, *)) { + AVCaptureSession* session = self.videoCapturer.captureSession; + if (session == nil || !session.isMultitaskingCameraAccessSupported || + session.multitaskingCameraAccessEnabled) { + return; + } + + @try { + [session beginConfiguration]; + [session setMultitaskingCameraAccessEnabled:YES]; + [session commitConfiguration]; + } @catch (NSException* exception) { + NSLog(@"applyMultitaskingCameraAccessToCaptureSession: Exception occurred: %@ - %@", + exception.name, exception.reason); + } + } +#endif +} + +/// Reports whether the capture session in use supports camera access while +/// multitasking, or nil when there is no capture session to ask. +- (void)multitaskingCameraAccessSupported:(FlutterResult)result { +#if TARGET_OS_OSX + result(@NO); +#else + if (@available(iOS 16.0, *)) { + AVCaptureSession* session = self.videoCapturer.captureSession; + if (session == nil) { + result(nil); + return; + } + + result(@(session.isMultitaskingCameraAccessSupported)); + } else { + result(@NO); + } +#endif +} + - (void)enableMultitaskingCameraAccess:(BOOL)enable result:(FlutterResult)result { @try { + self.multitaskingCameraAccessRequested = enable; + AVCaptureSession* session = self.videoCapturer.captureSession; if (session == nil) { NSLog(@"enableMultitaskingCameraAccess: Capture session is nil."); diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h index 3def227d90..d1363208cf 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h @@ -90,6 +90,10 @@ typedef void (^CapturerStopHandler)(CompletionHandler _Nonnull handler); @property(nonatomic, strong) NSMutableDictionary* _Nonnull pausedTrackVolumes; @property(nonatomic) BOOL isAudioPlayoutPaused; +/// Whether camera access while multitasking was asked for. Remembered so that +/// a capture session created afterwards is started with it applied. +@property(nonatomic) BOOL multitaskingCameraAccessRequested; + - (void)mediaStreamTrackSetVideoEffects:(nonnull NSString*)trackId names:(nonnull NSArray*)names; - (RTCMediaStream* _Nullable)streamForId:(NSString* _Nonnull)streamId @@ -108,6 +112,7 @@ typedef void (^CapturerStopHandler)(CompletionHandler _Nonnull handler); - (RTCMediaStreamTrack* _Nullable)remoteTrackForId:(NSString* _Nonnull)trackId; - (BOOL)hasLocalAudioTrack; +- (void)applyMultitaskingCameraAccessToCaptureSession; - (void)ensureAudioSession; - (void)deactiveRtcAudioSession; diff --git a/lib/src/helper.dart b/lib/src/helper.dart index c0d35f1dff..d5980deca3 100644 --- a/lib/src/helper.dart +++ b/lib/src/helper.dart @@ -224,6 +224,14 @@ class Helper { static Future enableIOSMultitaskingCameraAccess(bool enable) => IosAudioManagement.enableMultitaskingCameraAccess(enable); + /// Whether the capture session in use supports camera access while + /// multitasking (iOS only). + /// + /// Returns `null` when there is no capture session to ask, which is the case + /// until the camera is started. + static Future isIOSMultitaskingCameraAccessSupported() => + IosAudioManagement.isMultitaskingCameraAccessSupported(); + /// Trigger the iOS audio route selection UI (iOS only). static Future triggeriOSAudioRouteSelectionUI() => IosAudioManagement.triggerAudioRouteSelectionUI(); diff --git a/lib/src/native/ios/audio_management.dart b/lib/src/native/ios/audio_management.dart index 3d24748def..13a5f578a0 100644 --- a/lib/src/native/ios/audio_management.dart +++ b/lib/src/native/ios/audio_management.dart @@ -84,4 +84,20 @@ class IosAudioManagement { 'enableMultitaskingCameraAccess is only supported for iOS'); } } + + /// Whether the capture session in use supports camera access while + /// multitasking (iOS only). + /// + /// Returns `null` when there is no capture session to ask, which is the case + /// until the camera is started. + static Future isMultitaskingCameraAccessSupported() async { + if (WebRTC.platformIsIOS) { + return await WebRTC.invokeMethod( + 'isIOSMultitaskingCameraAccessSupported', + ); + } else { + throw Exception( + 'isMultitaskingCameraAccessSupported is only supported for iOS'); + } + } } diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m index e7fec46cbb..e848ec43ba 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m @@ -975,6 +975,8 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { BOOL enable = [argsMap[@"enable"] boolValue]; [self enableMultitaskingCameraAccess:enable result:result]; + } else if ([@"isIOSMultitaskingCameraAccessSupported" isEqualToString:call.method]) { + [self multitaskingCameraAccessSupported:result]; } else if ([@"mediaStreamTrackHasTorch" isEqualToString:call.method]) { NSDictionary* argsMap = call.arguments; NSString* trackId = argsMap[@"trackId"]; @@ -2151,6 +2153,10 @@ - (void)enableMultitaskingCameraAccess:(BOOL)enable result:(FlutterResult)result result(@NO); } +- (void)multitaskingCameraAccessSupported:(FlutterResult)result { + result(@NO); +} + - (void)mediaStreamGetTracks:(NSString*)streamId result:(FlutterResult)result { RTCMediaStream* stream = [self streamForId:streamId peerConnectionId:@""]; if (stream) {