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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,5 @@ macos/Frameworks

# SPM
.build/
.swiftpm/
.swiftpm/
example/android/build
12 changes: 9 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@

# Changelog

[3.0.1] - upcoming

**New**
[3.0.1] - 2026.07.10

* [iOS] Added an onAudioRouteChangeEvent that fires whenever audio output route changes.
* Synced flutter-webrtc v1.5.2
* [iOS] fix: audio session output port override for speaker toggle (#1941)
* [Android] fix: tolerate Qualcomm/Hisi encoders in VideoFileRenderer and guard muxer writes against invalid sample buffers. (#2031 and #2030)
* [Android] feat: add fullScreenOnly option to requestCapturePermission to force entire-screen capture on API 34+ (#2079)
* [Android] fix: recreate the texture surface when the incoming frame size changes, so simulcast layer upgrades no longer stay blurry. (#2085)
* [Android] Support AGP 9's built-in Kotlin: apply the Kotlin Gradle Plugin only when built-in Kotlin is inactive (AGP < 9, or AGP 9 with `android.builtInKotlin=false`), set the JVM target through the `kotlin { compilerOptions {} }` DSL when available, and fall back to the legacy `kotlinOptions` DSL for apps still on Kotlin Gradle Plugin 1.8.x. (#2075)
* [Windows/Linux] chore: drive prebuilt libwebrtc download from third_party/libwebrtc_version.ini. Bump WebRTC version to 144.7559.09. (#2061 and #2078)
* [Windows/Linux] fix: map echoCancellation/noiseSuppression/autoGainControl constraints to RTCAudioOptions (#2068)

[3.0.0] - 2026-05-14

Expand Down
34 changes: 28 additions & 6 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ group 'io.getstream.webrtc.flutter'
version '1.0-SNAPSHOT'

buildscript {
ext.kotlin_version = '1.9.10'
ext.kotlin_version = '2.2.20'
repositories {
google()
mavenCentral()
maven { url = uri("https://central.sonatype.com/repository/maven-snapshots/") }
}

dependencies {
classpath 'com.android.tools.build:gradle:7.1.1'
classpath 'com.android.tools.build:gradle:8.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
Expand All @@ -25,7 +25,18 @@ rootProject.allprojects {
}

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

// AGP 9's built-in Kotlin compiles Kotlin itself and rejects the Kotlin Gradle
// Plugin. Apply KGP only when built-in Kotlin is NOT active: that means AGP < 9,
// or AGP 9 with android.builtInKotlin=false (the configuration Flutter currently
// ships by default while the ecosystem migrates).
def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0] as int
def builtInKotlinActive = agpMajor >= 9 &&
(!project.hasProperty('android.builtInKotlin') ||
Boolean.parseBoolean(project.property('android.builtInKotlin').toString()))
if (!builtInKotlinActive) {
apply plugin: 'kotlin-android'
}

android {
if (project.android.hasProperty("namespace")) {
Expand All @@ -47,15 +58,26 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}

kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
// Configure the Kotlin JVM target. The compilerOptions DSL requires KGP 1.9+ or
// AGP 9 built-in Kotlin; older Flutter app templates ship KGP 1.8.x, which only
// supports the legacy kotlinOptions DSL.
def kotlinExt = project.extensions.findByName('kotlin')
if (kotlinExt?.hasProperty('compilerOptions')) {
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8
}
}
} else {
android.kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
}

dependencies {
implementation("io.getstream:stream-video-webrtc-android:145.9.0")
implementation 'com.github.davidliu:audioswitch:89582c47c9a04c62f90aa5e57251af4800a62c9a'
implementation 'androidx.annotation:annotation:1.1.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import android.hardware.camera2.CameraManager;
import android.media.AudioDeviceInfo;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionConfig;
import android.media.projection.MediaProjectionManager;
import android.net.Uri;
import android.os.Build;
Expand Down Expand Up @@ -110,6 +111,7 @@ public class GetUserMediaImpl {
private static final String PROJECTION_DATA = "PROJECTION_DATA";
private static final String RESULT_RECEIVER = "RESULT_RECEIVER";
private static final String REQUEST_CODE = "REQUEST_CODE";
private static final String FULL_SCREEN_ONLY = "FULL_SCREEN_ONLY";

static final String TAG = FlutterWebRTCPlugin.TAG;

Expand Down Expand Up @@ -138,6 +140,10 @@ public class GetUserMediaImpl {
private int audioChannelCount = 1;

public void screenRequestPermissions(ResultReceiver resultReceiver) {
screenRequestPermissions(resultReceiver, false);
}

public void screenRequestPermissions(ResultReceiver resultReceiver, boolean fullScreenOnly) {
mediaProjectionData = null;
final Activity activity = stateProvider.getActivity();
if (activity == null) {
Expand All @@ -148,6 +154,7 @@ public void screenRequestPermissions(ResultReceiver resultReceiver) {
Bundle args = new Bundle();
args.putParcelable(RESULT_RECEIVER, resultReceiver);
args.putInt(REQUEST_CODE, CAPTURE_PERMISSION_REQUEST_CODE);
args.putBoolean(FULL_SCREEN_ONLY, fullScreenOnly);

ScreenRequestPermissionsFragment fragment = new ScreenRequestPermissionsFragment();
fragment.setArguments(args);
Expand All @@ -166,6 +173,10 @@ public void screenRequestPermissions(ResultReceiver resultReceiver) {
}

public void requestCapturePermission(final Result result) {
requestCapturePermission(result, false);
}

public void requestCapturePermission(final Result result, final boolean fullScreenOnly) {
screenRequestPermissions(
new ResultReceiver(new Handler(Looper.getMainLooper())) {
@Override
Expand All @@ -178,7 +189,8 @@ protected void onReceiveResult(int requestCode, Bundle resultData) {
result.success(false);
}
}
});
},
fullScreenOnly);
}

public static class ScreenRequestPermissionsFragment extends Fragment {
Expand Down Expand Up @@ -207,6 +219,7 @@ private void checkSelfPermissions(boolean requestPermissions) {

resultReceiver = args.getParcelable(RESULT_RECEIVER);
requestCode = args.getInt(REQUEST_CODE);
boolean fullScreenOnly = args.getBoolean(FULL_SCREEN_ONLY, false);

hasRequestedPermission = true;

Expand All @@ -216,13 +229,13 @@ private void checkSelfPermissions(boolean requestPermissions) {
new Handler(Looper.getMainLooper()).postDelayed(() -> {
Activity currentActivity = getActivity();
if (currentActivity != null && !currentActivity.isFinishing() && isAdded()) {
requestStart(currentActivity, requestCode);
requestStart(currentActivity, requestCode, fullScreenOnly);
}
}, 100);
}
}

public void requestStart(Activity activity, int requestCode) {
public void requestStart(Activity activity, int requestCode, boolean fullScreenOnly) {
if (android.os.Build.VERSION.SDK_INT < minAPILevel) {
Log.w(
TAG,
Expand All @@ -231,9 +244,21 @@ public void requestStart(Activity activity, int requestCode) {
MediaProjectionManager mediaProjectionManager =
(MediaProjectionManager) activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE);

// On Android 14+ (API 34), opt in to capturing the entire display so the
// consent dialog no longer offers the single-app option.
Intent captureIntent;
if (fullScreenOnly
&& android.os.Build.VERSION.SDK_INT
>= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
captureIntent =
mediaProjectionManager.createScreenCaptureIntent(
MediaProjectionConfig.createConfigForDefaultDisplay());
} else {
captureIntent = mediaProjectionManager.createScreenCaptureIntent();
}

// call for the projection manager
this.startActivityForResult(
mediaProjectionManager.createScreenCaptureIntent(), requestCode);
this.startActivityForResult(captureIntent, requestCode);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1206,7 +1206,9 @@ public void onInterruptionEnd() {
"unknown factoryId " + factoryId, result);
break;
}
nf.getUserMediaImpl.requestCapturePermission(result);
Boolean fullScreenOnlyArg = call.argument("fullScreenOnly");
boolean fullScreenOnly = fullScreenOnlyArg != null && fullScreenOnlyArg;
nf.getUserMediaImpl.requestCapturePermission(result, fullScreenOnly);
break;
}
case "getDisplayMedia": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,46 @@ public void pauseVideo() {
// VideoSink interface.
@Override
public void onFrame(VideoFrame frame) {
if(surface == null) {
producer.setSize(frame.getRotatedWidth(),frame.getRotatedHeight());
surface = producer.getSurface();
createEglSurface(surface);
synchronized (surfaceLock) {
if(surface == null) {
producer.setSize(frame.getRotatedWidth(),frame.getRotatedHeight());
surface = producer.getSurface();
createEglSurface(surface);
} else if (frameSizeChanged(frame)) {
// The producer's backing buffers are fixed-size: setSize() only takes
// effect for a Surface obtained afterwards. Without recreating the EGL
// surface here, a simulcast layer upgrade keeps rendering into the old
// low-resolution buffer and the video stays blurry.
releaseEglSurface(() -> {});
// Clear the field before re-obtaining: if getSurface() throws, the
// next frame takes the surface == null path and recreates cleanly
// rather than rendering into the already-released surface.
surface = null;
producer.setSize(frame.getRotatedWidth(), frame.getRotatedHeight());
surface = producer.getSurface();
createEglSurface(surface);
}
}
updateFrameDimensionsAndReportEvents(frame);
super.onFrame(frame);
}

private boolean frameSizeChanged(VideoFrame frame) {
synchronized (layoutLock) {
return !isRenderingPaused
&& (rotatedFrameWidth != frame.getRotatedWidth()
|| rotatedFrameHeight != frame.getRotatedHeight());
}
}

// Guards surface lifecycle transitions: creation/recreation happens on the
// frame delivery thread while destruction arrives on the main thread via
// the producer's onSurfaceCleanup callback. Serializing the two prevents a
// frame from re-creating the EGL surface against a Surface the producer is
// concurrently invalidating. surfaceDestroyed() blocks on the EGL release
// while holding this lock; the latch is signaled by the EglRenderer render
// thread, which never acquires it, so the wait cannot deadlock.
private final Object surfaceLock = new Object();
private Surface surface = null;

private TextureRegistry.SurfaceProducer producer;
Expand All @@ -130,10 +161,12 @@ public void onSurfaceCleanup() {

public void onSurfaceCleanup() {
ThreadUtils.checkIsOnMainThread();
final CountDownLatch completionLatch = new CountDownLatch(1);
releaseEglSurface(completionLatch::countDown);
ThreadUtils.awaitUninterruptibly(completionLatch);
surface = null;
synchronized (surfaceLock) {
final CountDownLatch completionLatch = new CountDownLatch(1);
releaseEglSurface(completionLatch::countDown);
ThreadUtils.awaitUninterruptibly(completionLatch);
surface = null;
}
}

// Update frame dimensions and report any changes to |rendererEvents|.
Expand All @@ -157,7 +190,6 @@ private void updateFrameDimensionsAndReportEvents(VideoFrame frame) {
}
rotatedFrameWidth = frame.getRotatedWidth();
rotatedFrameHeight = frame.getRotatedHeight();
producer.setSize(rotatedFrameWidth, rotatedFrameHeight);
frameRotation = frame.getRotation();
}
}
Expand Down
Loading
Loading