Use this SDK to add realtime video, audio and data features to your Flutter app. By connecting to LiveKit Cloud or a self-hosted server, you can quickly build applications such as multi-modal AI, live streaming, or video calls with just a few lines of code.
This package is published to pub.dev as livekit_client.
More Docs and guides are available at https://docs.livekit.io
LiveKit client SDK for Flutter is designed to work across all platforms supported by Flutter:
- Android
- iOS
- Web
- macOS
- Windows
- Linux
We built a multi-user conferencing app as an example in the example/ folder. LiveKit is compatible cross-platform: you could join the same room using any of our supported realtime SDKs.
Online demo: https://livekit.github.io/client-sdk-flutter/
Include this package to your pubspec.yaml
---
dependencies:
livekit_client: <version>
Camera and microphone usage need to be declared in your Info.plist file.
<dict>
...
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) uses your camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) uses your microphone</string>
Your application can still run the voice call when it is switched to the background if the background mode is enabled. Select the app target in Xcode, click the Capabilities tab, enable Background Modes, and check Audio, AirPlay, and Picture in Picture.
Your Info.plist should have the following entries.
<dict>
...
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
Since xcode 14 no longer supports 32bit builds, and our latest version is based on libwebrtc m104+ the iOS framework no longer supports 32bit builds, we strongly recommend upgrading to flutter 3.3.0+. if you are using flutter 3.0.0 or below, there is a high chance that your flutter app cannot be compiled correctly due to the missing i386 and arm 32bit framework #132 #172.
You can try to modify your {projects_dir}/ios/Podfile to fix this issue.
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
# Workaround for https://github.com/flutter/flutter/issues/64502
config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES' # <= this line
end
end
end
For iOS, the minimum supported deployment target is 12.1. You will need to add the following to your Podfile.
You may need to delete Podfile.lock and re-run pod install after updating deployment target.
We require a set of permissions that need to be declared in your AppManifest.xml. These are required by Flutter WebRTC, which we depend on.
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.your.package">
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
...
</manifest>
For using the bluetooth headset correctly on the android device, you need to add permission_handler to your project. And call the following code after launching your app for the first time.
import 'package:permission_handler/permission_handler.dart';
Future<void> _checkPermissions() async {
var status = await Permission.bluetooth.request();
if (status.isPermanentlyDenied) {
print('Bluetooth Permission disabled');
}
status = await Permission.bluetoothConnect.request();
if (status.isPermanentlyDenied) {
print('Bluetooth Connect Permission disabled');
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await _checkPermissions();
runApp(MyApp());
}
By default, we use the communication audio mode on Android which works best for two-way voice communication.
If your app is media playback oriented and does not need the use of the device’s microphone, you can use the media audio mode which will provide better audio quality.
import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc;
Future<void> _initializeAndroidAudioSettings() async {
await webrtc.WebRTC.initialize(options: {
'androidAudioConfiguration': webrtc.AndroidAudioConfiguration.media.toMap()
});
webrtc.Helper.setAndroidAudioConfiguration(
webrtc.AndroidAudioConfiguration.media);
}
void main() async {
await _initializeAudioSettings();
runApp(const MyApp());
}
Note: the audio routing will become controlled by the system and cannot be manually changed with functions like Hardware.selectAudioOutput.
In order to enable Flutter desktop development, please follow instructions here.
On Windows VS 2019 is needed (link in flutter docs will download VS 2022).
Connecting to a room, publish video & audio
final roomOptions = RoomOptions(
adaptiveStream: true,
dynacast: true,
// ... your room options
)
final room = Room();
// you can use `prepareConnection` to speed up connection.
await room.prepareConnection(url, token);
await room.connect(url, token, roomOptions: roomOptions);
try {
// video will fail when running in ios simulator
await room.localParticipant.setCameraEnabled(true);
} catch (error) {
print('Could not publish video, error: $error');
}
await room.localParticipant.setMicrophoneEnabled(true);
Screen sharing is supported across all platforms. You can enable it with:
room.localParticipant.setScreenShareEnabled(true);
On Android, you will have to use a media projection foreground service.
In our example, we use the flutter_background package to handle this. In the app’s AndroidManifest.xml file, declare the service with the appropriate types and permissions as following:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required permissions for screen share -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<application>
...
<service
android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
</application>
</manifest>
Before starting the background service and enabling screen share, you must call Helper.requestCapturePermission() from flutter_webrtc, and only proceed if it returns true. Refer to our example implementation for details.
On iOS, a broadcast extension is needed in order to capture screen content from other apps. See setup guide for instructions.
On dekstop you can use ScreenSelectDialog to select the window or screen you want to share.
try {
final source = await showDialog<DesktopCapturerSource>(
context: context,
builder: (context) => ScreenSelectDialog(),
);
if (source == null) {
print('cancelled screenshare');
return;
}
print('DesktopCapturerSource: ${source.id}');
var track = await LocalVideoTrack.createScreenShareTrack(
ScreenShareCaptureOptions(
sourceId: source.id,
maxFrameRate: 15.0,
),
);
await room.localParticipant.publishVideoTrack(track);
} catch (e) {
print('could not publish screen sharing: $e');
}
LiveKit supports end-to-end encryption for audio/video data sent over the network. By default, the native platform can support E2EE without any settings, but for flutter web, you need to use the following steps to create e2ee.worker.dart.js file.
# for example app
dart compile js web/e2ee.worker.dart -o example/web/e2ee.worker.dart.js -m
# for your project
export YOU_PROJECT_DIR=your_project_dir
git clone https://github.com/livekit/client-sdk-flutter.git
cd client-sdk-flutter && flutter pub get
dart compile js web/e2ee.worker.dart -o ${YOU_PROJECT_DIR}/web/e2ee.worker.dart.js -m
Advanced track manipulation
The setCameraEnabled/setMicrophoneEnabled helpers are wrappers around the Track API.
You can also manually create and publish tracks:
var localVideo = await LocalVideoTrack.createCameraTrack();
await room.localParticipant.publishVideoTrack(localVideo);
Each track can be rendered separately with the provided VideoTrackRenderer widget.
VideoTrack? track;
@override
Widget build(BuildContext context) {
if (track != null) {
return VideoTrackRenderer(track);
} else {
return Container(
color: Colors.grey,
);
}
}
Audio tracks are played automatically as long as you are subscribed to them.
LiveKit client makes it simple to build declarative UI that reacts to state changes. It notifies changes in two ways
ChangeNotifier – generic notification of changes. This is useful when you are building reactive UI and only care about changes that may impact rendering.
EventsListener<Event> – listener pattern to listen to specific events (see events.dart).
This example will show you how to use both to react to room events.
class RoomWidget extends StatefulWidget {
final Room room;
RoomWidget(this.room);
@override
State<StatefulWidget> createState() {
return _RoomState();
}
}
class _RoomState extends State<RoomWidget> {
late final EventsListener<RoomEvent> _listener = widget.room.createListener();
@override
void initState() {
super.initState();
// used for generic change updates
widget.room.addListener(_onChange);
// used for specific events
_listener
..on<RoomDisconnectedEvent>((_) {
// handle disconnect
})
..on<ParticipantConnectedEvent>((e) {
print("participant joined: ${e.participant.identity}");
})
}
@override
void dispose() {
// be sure to dispose listener to stop listening to further updates
_listener.dispose();
widget.room.removeListener(_onChange);
super.dispose();
}
void _onChange() {
// perform computations and then call setState
// setState will trigger a build
setState(() {
// your updates here
});
}
@override
Widget build(BuildContext context) {
// your build function
}
}
Similarly, you could do the same when rendering participants. Reacting to changes makes it possible to handle tracks published/unpublished or re-ordering participants in your UI.
class VideoView extends StatefulWidget {
final Participant participant;
VideoView(this.participant);
@override
State<StatefulWidget> createState() {
return _VideoViewState();
}
}
class _VideoViewState extends State<VideoView> {
TrackPublication? videoPub;
@override
void initState() {
super.initState();
widget.participant.addListener(this._onParticipantChanged);
// trigger initial change
_onParticipantChanged();
}
@override
void dispose() {
widget.participant.removeListener(this._onParticipantChanged);
super.dispose();
}
@override
void didUpdateWidget(covariant VideoView oldWidget) {
oldWidget.participant.removeListener(_onParticipantChanged);
widget.participant.addListener(_onParticipantChanged);
_onParticipantChanged();
super.didUpdateWidget(oldWidget);
}
void _onParticipantChanged() {
var subscribedVideos = widget.participant.videoTracks.values.where((pub) {
return pub.kind == TrackType.VIDEO &&
!pub.isScreenShare &&
pub.subscribed;
});
setState(() {
if (subscribedVideos.length > 0) {
var videoPub = subscribedVideos.first;
// when muted, show placeholder
if (!videoPub.muted) {
this.videoPub = videoPub;
return;
}
}
this.videoPub = null;
});
}
@override
Widget build(BuildContext context) {
var videoPub = this.videoPub;
if (videoPub != null) {
return VideoTrackRenderer(videoPub.track as VideoTrack);
} else {
return Container(
color: Colors.grey,
);
}
}
}
Mute, unmute local tracks
On LocalTrackPublications, you could control if the track is muted by setting its muted property. Changing the mute status will generate an onTrackMuted or onTrack Unmuted delegate call for the local participant. Other participant will receive the status change as well.
// mute track
trackPub.muted = true;
// unmute track
trackPub.muted = false;
When subscribing to remote tracks, the client has precise control over status of its subscriptions. You could subscribe or unsubscribe to a track, change its quality, or disabling the track temporarily.
These controls are accessible on the RemoteTrackPublication object.
Getting help / Contributing
Please join us on Slack to get help from our devs / community members. We welcome your contributions(PRs) and details can be discussed there.
Apache License 2.0
A huge thank you to flutter-webrtc for making it possible to use WebRTC in Flutter.
Download and/or contribute to the Flutter package on GitHub
Flutter Client SDK for LiveKit
https://github.com/livekit/client-sdk-flutter
255 forks.
416 stars.
71 open issues.
Recent commits:
- Do not fail audio capture when local recording pre-warm fails on Android (#1188)## Problem`LocalAudioTrack.startCapture()` calls `Native.startLocalRecording(…)`before publishing. On Android, `handleStartLocalRecording` catches every`Throwable` from `JavaAudioDeviceModule.prewarmRecording(options)` andreports it as `applyFailed`, which Dart converts into`AudioProcessingException` and rethrows out of `startCapture()`,aborting the publish.On devices where WebRTC cannot open an `AudioRecord` at that moment, thepre-warm throws `java.lang.AssertionError: Expected condition to betrue`, so the app sees“`AudioProcessingException(applyFailed): Expected condition to be true“`and the microphone is never published — although nothing is wrong withthe requested audio processing options, and the assertion text saysnothing an application can act on.### Why the assertion firesIn webrtc-sdk `144.7559.09`, the pinned native build:“`javapublic void prewarmRecording(@Nullable AudioProcessingOptions options) { audioInput.applyPlatformAudioProcessingOptions(options); audioInput.initRecordingIfNeeded(); // boolean result discarded audioInput.prewarmRecordingIfNeeded();}““initRecordingIfNeeded()` returns `false` when `AudioRecord` creation orinitialization fails — the microphone is already captured by anotherprocess, a vendor capture restriction applies, or `getMinBufferSize`rejects the configuration. That result is discarded, and`initAudioRecord()` has already called `releaseAudioResources()`, whichleaves `audioRecord` null. `prewarmRecordingIfNeeded()` then runs anywayand reaches `startRecordingImpl()`:“`javaassertTrue(audioThread == null);if (useAudioRecord) { assertTrue(audioRecord != null); // throws AssertionError here“`The real reason for the failure is only in logcat under`WebRtcAudioRecordExternal` ("Creation or initialization of audiorecorder failed.", "AudioRecord.getMinBufferSize failed: …").The same assertion has been reported against the Android SDK inlivekit/client-sdk-android#700.## FixTreat the pre-warm as best effort: log the failure and let capturecontinue.- **The processing options are already applied.**`applyPlatformAudioProcessingOptions(options)` is the first statement of`prewarmRecording`, so the reason the pre-warm exists — applyingcapture-time processing before WebRTC opens the microphone — is stillsatisfied when the later recorder preparation fails.- **WebRTC opens the recorder again on the real start path.** Becausethe failed attempt leaves `audioRecord` null, the native`initRecording()` re-enters `initRecordingImpl()` when the track isactually published, and failures there are reported through the audiodevice module's own recording callbacks. Aborting at pre-warm timeremoves that second attempt entirely, which turns a transiently busymicrophone into a permanent publish failure.- **A recorder that cannot be prepared is not an audio processingfailure.** Reporting it as `applyFailed` is misleading, and it makes`AudioProcessingException` mean two different things. After this changethe exception keeps its documented meaning, that the requested optionscould not be applied, and `AudioManager.getAudioProcessingState()` stillreports the resolved state.The iOS and macOS paths are unchanged.## ReproducingOn Android, hold the microphone with another application, then publishthe mic.- Before: `startCapture()` throws`AudioProcessingException(applyFailed): Expected condition to be true`and the track is never published.- After: a warning is logged under the `LiveKitPlugin` tag, the publishproceeds, and the recorder is opened again on the normal start path.## Notes- Includes a `patch` / `fixed` changeset.- No automated test: reproducing it needs a device on which`AudioRecord` creation fails, and the Android plugin has noinstrumentation-test harness here. `:livekit_client:compileDebugKotlin`and the existing `:livekit_client` unit tests pass., GitHub
- Fix use-after-free freeze in Linux TaskRunnerLinux::EnqueueTask (#1177)On Linux each AudioRendererSink / VisualizerSink owns a TaskRunnerLinux that posts to the GLib main loop with a raw this pointer. stopAudioRenderer / stopVisualizer destroy the sink synchronously, so any idle callback still pending is dispatched against freed memory and locks a mutex that no longer exists, which hangs the GTK thread permanently.The runner is now held via shared_ptr and the idle callback receives a heap-allocated weak_ptr through g_main_context_invoke_full, freed by the destroy notify. If the runner is gone by dispatch time the callback no-ops. Queued tasks also run outside tasks_mutex_ so a re-entrant EnqueueTask cannot deadlock.Verified with an AddressSanitizer harness driving the runner the way the sink does: pre-fix 0/20 runs survive, post-fix 20/20., GitHub
- Declare the UTF-8 byte length in the sendText stream header (#1184)**Description**sendText() currently calculates the total stream size usingtext.codeUnits.length.This does not correspond to the actual number of bytes produced when thetext is encoded as UTF-8. As a result, messages containing non-ASCIIcharacters such as accented characters or emojis can cause the receiverto reject the stream with:StreamError: read length exceeded total length specified in streamheaderFor example, é is one UTF-16 code unit but requires two bytes in UTF-8.**Fix**Use the UTF-8 encoded byte length when setting the total stream size:final textInBytes = utf8.encode(text);final totalTextLength = textInBytes.length;This ensures that the size declared in the stream header matches theactual UTF-8 payload size.**Testing**Tested with messages containing:ASCII charactersAccented characters (é, à, è, etc.)EmojisMultibyte Unicode charactersBefore this change, messages containing accented characters couldtrigger the StreamError. After the change, they are correctly receivedby the LiveKit Agents backend.**Related issue**Fixes #1054———Co-authored-by: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com>, GitHub
- Request microphone permission before audio capture starts (#1183)Flutter counterpart of client-sdk-swift #1085, matching its final mergedbehavior. Builds on #1182.## Whywebrtc-sdk/webrtc#265 (first shipped in `m144.7559.12`) removed theblocking mic permission request from the AudioEngine device. Thepre-enable check is now passive: it returns`kAudioEngineErrorInsufficientDevicePermission` (-9000) instead ofprompting, so requesting permission is the SDK's job.flutter-webrtc still pins `144.7559.10`, so this is not load-bearingyet. It is harmless there, since `getUserMedia` in flutter-webrtcalready prompts and the status is resolved before the device's blockingpath runs. Once flutter-webrtc bumps past `.12` and the pin herefollows, this is what keeps the current behavior.## What Flutter already hadflutter-webrtc's `getUserMedia` calls `AVCaptureDevicerequestAccessForMediaType:` and waits for the answer, so everylivekit_client mic path (publish, `restartTrack` on unmute, pre-connectaudio) already prompted before the audio device saw the track. That partof #1085 needs no port. #1182 already maps -9000 to`TrackCreateException` for the direct ADM entry points(`setEngineAvailability`, `startLocalRecording`).## What this addsThe one behavior from #1085 that was missing: only prompt while the appcan show the alert.- Native `ensureMicrophoneAccess` in `LiveKitPlugin.swift`: `authorized`passes, `denied`/`restricted` fail, `notDetermined` requests access. OniOS the request is only made while`UIApplication.shared.applicationState == .active`. An inactive orbackgrounded app (locked screen, CallKit wake, app switcher) has thealert deferred by the system, and awaiting it would suspend`getUserMedia` and the `_publishRunner` behind it, blocking camera andscreen share publishes for as long as the app stays there. Failing fastlets the next foreground attempt prompt normally. macOS can present theprompt regardless, so it always requests. No app extension concern here,the plugin is app-only.- The gate is skipped while engine input availability is disabled(`setEngineAvailability`, the CallKit flow), mirroring the same late fixin #1085: the audio device module defers opening input entirely and runsno permission check there, so gating would turn a working backgroundconnect into a `deviceAccessDenied` failure. The check reads theplugin's tracked availability value, so it also covers gating donenatively before the Flutter engine exists.- `LocalTrack.createStream` calls it for `AudioCaptureOptions` on Appleplatforms before `getUserMedia`. That is the Flutter choke point:`LocalAudioTrack.create()`, `restartTrack()` and`PreConnectAudioBuffer.startRecording()` all reach it. Since the promptin Flutter happens at `getUserMedia` rather than at capture start, thegate sits in front of that instead of in `startCapture` as in Swift.- Failures surface as `TrackCreateException` through the`deviceAccessDenied` code introduced in #1182.- Docs for `withPreConnectAudio` and`PreConnectAudioBuffer.startRecording` now say permission is requestedat recording start but only while the app is active, so callers runningat app launch should request it up front (matching the final #1085wording). `Native.setEngineAvailability` documents that permission isnot requested there and must be granted before input availability isrestored.## Testing- `flutter analyze`, `flutter test`, `dart format–set-exit-if-changed`, `import_sorter –exit-if-changed` clean.- Unit tests cover `Native.ensureMicrophoneAccess` (no-op whenunimplemented, propagates `deviceAccessDenied`). The `createStream` gateis behind `lkPlatformIsApple()` and not reachable from unit tests.- The example app builds for iOS (device SDK) and macOS with the change,re-verified after the rebase onto `main`. On-device run against a freshinstall (first-launch prompt) and a CallKit background wake still to do.Refs CLT-3243, client-sdk-swift#1085, GitHub
- Resolve a default audio session from engine state when no policy was pushed (#1182)## ProblemOn iOS the WebRTC audio engine refuses to enable recording unless theaudio session category permits input. Its pre-enable check returns`kAudioEngineErrorAudioSessionInvalidCategory` (-9001), which the SDKreported as `AudioProcessingException(applyFailed): Audio enginereturned error code: -9001`.`livekit_client` owns the iOS audio session since #1108(`LiveKitPlugin.swift` disables flutter_webrtc's session management atregistration). The native engine observer(`LKAudioEngineObserver.willEnableEngine`) is the right hook and runsbefore the engine's check, but it only applied a configuration that Darthad pushed. In automatic mode the only push site was `Room.connect`, soanything that started recording earlier met an empty cache, the observerreturned "proceed" with the session still `soloAmbient`, and the enginerolled back:- `Room.withPreConnectAudio`, which `SessionOptions.preConnectAudio`enables by default, so the Flutter agent starter failed on every "Startcall"- a pre-join microphone preview (#1165)- an engine start driven from native before the Flutter side exists, forexample the plugin's static `setEngineAvailability` on a CallKitkilled-state wakeBecause the preconnect throw happens before `connect`, the cache wasnever seeded and the failure repeated on every attempt.## How the Swift SDK handles this`AudioSessionEngineObserver.engineWillEnable` derives the sessionconfiguration from the requested engine state alone (`playAndRecord`presets while recording, `playback` for playout only), synchronouslyinside the engine's enable call. Nothing is configured "before connect".The engine asks, the observer configures, the engine starts. The Flutterplugin already has the same observer in the same place, it just had nobuilt-in policy.## Fix`LKAudioEngineObserver.effectiveConfigurationLocked` now resolves abuilt-in `playAndRecord` preset (`allowBluetooth | allowBluetoothA2DP |allowAirPlay`, `videoChat`) whenever nothing has been pushed andautomatic management is on. The existing playout-only `playback` branchapplies to it as well. Manual mode still leaves the session alone. TheDart-pushed policy becomes an override rather than a prerequisite, andfor the default `AudioSessionOptions.communication` it pushes the samevalues, so the connect-time push does not change the live session.The preset is built on a copy of the shared`RTCAudioSessionConfiguration.webRTC()` object, and it is best-effort:if applying it fails, the engine start proceeds and the ADM's ownpre-enable checks still gate recording, so apps whose own session wasalready valid are not newly rolled back with -4100. Only a policy theapp actually pushed keeps failing the engine start hard. The mode isfixed to `videoChat` because it matches the Dart default speakerpreference, and a non-default preference always arrives as a pushedpolicy whose mode already carries it, so the preset can never observeanything else.`LocalAudioTrack.startCapture` additionally pushes the resolved Dartpolicy to native before recording starts (cache-only while the engine isidle in automatic mode). Flutter-driven starts therefore always use thereal Dart policy, and the built-in preset only stands in for enginestarts that happen before the Flutter side exists, for example theplugin's static `setEngineAvailability` on a CallKit killed-state wake.The engine observer is also shared across plugin registrations now: asecond Flutter engine registering in the same process (add-to-app,`FlutterEngineGroup`) previously reset the pushed policy and managementmode silently, which the preset would have turned into an unwantedsession activation. Only the notification channel is rebound perregistration.## Error mappingAudio device module results now get their own error codes on the`startLocalRecording`, `setEngineAvailability`, `setMicrophoneMuteMode`and `stopLocalRecording` channels, mirroring client-sdk-swift's`checkAdmResult`. A mute-mode change can rebuild the engine and hit thesame pre-enable checks as a recording start, so all entry points surfacethe same failure the same way:| ADM result | Native error code | Dart exception || — | — | — || -9000 `InsufficientDevicePermission` | `deviceAccessDenied` |`TrackCreateException` || -9001 `AudioSessionInvalidCategory` | `audioSessionInvalidCategory` |`AudioSessionException` (new) || -4100 `FailedToConfigureAudioSession` | `audioSessionConfigureFailed`| `AudioSessionException` (new) || anything else | caller fallback (`applyFailed`,`setEngineAvailability`, …) | unchanged |`AudioSessionException` is a new public class with its own changesetentry.## Relation to #1179@MaxHeimbrock's #1179 diagnosed this first and fixes it by pushing thepolicy from `LocalAudioTrack.startCapture` (`prepareRecording()`). ThisPR ends up covering both layers: `startCapture` pushes the resolvedpolicy like #1179 does (without a Dart-side flag tracking native cachestate), and the native observer is additionally self-sufficient likeSwift's, so engine enables that never pass through Dart are covered too.With this merged, `prepareRecording()` becomes redundant.## Testing- Flutter agent starter on an iPhone 17 Pro (iOS 27.0): unpatched,`Start call` failed with -9001 on every attempt. Patched,`startCapture()` succeeds two seconds before the connect-time policypush, the preconnect buffer is sent to the agent, and the call connects.- `flutter analyze`, `flutter test`, `dart format–set-exit-if-changed`, `import_sorter –exit-if-changed` clean.- New unit tests cover the error mapping. The iOS-gated branches are notreachable from unit tests (`lkPlatform()` has no seam), same limitationas #1179 noted.Fixes #1165Refs #1042, GitHub
Provides the list of the opensource Flutter apps collection with GitHub repository.