Skip to content

Commit b505d41

Browse files
authored
[camera_android_camerax] use MediaSettings::fps for image preview, streaming, and video recording (#10301)
Implements `MediaSettings::fps` in `camera_android_camerax` for image preview, image streaming, and video recording. - Resolves [#167719](flutter/flutter#167719): implements the feature instead of adding documentation that it does not work. - Partially resolves [#176148](flutter/flutter#176148): only implements for `camera_android_camerax`, and uses the existing `MediaSettings::fps` instead of adding parameters to `CameraImageStreamOptions` ## Pre-Review Checklist **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent 8168d9c commit b505d41

File tree

16 files changed

+397
-43
lines changed

16 files changed

+397
-43
lines changed

packages/camera/camera_android_camerax/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.6.25
2+
3+
* Adds support for `MediaSettings.fps` for camera preview, image streaming, and video recording.
4+
15
## 0.6.24+4
26

37
* Allows for video recording without audio when permission RECORD_AUDIO is denied.

packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3193,7 +3193,8 @@ abstract class PigeonApiDeviceOrientationManager(
31933193
abstract class PigeonApiPreview(open val pigeonRegistrar: CameraXLibraryPigeonProxyApiRegistrar) {
31943194
abstract fun pigeon_defaultConstructor(
31953195
resolutionSelector: androidx.camera.core.resolutionselector.ResolutionSelector?,
3196-
targetRotation: Long?
3196+
targetRotation: Long?,
3197+
targetFpsRange: android.util.Range<*>?
31973198
): androidx.camera.core.Preview
31983199

31993200
abstract fun resolutionSelector(
@@ -3249,10 +3250,12 @@ abstract class PigeonApiPreview(open val pigeonRegistrar: CameraXLibraryPigeonPr
32493250
val resolutionSelectorArg =
32503251
args[1] as androidx.camera.core.resolutionselector.ResolutionSelector?
32513252
val targetRotationArg = args[2] as Long?
3253+
val targetFpsRangeArg = args[3] as android.util.Range<*>?
32523254
val wrapped: List<Any?> =
32533255
try {
32543256
api.pigeonRegistrar.instanceManager.addDartCreatedInstance(
3255-
api.pigeon_defaultConstructor(resolutionSelectorArg, targetRotationArg),
3257+
api.pigeon_defaultConstructor(
3258+
resolutionSelectorArg, targetRotationArg, targetFpsRangeArg),
32563259
pigeon_identifierArg)
32573260
listOf(null)
32583261
} catch (exception: Throwable) {
@@ -3433,7 +3436,8 @@ abstract class PigeonApiVideoCapture(
34333436
) {
34343437
/** Create a `VideoCapture` associated with the given `VideoOutput`. */
34353438
abstract fun withOutput(
3436-
videoOutput: androidx.camera.video.VideoOutput
3439+
videoOutput: androidx.camera.video.VideoOutput,
3440+
targetFpsRange: android.util.Range<*>?
34373441
): androidx.camera.video.VideoCapture<*>
34383442

34393443
/** Gets the VideoOutput associated with this VideoCapture. */
@@ -3462,10 +3466,11 @@ abstract class PigeonApiVideoCapture(
34623466
val args = message as List<Any?>
34633467
val pigeon_identifierArg = args[0] as Long
34643468
val videoOutputArg = args[1] as androidx.camera.video.VideoOutput
3469+
val targetFpsRangeArg = args[2] as android.util.Range<*>?
34653470
val wrapped: List<Any?> =
34663471
try {
34673472
api.pigeonRegistrar.instanceManager.addDartCreatedInstance(
3468-
api.withOutput(videoOutputArg), pigeon_identifierArg)
3473+
api.withOutput(videoOutputArg, targetFpsRangeArg), pigeon_identifierArg)
34693474
listOf(null)
34703475
} catch (exception: Throwable) {
34713476
CameraXLibraryPigeonUtils.wrapError(exception)
@@ -5058,6 +5063,7 @@ abstract class PigeonApiImageAnalysis(
50585063
abstract fun pigeon_defaultConstructor(
50595064
resolutionSelector: androidx.camera.core.resolutionselector.ResolutionSelector?,
50605065
targetRotation: Long?,
5066+
targetFpsRange: android.util.Range<*>?,
50615067
outputImageFormat: Long?
50625068
): androidx.camera.core.ImageAnalysis
50635069

@@ -5097,12 +5103,16 @@ abstract class PigeonApiImageAnalysis(
50975103
val resolutionSelectorArg =
50985104
args[1] as androidx.camera.core.resolutionselector.ResolutionSelector?
50995105
val targetRotationArg = args[2] as Long?
5100-
val outputImageFormatArg = args[3] as Long?
5106+
val targetFpsRangeArg = args[3] as android.util.Range<*>?
5107+
val outputImageFormatArg = args[4] as Long?
51015108
val wrapped: List<Any?> =
51025109
try {
51035110
api.pigeonRegistrar.instanceManager.addDartCreatedInstance(
51045111
api.pigeon_defaultConstructor(
5105-
resolutionSelectorArg, targetRotationArg, outputImageFormatArg),
5112+
resolutionSelectorArg,
5113+
targetRotationArg,
5114+
targetFpsRangeArg,
5115+
outputImageFormatArg),
51065116
pigeon_identifierArg)
51075117
listOf(null)
51085118
} catch (exception: Throwable) {

packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageAnalysisProxyApi.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44

55
package io.flutter.plugins.camerax;
66

7+
import android.hardware.camera2.CaptureRequest;
8+
import android.util.Range;
79
import androidx.annotation.NonNull;
810
import androidx.annotation.Nullable;
11+
import androidx.annotation.OptIn;
12+
import androidx.camera.camera2.interop.Camera2Interop;
13+
import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
914
import androidx.camera.core.ImageAnalysis;
1015
import androidx.camera.core.resolutionselector.ResolutionSelector;
1116
import androidx.core.content.ContextCompat;
@@ -18,11 +23,15 @@
1823
class ImageAnalysisProxyApi extends PigeonApiImageAnalysis {
1924
static final long CLEAR_FINALIZED_WEAK_REFERENCES_INTERVAL_FOR_IMAGE_ANALYSIS = 1000;
2025

26+
// Range<?> is defined as Range<Integer> in pigeon.
27+
@SuppressWarnings("unchecked")
28+
@OptIn(markerClass = ExperimentalCamera2Interop.class)
2129
@NonNull
2230
@Override
2331
public ImageAnalysis pigeon_defaultConstructor(
2432
@Nullable ResolutionSelector resolutionSelector,
2533
@Nullable Long targetRotation,
34+
@Nullable Range<?> targetFpsRange,
2635
@Nullable Long outputImageFormat) {
2736
final ImageAnalysis.Builder builder = new ImageAnalysis.Builder();
2837
if (resolutionSelector != null) {
@@ -36,6 +45,12 @@ public ImageAnalysis pigeon_defaultConstructor(
3645
builder.setOutputImageFormat(outputImageFormat.intValue());
3746
}
3847

48+
if (targetFpsRange != null) {
49+
Camera2Interop.Extender<ImageAnalysis> extender = new Camera2Interop.Extender<>(builder);
50+
extender.setCaptureRequestOption(
51+
CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, (Range<Integer>) targetFpsRange);
52+
}
53+
3954
return builder.build();
4055
}
4156

packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PreviewProxyApi.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44

55
package io.flutter.plugins.camerax;
66

7+
import android.hardware.camera2.CaptureRequest;
8+
import android.util.Range;
79
import android.view.Surface;
810
import androidx.annotation.NonNull;
911
import androidx.annotation.Nullable;
12+
import androidx.annotation.OptIn;
13+
import androidx.camera.camera2.interop.Camera2Interop;
14+
import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
1015
import androidx.camera.core.Preview;
1116
import androidx.camera.core.ResolutionInfo;
1217
import androidx.camera.core.SurfaceRequest;
@@ -35,17 +40,29 @@ public ProxyApiRegistrar getPigeonRegistrar() {
3540
return (ProxyApiRegistrar) super.getPigeonRegistrar();
3641
}
3742

43+
// Range<?> is defined as Range<Integer> in pigeon.
44+
@SuppressWarnings("unchecked")
45+
@OptIn(markerClass = ExperimentalCamera2Interop.class)
3846
@NonNull
3947
@Override
4048
public Preview pigeon_defaultConstructor(
41-
@Nullable ResolutionSelector resolutionSelector, @Nullable Long targetRotation) {
49+
@Nullable ResolutionSelector resolutionSelector,
50+
@Nullable Long targetRotation,
51+
@Nullable Range<?> targetFpsRange) {
4252
final Preview.Builder builder = new Preview.Builder();
4353
if (targetRotation != null) {
4454
builder.setTargetRotation(targetRotation.intValue());
4555
}
4656
if (resolutionSelector != null) {
4757
builder.setResolutionSelector(resolutionSelector);
4858
}
59+
60+
if (targetFpsRange != null) {
61+
Camera2Interop.Extender<Preview> extender = new Camera2Interop.Extender<>(builder);
62+
extender.setCaptureRequestOption(
63+
CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, (Range<Integer>) targetFpsRange);
64+
}
65+
4966
return builder.build();
5067
}
5168

packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoCaptureProxyApi.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44

55
package io.flutter.plugins.camerax;
66

7+
import android.hardware.camera2.CaptureRequest;
8+
import android.util.Range;
79
import androidx.annotation.NonNull;
10+
import androidx.annotation.Nullable;
11+
import androidx.annotation.OptIn;
12+
import androidx.camera.camera2.interop.Camera2Interop;
13+
import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
814
import androidx.camera.video.VideoCapture;
915
import androidx.camera.video.VideoOutput;
1016

@@ -18,10 +24,23 @@ class VideoCaptureProxyApi extends PigeonApiVideoCapture {
1824
super(pigeonRegistrar);
1925
}
2026

27+
// Range<?> is defined as Range<Integer> in pigeon.
28+
@SuppressWarnings("unchecked")
29+
@OptIn(markerClass = ExperimentalCamera2Interop.class)
2130
@NonNull
2231
@Override
23-
public VideoCapture<?> withOutput(@NonNull VideoOutput videoOutput) {
24-
return VideoCapture.withOutput(videoOutput);
32+
public VideoCapture<?> withOutput(
33+
@NonNull VideoOutput videoOutput, @Nullable Range<?> targetFpsRange) {
34+
VideoCapture.Builder<VideoOutput> builder = new VideoCapture.Builder<>(videoOutput);
35+
36+
if (targetFpsRange != null) {
37+
Camera2Interop.Extender<VideoCapture<VideoOutput>> extender =
38+
new Camera2Interop.Extender<>(builder);
39+
extender.setCaptureRequestOption(
40+
CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, (Range<Integer>) targetFpsRange);
41+
}
42+
43+
return builder.build();
2544
}
2645

2746
@NonNull

packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageAnalysisTest.java

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,55 @@
1111
import static org.mockito.Mockito.verify;
1212
import static org.mockito.Mockito.when;
1313

14+
import android.hardware.camera2.CaptureRequest;
15+
import android.util.Range;
1416
import android.view.Surface;
17+
import androidx.camera.camera2.interop.Camera2Interop;
1518
import androidx.camera.core.ImageAnalysis;
1619
import androidx.camera.core.ImageAnalysis.Analyzer;
1720
import androidx.camera.core.resolutionselector.ResolutionSelector;
1821
import androidx.core.content.ContextCompat;
1922
import java.util.concurrent.Executor;
2023
import org.junit.Test;
2124
import org.junit.runner.RunWith;
25+
import org.mockito.MockedConstruction;
2226
import org.mockito.MockedStatic;
2327
import org.mockito.Mockito;
2428
import org.mockito.stubbing.Answer;
2529
import org.robolectric.RobolectricTestRunner;
2630

2731
@RunWith(RobolectricTestRunner.class)
2832
public class ImageAnalysisTest {
33+
// Due to Java's Type Erasure, we cannot get a class literal (e.g., Extender<T>.class) for a
34+
// parameterized type. We must use the raw type (Extender.class) which forces the 'unchecked' and
35+
// 'rawtypes' warnings. The runtime logic handles the type safely.
36+
@SuppressWarnings({"unchecked", "rawtypes"})
2937
@Test
3038
public void pigeon_defaultConstructor_createsExpectedImageAnalysisInstance() {
3139
final PigeonApiImageAnalysis api = new TestProxyApiRegistrar().getPigeonApiImageAnalysis();
3240

3341
final ResolutionSelector mockResolutionSelector = new ResolutionSelector.Builder().build();
3442
final long targetResolution = Surface.ROTATION_0;
43+
final Range<Integer> targetFpsRange = new Range<>(30, 30);
3544
final long outputImageFormat = ImageAnalysis.OUTPUT_IMAGE_FORMAT_NV21;
36-
final ImageAnalysis imageAnalysis =
37-
api.pigeon_defaultConstructor(mockResolutionSelector, targetResolution, outputImageFormat);
3845

39-
assertEquals(imageAnalysis.getResolutionSelector(), mockResolutionSelector);
40-
assertEquals(imageAnalysis.getTargetRotation(), Surface.ROTATION_0);
41-
assertEquals(imageAnalysis.getOutputImageFormat(), ImageAnalysis.OUTPUT_IMAGE_FORMAT_NV21);
46+
try (MockedConstruction<Camera2Interop.Extender> mockCamera2InteropExtender =
47+
Mockito.mockConstruction(
48+
Camera2Interop.Extender.class,
49+
(mock, context) -> {
50+
when(mock.setCaptureRequestOption(
51+
CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, targetFpsRange))
52+
.thenReturn(mock);
53+
})) {
54+
final ImageAnalysis imageAnalysis =
55+
api.pigeon_defaultConstructor(
56+
mockResolutionSelector, targetResolution, targetFpsRange, outputImageFormat);
57+
58+
assertEquals(mockResolutionSelector, imageAnalysis.getResolutionSelector());
59+
assertEquals(Surface.ROTATION_0, imageAnalysis.getTargetRotation());
60+
assertEquals(1, mockCamera2InteropExtender.constructed().size());
61+
assertEquals(ImageAnalysis.OUTPUT_IMAGE_FORMAT_NV21, imageAnalysis.getOutputImageFormat());
62+
}
4263
}
4364

4465
@Test

packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PreviewTest.java

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313
import static org.mockito.Mockito.verifyNoMoreInteractions;
1414
import static org.mockito.Mockito.when;
1515

16+
import android.hardware.camera2.CaptureRequest;
17+
import android.util.Range;
1618
import android.util.Size;
1719
import android.view.Surface;
1820
import androidx.annotation.NonNull;
21+
import androidx.camera.camera2.interop.Camera2Interop;
1922
import androidx.camera.core.Preview;
2023
import androidx.camera.core.ResolutionInfo;
2124
import androidx.camera.core.SurfaceRequest;
@@ -26,21 +29,39 @@
2629
import org.junit.Test;
2730
import org.junit.runner.RunWith;
2831
import org.mockito.ArgumentCaptor;
32+
import org.mockito.MockedConstruction;
33+
import org.mockito.Mockito;
2934
import org.robolectric.RobolectricTestRunner;
3035

3136
@RunWith(RobolectricTestRunner.class)
3237
public class PreviewTest {
38+
// Due to Java's Type Erasure, we cannot get a class literal (e.g., Extender<T>.class) for a
39+
// parameterized type. We must use the raw type (Extender.class) which forces the 'unchecked' and
40+
// 'rawtypes' warnings. The runtime logic handles the type safely.
41+
@SuppressWarnings({"unchecked", "rawtypes"})
3342
@Test
3443
public void pigeon_defaultConstructor_createsPreviewWithCorrectConfiguration() {
3544
final PigeonApiPreview api = new TestProxyApiRegistrar().getPigeonApiPreview();
3645

3746
final ResolutionSelector mockResolutionSelector = new ResolutionSelector.Builder().build();
3847
final long targetResolution = Surface.ROTATION_0;
39-
final Preview instance =
40-
api.pigeon_defaultConstructor(mockResolutionSelector, targetResolution);
41-
42-
assertEquals(instance.getResolutionSelector(), mockResolutionSelector);
43-
assertEquals(instance.getTargetRotation(), Surface.ROTATION_0);
48+
final Range<Integer> targetFpsRange = new Range<>(30, 30);
49+
50+
try (MockedConstruction<Camera2Interop.Extender> mockCamera2InteropExtender =
51+
Mockito.mockConstruction(
52+
Camera2Interop.Extender.class,
53+
(mock, context) -> {
54+
when(mock.setCaptureRequestOption(
55+
CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, targetFpsRange))
56+
.thenReturn(mock);
57+
})) {
58+
final Preview instance =
59+
api.pigeon_defaultConstructor(mockResolutionSelector, targetResolution, targetFpsRange);
60+
61+
assertEquals(mockResolutionSelector, instance.getResolutionSelector());
62+
assertEquals(Surface.ROTATION_0, instance.getTargetRotation());
63+
assertEquals(1, mockCamera2InteropExtender.constructed().size());
64+
}
4465
}
4566

4667
@Test

packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoCaptureTest.java

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,42 @@
99
import static org.mockito.Mockito.verify;
1010
import static org.mockito.Mockito.when;
1111

12+
import android.hardware.camera2.CaptureRequest;
13+
import android.util.Range;
14+
import androidx.camera.camera2.interop.Camera2Interop;
1215
import androidx.camera.video.VideoCapture;
1316
import androidx.camera.video.VideoOutput;
1417
import org.junit.Test;
1518
import org.junit.runner.RunWith;
16-
import org.mockito.MockedStatic;
19+
import org.mockito.MockedConstruction;
1720
import org.mockito.Mockito;
18-
import org.mockito.stubbing.Answer;
1921
import org.robolectric.RobolectricTestRunner;
2022

2123
@RunWith(RobolectricTestRunner.class)
2224
public class VideoCaptureTest {
25+
// Due to Java's Type Erasure, we cannot get a class literal (e.g., Extender<T>.class) for a
26+
// parameterized type. We must use the raw type (Extender.class) which forces the 'unchecked' and
27+
// 'rawtypes' warnings. The runtime logic handles the type safely.
2328
@SuppressWarnings({"unchecked", "rawtypes"})
2429
@Test
2530
public void withOutput_createsVideoCaptureWithVideoOutput() {
2631
final PigeonApiVideoCapture api = new TestProxyApiRegistrar().getPigeonApiVideoCapture();
2732

28-
final VideoCapture<VideoOutput> instance = mock(VideoCapture.class);
2933
final VideoOutput videoOutput = mock(VideoOutput.class);
34+
final Range<Integer> targetFpsRange = new Range<>(30, 30);
3035

31-
try (MockedStatic<VideoCapture> mockedCamera2CameraInfo =
32-
Mockito.mockStatic(VideoCapture.class)) {
33-
mockedCamera2CameraInfo
34-
.when(() -> VideoCapture.withOutput(videoOutput))
35-
.thenAnswer((Answer<VideoCapture>) invocation -> instance);
36+
try (MockedConstruction<Camera2Interop.Extender> mockCamera2InteropExtender =
37+
Mockito.mockConstruction(
38+
Camera2Interop.Extender.class,
39+
(mock, context) -> {
40+
when(mock.setCaptureRequestOption(
41+
CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, targetFpsRange))
42+
.thenReturn(mock);
43+
})) {
44+
final VideoCapture videoCapture = api.withOutput(videoOutput, targetFpsRange);
3645

37-
assertEquals(api.withOutput(videoOutput), instance);
46+
assertEquals(1, mockCamera2InteropExtender.constructed().size());
47+
assertEquals(videoOutput, videoCapture.getOutput());
3848
}
3949
}
4050

0 commit comments

Comments
 (0)