Merge "Add API to query the supported DynamicRanges from Recorder" into androidx-main
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt
new file mode 100644
index 0000000..969868a
--- /dev/null
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video
+
+import android.content.Context
+import android.os.Build
+import androidx.camera.camera2.Camera2Config
+import androidx.camera.camera2.pipe.integration.CameraPipeConfig
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.CameraXConfig
+import androidx.camera.core.DynamicRange.SDR
+import androidx.camera.testing.AndroidUtil
+import androidx.camera.testing.CameraPipeConfigTestRule
+import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.CameraXUtil
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.TimeUnit
+import org.junit.After
+import org.junit.Assume.assumeFalse
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RunWith(Parameterized::class)
+@SmallTest
+@SdkSuppress(minSdkVersion = 21)
+class RecorderVideoCapabilitiesTest(
+    private val implName: String,
+    private val cameraConfig: CameraXConfig,
+) {
+
+    @get:Rule
+    val cameraPipeConfigTestRule = CameraPipeConfigTestRule(
+        active = implName == CameraPipeConfig::class.simpleName,
+    )
+
+    @get:Rule
+    val cameraRule = CameraUtil.grantCameraPermissionAndPreTest(
+        CameraUtil.PreTestCameraIdList(cameraConfig)
+    )
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun data() = listOf(
+            arrayOf(Camera2Config::class.simpleName, Camera2Config.defaultConfig()),
+            arrayOf(CameraPipeConfig::class.simpleName, CameraPipeConfig.defaultConfig())
+        )
+    }
+
+    private val context: Context = ApplicationProvider.getApplicationContext()
+    private val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
+
+    private lateinit var videoCapabilities: RecorderVideoCapabilities
+
+    @Before
+    fun setUp() {
+        assumeTrue(CameraUtil.hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK))
+
+        CameraXUtil.initialize(context, cameraConfig).get()
+
+        val cameraInfo = CameraUtil.createCameraUseCaseAdapter(context, cameraSelector).cameraInfo
+        videoCapabilities = RecorderVideoCapabilities.from(cameraInfo)
+    }
+
+    @After
+    fun tearDown() {
+        CameraXUtil.shutdown().get(10, TimeUnit.SECONDS)
+    }
+
+    @Test
+    fun supportStandardDynamicRange() {
+        assumeFalse(isSpecificSkippedDevice())
+        assumeFalse(AndroidUtil.isEmulatorAndAPI21())
+        assertThat(videoCapabilities.supportedDynamicRanges).contains(SDR)
+    }
+
+    @Test
+    fun supportedQualitiesOfSdrIsNotEmpty() {
+        assumeFalse(isSpecificSkippedDevice())
+        assumeFalse(AndroidUtil.isEmulatorAndAPI21())
+        assertThat(videoCapabilities.getSupportedQualities(SDR)).isNotEmpty()
+    }
+
+    private fun isSpecificSkippedDevice(): Boolean {
+        // skip for b/231903433
+        val isNokia2Point1 = "nokia".equals(Build.BRAND, true) &&
+            "nokia 2.1".equals(Build.MODEL, true)
+        val isMotoE5Play = "motorola".equals(Build.BRAND, true) &&
+            "moto e5 play".equals(Build.MODEL, true)
+
+        return isNokia2Point1 || isMotoE5Play
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java b/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java
index 0dd1fe0..526a66a 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java
@@ -313,6 +313,55 @@
         return new ArrayList<>(sortedQualities);
     }
 
+    /**
+     * Generates a sorted quality list that matches the desired quality settings.
+     *
+     * <p>The method bases on the desired qualities and the fallback strategy to find a matched
+     * quality list on this device. The search algorithm first checks which desired quality is
+     * supported according to the set sequence and adds to the returned list by order. Then the
+     * fallback strategy will be applied to add more valid qualities.
+     *
+     * @param supportedQualities the supported qualities.
+     * @return a sorted supported quality list according to the desired quality settings.
+     */
+    @NonNull
+    List<Quality> getPrioritizedQualities(@NonNull List<Quality> supportedQualities) {
+        if (supportedQualities.isEmpty()) {
+            Logger.w(TAG, "No supported quality on the device.");
+            return new ArrayList<>();
+        }
+        Logger.d(TAG, "supportedQualities = " + supportedQualities);
+
+        // Use LinkedHashSet to prevent from duplicate quality and keep the adding order.
+        Set<Quality> sortedQualities = new LinkedHashSet<>();
+        // Add exact quality.
+        for (Quality quality : mPreferredQualityList) {
+            if (quality == Quality.HIGHEST) {
+                // Highest means user want a quality as higher as possible, so the return list can
+                // contain all supported resolutions from large to small.
+                sortedQualities.addAll(supportedQualities);
+                break;
+            } else if (quality == Quality.LOWEST) {
+                // Opposite to the highest
+                List<Quality> reversedList = new ArrayList<>(supportedQualities);
+                Collections.reverse(reversedList);
+                sortedQualities.addAll(reversedList);
+                break;
+            } else {
+                if (supportedQualities.contains(quality)) {
+                    sortedQualities.add(quality);
+                } else {
+                    Logger.w(TAG, "quality is not supported and will be ignored: " + quality);
+                }
+            }
+        }
+
+        // Add quality by fallback strategy based on fallback quality.
+        addByFallbackStrategy(supportedQualities, sortedQualities);
+
+        return new ArrayList<>(sortedQualities);
+    }
+
     @NonNull
     @Override
     public String toString() {
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java b/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
index 56b0c79..30fd36c 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
@@ -57,6 +57,7 @@
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.camera.core.AspectRatio;
+import androidx.camera.core.CameraInfo;
 import androidx.camera.core.Logger;
 import androidx.camera.core.SurfaceRequest;
 import androidx.camera.core.impl.MutableStateObservable;
@@ -2614,6 +2615,15 @@
         return defaultMuxerFormat;
     }
 
+    /**
+     * Gets the {@link VideoCapabilities} of Recorder.
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @NonNull
+    public static VideoCapabilities getVideoCapabilities(@NonNull CameraInfo cameraInfo) {
+        return RecorderVideoCapabilities.from(cameraInfo);
+    }
+
     @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
     @AutoValue
     abstract static class RecordingRecord implements AutoCloseable {
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/RecorderVideoCapabilities.java b/camera/camera-video/src/main/java/androidx/camera/video/RecorderVideoCapabilities.java
new file mode 100644
index 0000000..d78d614
--- /dev/null
+++ b/camera/camera-video/src/main/java/androidx/camera/video/RecorderVideoCapabilities.java
@@ -0,0 +1,396 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video;
+
+import static androidx.camera.core.DynamicRange.FORMAT_HLG;
+import static androidx.camera.core.DynamicRange.HDR_UNSPECIFIED_10_BIT;
+import static androidx.camera.core.DynamicRange.SDR;
+import static androidx.camera.video.internal.BackupHdrProfileEncoderProfilesProvider.DEFAULT_VALIDATOR;
+import static androidx.camera.video.internal.utils.DynamicRangeUtil.VP_TO_DR_FORMAT_MAP;
+
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+import androidx.arch.core.util.Function;
+import androidx.camera.core.CameraInfo;
+import androidx.camera.core.DynamicRange;
+import androidx.camera.core.Logger;
+import androidx.camera.core.impl.CameraInfoInternal;
+import androidx.camera.core.impl.EncoderProfilesProvider;
+import androidx.camera.core.impl.EncoderProfilesProxy;
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy;
+import androidx.camera.core.impl.Quirks;
+import androidx.camera.core.impl.ResolutionValidatedEncoderProfilesProvider;
+import androidx.camera.core.impl.utils.CompareSizesByArea;
+import androidx.camera.video.internal.BackupHdrProfileEncoderProfilesProvider;
+import androidx.camera.video.internal.DynamicRangeMatchedEncoderProfilesProvider;
+import androidx.camera.video.internal.VideoValidatedEncoderProfilesProxy;
+import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
+import androidx.camera.video.internal.workaround.QualityValidatedEncoderProfilesProvider;
+import androidx.core.util.Preconditions;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * RecorderVideoCapabilities is used to query video recording capabilities related to Recorder.
+ *
+ * <p>The {@link EncoderProfilesProxy} queried from RecorderVideoCapabilities will contain
+ * {@link VideoProfileProxy}s matches with the target {@link DynamicRange}. When HDR is
+ * supported, RecorderVideoCapabilities will try best to provide additional backup HDR
+ * {@link VideoProfileProxy}s in case the information is lacked in the device.
+ *
+ * @see Recorder#getVideoCapabilities(CameraInfo)
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public final class RecorderVideoCapabilities implements VideoCapabilities {
+
+    private static final String TAG = "RecorderVideoCapabilities";
+
+    private final Map<DynamicRange, CapabilitiesByQuality> mCapabilitiesMap = new HashMap<>();
+
+    /**
+     * Creates a RecorderVideoCapabilities.
+     *
+     * @param cameraInfoInternal the cameraInfo.
+     * @param backupVideoProfileValidator the validator used to check if the derived backup HDR
+     *                                    {@link VideoProfileProxy} is valid.
+     * @throws IllegalArgumentException if unable to get the capability information from the
+     *                                  CameraInfo.
+     */
+    @VisibleForTesting
+    RecorderVideoCapabilities(@NonNull CameraInfoInternal cameraInfoInternal,
+            @NonNull Function<VideoProfileProxy, VideoProfileProxy> backupVideoProfileValidator) {
+        EncoderProfilesProvider encoderProfilesProvider =
+                cameraInfoInternal.getEncoderProfilesProvider();
+
+        // Add backup HDR video information. In the initial version, only HLG10 profile is added.
+        if (isHlg10SupportedByCamera(cameraInfoInternal)) {
+            encoderProfilesProvider = new BackupHdrProfileEncoderProfilesProvider(
+                    encoderProfilesProvider, backupVideoProfileValidator);
+        }
+
+        // Workaround resolution quirk.
+        Quirks cameraQuirks = cameraInfoInternal.getCameraQuirks();
+        encoderProfilesProvider = new ResolutionValidatedEncoderProfilesProvider(
+                encoderProfilesProvider, cameraQuirks);
+
+        // Workaround quality quirk.
+        Quirks deviceQuirks = DeviceQuirks.getAll();
+        encoderProfilesProvider = new QualityValidatedEncoderProfilesProvider(
+                encoderProfilesProvider, cameraInfoInternal, deviceQuirks);
+
+        // Group by dynamic range.
+        for (DynamicRange dynamicRange : getCandidateDynamicRanges(encoderProfilesProvider)) {
+            if (!isDynamicRangeSupported(dynamicRange, cameraInfoInternal)) {
+                continue;
+            }
+
+            // Filter video profiles to include only the profiles match with the target dynamic
+            // range.
+            EncoderProfilesProvider constrainedProvider =
+                    new DynamicRangeMatchedEncoderProfilesProvider(encoderProfilesProvider,
+                            dynamicRange);
+            CapabilitiesByQuality capabilitiesByQuality =
+                    new CapabilitiesByQuality(constrainedProvider);
+
+            if (!capabilitiesByQuality.getSupportedQualities().isEmpty()) {
+                mCapabilitiesMap.put(dynamicRange, capabilitiesByQuality);
+            }
+        }
+    }
+
+    /**
+     * Gets RecorderVideoCapabilities by the {@link CameraInfo}.
+     *
+     * <p>Should not be called directly, use {@link Recorder#getVideoCapabilities(CameraInfo)}
+     * instead.
+     *
+     * <p>The {@link BackupHdrProfileEncoderProfilesProvider#DEFAULT_VALIDATOR} is used for
+     * validating the derived backup HDR {@link VideoProfileProxy}.
+     */
+    @NonNull
+    static RecorderVideoCapabilities from(@NonNull CameraInfo cameraInfo) {
+        return new RecorderVideoCapabilities((CameraInfoInternal) cameraInfo, DEFAULT_VALIDATOR);
+    }
+
+    @NonNull
+    @Override
+    public Set<DynamicRange> getSupportedDynamicRanges() {
+        Set<DynamicRange> dynamicRanges = mCapabilitiesMap.keySet();
+
+        // Remove HDR_UNSPECIFIED_10_BIT from output, since it does not have explicit content.
+        dynamicRanges.remove(HDR_UNSPECIFIED_10_BIT);
+
+        return dynamicRanges;
+    }
+
+    @NonNull
+    @Override
+    public List<Quality> getSupportedQualities(@NonNull DynamicRange dynamicRange) {
+        CapabilitiesByQuality capabilities = mCapabilitiesMap.get(dynamicRange);
+        return capabilities == null ? new ArrayList<>() : capabilities.getSupportedQualities();
+    }
+
+    @Override
+    public boolean isQualitySupported(@NonNull Quality quality,
+            @NonNull DynamicRange dynamicRange) {
+        CapabilitiesByQuality capabilities = mCapabilitiesMap.get(dynamicRange);
+        return capabilities != null && capabilities.isQualitySupported(quality);
+    }
+
+    @Nullable
+    @Override
+    public VideoValidatedEncoderProfilesProxy getProfiles(@NonNull Quality quality,
+            @NonNull DynamicRange dynamicRange) {
+        CapabilitiesByQuality capabilities = mCapabilitiesMap.get(dynamicRange);
+        return capabilities == null ? null : capabilities.getProfiles(quality);
+    }
+
+    @Nullable
+    @Override
+    public VideoValidatedEncoderProfilesProxy findHighestSupportedEncoderProfilesFor(
+            @NonNull Size size, @NonNull DynamicRange dynamicRange) {
+        CapabilitiesByQuality capabilities = mCapabilitiesMap.get(dynamicRange);
+        return capabilities == null ? null : capabilities.findHighestSupportedEncoderProfilesFor(
+                size);
+    }
+
+    @NonNull
+    @Override
+    public Quality findHighestSupportedQualityFor(@NonNull Size size,
+            @NonNull DynamicRange dynamicRange) {
+        CapabilitiesByQuality capabilities = mCapabilitiesMap.get(dynamicRange);
+        return capabilities == null ? Quality.NONE : capabilities.findHighestSupportedQualityFor(
+                size);
+    }
+
+    @NonNull
+    private static Set<DynamicRange> getCandidateDynamicRanges(
+            @NonNull EncoderProfilesProvider provider) {
+        Set<DynamicRange> dynamicRanges = new HashSet<>();
+        for (Quality quality : Quality.getSortedQualities()) {
+            int qualityValue = ((Quality.ConstantQuality) quality).getValue();
+            EncoderProfilesProxy encoderProfiles = provider.getAll(qualityValue);
+
+            if (encoderProfiles != null) {
+                for (VideoProfileProxy videoProfile : encoderProfiles.getVideoProfiles()) {
+                    Integer format = VP_TO_DR_FORMAT_MAP.get(videoProfile.getHdrFormat());
+                    if (format != null) {
+                        dynamicRanges.add(new DynamicRange(format, videoProfile.getBitDepth()));
+                    }
+                }
+            }
+        }
+
+        dynamicRanges.add(SDR);
+        dynamicRanges.add(HDR_UNSPECIFIED_10_BIT);
+
+        return dynamicRanges;
+    }
+
+    private static boolean isDynamicRangeSupported(@NonNull DynamicRange dynamicRange,
+            @NonNull CameraInfoInternal cameraInfoInternal) {
+        Set<DynamicRange> supportedDynamicRanges = cameraInfoInternal.getSupportedDynamicRanges();
+        if (supportedDynamicRanges.contains(dynamicRange)) {
+            return true;
+        } else {
+            return dynamicRange.equals(HDR_UNSPECIFIED_10_BIT) && contains10BitHdrDynamicRange(
+                    supportedDynamicRanges);
+        }
+    }
+
+    private static boolean contains10BitHdrDynamicRange(@NonNull Set<DynamicRange> dynamicRanges) {
+        for (DynamicRange dynamicRange : dynamicRanges) {
+            if (dynamicRange.getFormat() != DynamicRange.FORMAT_SDR
+                    && dynamicRange.getBitDepth() == DynamicRange.BIT_DEPTH_10_BIT) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private static boolean isHlg10SupportedByCamera(
+            @NonNull CameraInfoInternal cameraInfoInternal) {
+        Set<DynamicRange> dynamicRanges = cameraInfoInternal.getSupportedDynamicRanges();
+        for (DynamicRange dynamicRange : dynamicRanges) {
+            Integer format = dynamicRange.getFormat();
+            int bitDepth = dynamicRange.getBitDepth();
+            if (format.equals(FORMAT_HLG) && bitDepth == DynamicRange.BIT_DEPTH_10_BIT) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * This class implements the video capabilities query logic related to quality and resolution.
+     */
+    private static class CapabilitiesByQuality {
+
+        /**
+         * Maps quality to supported {@link VideoValidatedEncoderProfilesProxy}. The order is from
+         * size large to small.
+         */
+        private final Map<Quality, VideoValidatedEncoderProfilesProxy> mSupportedProfilesMap =
+                new LinkedHashMap<>();
+        private final TreeMap<Size, Quality> mAreaSortedSizeToQualityMap =
+                new TreeMap<>(new CompareSizesByArea());
+        private final VideoValidatedEncoderProfilesProxy mHighestProfiles;
+        private final VideoValidatedEncoderProfilesProxy mLowestProfiles;
+
+        CapabilitiesByQuality(@NonNull EncoderProfilesProvider provider) {
+            // Construct supported profile map.
+            for (Quality quality : Quality.getSortedQualities()) {
+                EncoderProfilesProxy profiles = getEncoderProfiles(quality, provider);
+                if (profiles == null) {
+                    continue;
+                }
+
+                // Validate that EncoderProfiles contain video information.
+                Logger.d(TAG, "profiles = " + profiles);
+                VideoValidatedEncoderProfilesProxy validatedProfiles = toValidatedProfiles(
+                        profiles);
+                if (validatedProfiles == null) {
+                    Logger.w(TAG, "EncoderProfiles of quality " + quality + " has no video "
+                            + "validated profiles.");
+                    continue;
+                }
+
+                EncoderProfilesProxy.VideoProfileProxy videoProfile =
+                        validatedProfiles.getDefaultVideoProfile();
+                Size size = new Size(videoProfile.getWidth(), videoProfile.getHeight());
+                mAreaSortedSizeToQualityMap.put(size, quality);
+
+                // SortedQualities is from size large to small.
+                mSupportedProfilesMap.put(quality, validatedProfiles);
+            }
+            if (mSupportedProfilesMap.isEmpty()) {
+                Logger.e(TAG, "No supported EncoderProfiles");
+                mLowestProfiles = null;
+                mHighestProfiles = null;
+            } else {
+                Deque<VideoValidatedEncoderProfilesProxy> profileQueue = new ArrayDeque<>(
+                        mSupportedProfilesMap.values());
+                mHighestProfiles = profileQueue.peekFirst();
+                mLowestProfiles = profileQueue.peekLast();
+            }
+        }
+
+        @NonNull
+        public List<Quality> getSupportedQualities() {
+            return new ArrayList<>(mSupportedProfilesMap.keySet());
+        }
+
+        public boolean isQualitySupported(@NonNull Quality quality) {
+            checkQualityConstantsOrThrow(quality);
+            return getProfiles(quality) != null;
+        }
+
+        @Nullable
+        public VideoValidatedEncoderProfilesProxy getProfiles(@NonNull Quality quality) {
+            checkQualityConstantsOrThrow(quality);
+            if (quality == Quality.HIGHEST) {
+                return mHighestProfiles;
+            } else if (quality == Quality.LOWEST) {
+                return mLowestProfiles;
+            }
+            return mSupportedProfilesMap.get(quality);
+        }
+
+        @Nullable
+        public VideoValidatedEncoderProfilesProxy findHighestSupportedEncoderProfilesFor(
+                @NonNull Size size) {
+            VideoValidatedEncoderProfilesProxy encoderProfiles = null;
+            Quality highestSupportedQuality = findHighestSupportedQualityFor(size);
+            Logger.d(TAG,
+                    "Using supported quality of " + highestSupportedQuality + " for size " + size);
+            if (highestSupportedQuality != Quality.NONE) {
+                encoderProfiles = getProfiles(highestSupportedQuality);
+                if (encoderProfiles == null) {
+                    throw new AssertionError("Camera advertised available quality but did not "
+                            + "produce EncoderProfiles for advertised quality.");
+                }
+            }
+            return encoderProfiles;
+        }
+
+        @NonNull
+        public Quality findHighestSupportedQualityFor(@NonNull Size size) {
+            Map.Entry<Size, Quality> ceilEntry = mAreaSortedSizeToQualityMap.ceilingEntry(size);
+
+            if (ceilEntry != null) {
+                // The ceiling entry will either be equivalent or higher in size, so always
+                // return it.
+                return ceilEntry.getValue();
+            } else {
+                // If a ceiling entry doesn't exist and a floor entry exists, it is the closest
+                // we have, so return it.
+                Map.Entry<Size, Quality> floorEntry = mAreaSortedSizeToQualityMap.floorEntry(size);
+                if (floorEntry != null) {
+                    return floorEntry.getValue();
+                }
+            }
+
+            // No supported qualities.
+            return Quality.NONE;
+        }
+
+        @Nullable
+        private EncoderProfilesProxy getEncoderProfiles(@NonNull Quality quality,
+                @NonNull EncoderProfilesProvider provider) {
+            Preconditions.checkState(quality instanceof Quality.ConstantQuality,
+                    "Currently only support ConstantQuality");
+            int qualityValue = ((Quality.ConstantQuality) quality).getValue();
+
+            return provider.getAll(qualityValue);
+        }
+
+        @Nullable
+        private VideoValidatedEncoderProfilesProxy toValidatedProfiles(
+                @NonNull EncoderProfilesProxy profiles) {
+            // According to the document, the first profile is the default video profile.
+            List<EncoderProfilesProxy.VideoProfileProxy> videoProfiles =
+                    profiles.getVideoProfiles();
+            if (videoProfiles.isEmpty()) {
+                return null;
+            }
+
+            return VideoValidatedEncoderProfilesProxy.from(profiles);
+        }
+
+        private static void checkQualityConstantsOrThrow(@NonNull Quality quality) {
+            Preconditions.checkArgument(Quality.containsQuality(quality),
+                    "Unknown quality: " + quality);
+        }
+    }
+}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapabilities.java b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapabilities.java
new file mode 100644
index 0000000..5814ce7
--- /dev/null
+++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapabilities.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video;
+
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.camera.core.DynamicRange;
+import androidx.camera.video.internal.VideoValidatedEncoderProfilesProxy;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * VideoCapabilities is used to query video recording capabilities on the device.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public interface VideoCapabilities {
+
+    /**
+     * Gets all dynamic ranges supported by both the camera and video output.
+     *
+     * <p>Only {@link DynamicRange}s with specified values both in {@link DynamicRange.BitDepth}
+     * and {@link DynamicRange.DynamicRangeFormat} will be present in the returned set.
+     * {@link DynamicRange}s such as {@link DynamicRange#HDR_UNSPECIFIED_10_BIT} will not be
+     * included, but they can be used in other methods, such as checking for quality support with
+     * {@link #isQualitySupported(Quality, DynamicRange)}.
+     */
+    @NonNull
+    Set<DynamicRange> getSupportedDynamicRanges();
+
+    /**
+     * Gets all supported qualities for the input dynamic range.
+     *
+     * <p>The returned list is sorted by quality size from large to small.
+     *
+     * <p>Note: Constants {@link Quality#HIGHEST} and {@link Quality#LOWEST} are not included.
+     */
+    @NonNull
+    List<Quality> getSupportedQualities(@NonNull DynamicRange dynamicRange);
+
+    /**
+     * Checks if the quality is supported for the input dynamic range.
+     *
+     * @param quality one of the quality constants. Possible values include
+     *                {@link Quality#LOWEST}, {@link Quality#HIGHEST}, {@link Quality#SD},
+     *                {@link Quality#HD}, {@link Quality#FHD}, or {@link Quality#UHD}.
+     * @param dynamicRange the target dynamicRange.
+     * @return {@code true} if the quality is supported; {@code false} otherwise.
+     */
+    boolean isQualitySupported(@NonNull Quality quality, @NonNull DynamicRange dynamicRange);
+
+    /**
+     * Gets the corresponding {@link VideoValidatedEncoderProfilesProxy} of the input quality and
+     * dynamic range.
+     *
+     * @param quality one of the quality constants. Possible values include
+     *                {@link Quality#LOWEST}, {@link Quality#HIGHEST}, {@link Quality#SD},
+     *                {@link Quality#HD}, {@link Quality#FHD}, or {@link Quality#UHD}.
+     * @param dynamicRange target dynamicRange.
+     * @return the corresponding VideoValidatedEncoderProfilesProxy, or {@code null} if the
+     * quality is not supported on the device.
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @Nullable
+    default VideoValidatedEncoderProfilesProxy getProfiles(@NonNull Quality quality,
+            @NonNull DynamicRange dynamicRange) {
+        return null;
+    }
+
+    /**
+     * Finds the supported EncoderProfilesProxy with the resolution nearest to the given
+     * {@link Size}.
+     *
+     * <p>The supported EncoderProfilesProxy means the corresponding {@link Quality} is also
+     * supported. If the size aligns exactly with the pixel count of an EncoderProfilesProxy,
+     * that EncoderProfilesProxy will be selected. If the size falls between two
+     * EncoderProfilesProxy, the higher resolution will always be selected. Otherwise, the
+     * nearest EncoderProfilesProxy will be selected, whether that EncoderProfilesProxy's
+     * resolution is above or below the given size.
+     *
+     * @see #findHighestSupportedQualityFor(Size, DynamicRange)
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @Nullable
+    default VideoValidatedEncoderProfilesProxy findHighestSupportedEncoderProfilesFor(
+            @NonNull Size size, @NonNull DynamicRange dynamicRange) {
+        return null;
+    }
+
+    /**
+     * Finds the nearest quality by number of pixels to the given {@link Size}.
+     *
+     * <p>If the size aligns exactly with the pixel count of a supported quality, that quality
+     * will be selected. If the size falls between two qualities, the higher quality will always
+     * be selected. Otherwise, the nearest single quality will be selected, whether that
+     * quality's size is above or below the given size.
+     *
+     * @param size the size representing the number of pixels for comparison. Pixels are assumed
+     *             to be square.
+     * @param dynamicRange target dynamicRange.
+     * @return the quality constant defined in {@link Quality}. If no qualities are supported,
+     * then {@link Quality#NONE} is returned.
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @NonNull
+    default Quality findHighestSupportedQualityFor(@NonNull Size size,
+            @NonNull DynamicRange dynamicRange) {
+        return Quality.NONE;
+    }
+
+    /** An empty implementation. */
+    VideoCapabilities EMPTY = new VideoCapabilities() {
+        @NonNull
+        @Override
+        public Set<DynamicRange> getSupportedDynamicRanges() {
+            return new HashSet<>();
+        }
+
+        @NonNull
+        @Override
+        public List<Quality> getSupportedQualities(@NonNull DynamicRange dynamicRange) {
+            return new ArrayList<>();
+        }
+
+        @Override
+        public boolean isQualitySupported(@NonNull Quality quality,
+                @NonNull DynamicRange dynamicRange) {
+            return false;
+        }
+    };
+}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/utils/DynamicRangeUtil.java b/camera/camera-video/src/main/java/androidx/camera/video/internal/utils/DynamicRangeUtil.java
index 2b081c7..8057fdf 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/internal/utils/DynamicRangeUtil.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/utils/DynamicRangeUtil.java
@@ -52,6 +52,7 @@
 
     public static final Map<Integer, Set<Integer>> DR_TO_VP_BIT_DEPTH_MAP = new HashMap<>();
     public static final Map<Integer, Set<Integer>> DR_TO_VP_FORMAT_MAP = new HashMap<>();
+    public static final Map<Integer, Integer> VP_TO_DR_FORMAT_MAP = new HashMap<>();
 
     private DynamicRangeUtil() {
     }
@@ -72,5 +73,12 @@
         DR_TO_VP_FORMAT_MAP.put(FORMAT_HDR10_PLUS, new HashSet<>(singletonList(HDR_HDR10PLUS)));
         DR_TO_VP_FORMAT_MAP.put(FORMAT_DOLBY_VISION,
                 new HashSet<>(singletonList(HDR_DOLBY_VISION)));
+
+        // VideoProfile HDR format to DynamicRange format.
+        VP_TO_DR_FORMAT_MAP.put(HDR_NONE, FORMAT_SDR);
+        VP_TO_DR_FORMAT_MAP.put(HDR_HLG, FORMAT_HLG);
+        VP_TO_DR_FORMAT_MAP.put(HDR_HDR10, FORMAT_HDR10);
+        VP_TO_DR_FORMAT_MAP.put(HDR_HDR10PLUS, FORMAT_HDR10_PLUS);
+        VP_TO_DR_FORMAT_MAP.put(HDR_DOLBY_VISION, FORMAT_DOLBY_VISION);
     }
 }
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt
new file mode 100644
index 0000000..fb598f9
--- /dev/null
+++ b/camera/camera-video/src/test/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video
+
+import android.media.CamcorderProfile
+import android.os.Build
+import android.util.Size
+import androidx.camera.core.DynamicRange
+import androidx.camera.core.DynamicRange.BIT_DEPTH_10_BIT
+import androidx.camera.core.DynamicRange.FORMAT_HLG
+import androidx.camera.core.DynamicRange.HDR_UNSPECIFIED_10_BIT
+import androidx.camera.core.DynamicRange.SDR
+import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy
+import androidx.camera.testing.EncoderProfilesUtil.PROFILES_2160P
+import androidx.camera.testing.EncoderProfilesUtil.PROFILES_720P
+import androidx.camera.testing.EncoderProfilesUtil.RESOLUTION_2160P
+import androidx.camera.testing.EncoderProfilesUtil.RESOLUTION_720P
+import androidx.camera.testing.fakes.FakeCameraInfoInternal
+import androidx.camera.testing.fakes.FakeEncoderProfilesProvider
+import androidx.camera.video.Quality.FHD
+import androidx.camera.video.Quality.HD
+import androidx.camera.video.Quality.HIGHEST
+import androidx.camera.video.Quality.LOWEST
+import androidx.camera.video.Quality.SD
+import androidx.camera.video.Quality.UHD
+import androidx.camera.video.internal.VideoValidatedEncoderProfilesProxy
+import androidx.core.util.component1
+import androidx.core.util.component2
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+private val HLG10 = DynamicRange(FORMAT_HLG, BIT_DEPTH_10_BIT)
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class RecorderVideoCapabilitiesTest {
+
+    private val defaultProfilesProvider = FakeEncoderProfilesProvider.Builder()
+        .add(CamcorderProfile.QUALITY_HIGH, PROFILES_2160P) // UHD (2160p) per above definition
+        .add(CamcorderProfile.QUALITY_2160P, PROFILES_2160P) // UHD (2160p)
+        .add(CamcorderProfile.QUALITY_720P, PROFILES_720P) // HD (720p)
+        .add(CamcorderProfile.QUALITY_LOW, PROFILES_720P) // HD (720p) per above definition
+        .build()
+    private val defaultDynamicRanges = setOf(SDR, HLG10)
+    private val cameraInfo = FakeCameraInfoInternal().apply {
+        encoderProfilesProvider = defaultProfilesProvider
+        supportedDynamicRanges = defaultDynamicRanges
+    }
+
+    private val fakeValidator: (VideoProfileProxy) -> VideoProfileProxy = {
+        // Just returns the input video profile.
+        it
+    }
+    private val validatedProfiles2160p = VideoValidatedEncoderProfilesProxy.from(PROFILES_2160P)
+    private val validatedProfiles720p = VideoValidatedEncoderProfilesProxy.from(PROFILES_720P)
+    private val videoCapabilities = RecorderVideoCapabilities(cameraInfo, fakeValidator)
+
+    @Test
+    fun canGetSupportedDynamicRanges() {
+        assertThat(videoCapabilities.supportedDynamicRanges).containsExactly(SDR, HLG10)
+    }
+
+    @Test
+    fun isQualitySupported_sdr() {
+        assertThat(videoCapabilities.isQualitySupported(HIGHEST, SDR)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(LOWEST, SDR)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(UHD, SDR)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(FHD, SDR)).isFalse()
+        assertThat(videoCapabilities.isQualitySupported(HD, SDR)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(SD, SDR)).isFalse()
+    }
+
+    @Test
+    fun isQualitySupported_hlg10WithBackupProfile() {
+        assertThat(videoCapabilities.isQualitySupported(HIGHEST, HLG10)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(LOWEST, HLG10)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(UHD, HLG10)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(FHD, HLG10)).isFalse()
+        assertThat(videoCapabilities.isQualitySupported(HD, HLG10)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(SD, HLG10)).isFalse()
+    }
+
+    @Test
+    fun isQualitySupported_hdrUnspecifiedWithBackupProfile() {
+        assertThat(videoCapabilities.isQualitySupported(HIGHEST, HDR_UNSPECIFIED_10_BIT)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(LOWEST, HDR_UNSPECIFIED_10_BIT)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(UHD, HDR_UNSPECIFIED_10_BIT)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(FHD, HDR_UNSPECIFIED_10_BIT)).isFalse()
+        assertThat(videoCapabilities.isQualitySupported(HD, HDR_UNSPECIFIED_10_BIT)).isTrue()
+        assertThat(videoCapabilities.isQualitySupported(SD, HDR_UNSPECIFIED_10_BIT)).isFalse()
+    }
+
+    @Test
+    fun canGetSameSdrProfile() {
+        assertThat(videoCapabilities.getProfiles(HIGHEST, SDR)).isEqualTo(validatedProfiles2160p)
+        assertThat(videoCapabilities.getProfiles(LOWEST, SDR)).isEqualTo(validatedProfiles720p)
+        assertThat(videoCapabilities.getProfiles(UHD, SDR)).isEqualTo(validatedProfiles2160p)
+        assertThat(videoCapabilities.getProfiles(FHD, SDR)).isNull()
+        assertThat(videoCapabilities.getProfiles(HD, SDR)).isEqualTo(validatedProfiles720p)
+        assertThat(videoCapabilities.getProfiles(SD, SDR)).isNull()
+    }
+
+    @Test
+    fun canGetNonNullHdrUnspecifiedBackupProfile_whenSdrProfileExisted() {
+        assertThat(videoCapabilities.getProfiles(HIGHEST, HDR_UNSPECIFIED_10_BIT)).isNotNull()
+        assertThat(videoCapabilities.getProfiles(LOWEST, HDR_UNSPECIFIED_10_BIT)).isNotNull()
+        assertThat(videoCapabilities.getProfiles(UHD, HDR_UNSPECIFIED_10_BIT)).isNotNull()
+        assertThat(videoCapabilities.getProfiles(FHD, HDR_UNSPECIFIED_10_BIT)).isNull()
+        assertThat(videoCapabilities.getProfiles(HD, HDR_UNSPECIFIED_10_BIT)).isNotNull()
+        assertThat(videoCapabilities.getProfiles(SD, HDR_UNSPECIFIED_10_BIT)).isNull()
+    }
+
+    @Test
+    fun canGetNonNullHlg10BackupProfile_whenSdrProfileExisted() {
+        assertThat(videoCapabilities.getProfiles(HIGHEST, HLG10)).isNotNull()
+        assertThat(videoCapabilities.getProfiles(LOWEST, HLG10)).isNotNull()
+        assertThat(videoCapabilities.getProfiles(UHD, HLG10)).isNotNull()
+        assertThat(videoCapabilities.getProfiles(FHD, HLG10)).isNull()
+        assertThat(videoCapabilities.getProfiles(HD, HLG10)).isNotNull()
+        assertThat(videoCapabilities.getProfiles(SD, HLG10)).isNull()
+    }
+
+    @Test
+    fun findHighestSupportedQuality_returnsHigherQuality() {
+        // Create a size between 720p and 2160p
+        val (width720p, height720p) = RESOLUTION_720P
+        val inBetweenSize = Size(width720p + 10, height720p)
+
+        assertThat(videoCapabilities.findHighestSupportedQualityFor(inBetweenSize, SDR))
+            .isEqualTo(UHD)
+    }
+
+    @Test
+    fun findHighestSupportedQuality_returnsHighestQuality_whenAboveHighest() {
+        // Create a size between greater than the max quality (UHD)
+        val (width2160p, height2160p) = RESOLUTION_2160P
+        val aboveHighestSize = Size(width2160p + 10, height2160p)
+
+        assertThat(videoCapabilities.findHighestSupportedQualityFor(aboveHighestSize, SDR))
+            .isEqualTo(UHD)
+    }
+
+    @Test
+    fun findHighestSupportedQuality_returnsLowestQuality_whenBelowLowest() {
+        // Create a size below the lowest quality (HD)
+        val (width720p, height720p) = RESOLUTION_720P
+        val belowLowestSize = Size(width720p - 10, height720p)
+
+        assertThat(videoCapabilities.findHighestSupportedQualityFor(belowLowestSize, SDR))
+            .isEqualTo(HD)
+    }
+
+    @Test
+    fun findHighestSupportedQuality_returnsExactQuality_whenExactSizeGiven() {
+        val exactSize720p = RESOLUTION_720P
+
+        assertThat(videoCapabilities.findHighestSupportedQualityFor(exactSize720p, SDR))
+            .isEqualTo(HD)
+    }
+
+    @Test
+    fun findHighestSupportedEncoderProfilesFor_returnsHigherProfile() {
+        // Create a size between 720p and 2160p
+        val (width720p, height720p) = RESOLUTION_720P
+        val inBetweenSize = Size(width720p + 10, height720p)
+
+        assertThat(videoCapabilities.findHighestSupportedEncoderProfilesFor(inBetweenSize, SDR))
+            .isEqualTo(validatedProfiles2160p)
+    }
+
+    @Test
+    fun findHighestSupportedEncoderProfilesFor_returnsHighestProfile_whenAboveHighest() {
+        // Create a size between greater than the max quality (UHD)
+        val (width2160p, height2160p) = RESOLUTION_2160P
+        val aboveHighestSize = Size(width2160p + 10, height2160p)
+
+        assertThat(videoCapabilities.findHighestSupportedEncoderProfilesFor(aboveHighestSize, SDR))
+            .isEqualTo(validatedProfiles2160p)
+    }
+
+    @Test
+    fun findHighestSupportedEncoderProfilesFor_returnsLowestProfile_whenBelowLowest() {
+        // Create a size below the lowest quality (HD)
+        val (width720p, height720p) = RESOLUTION_720P
+        val belowLowestSize = Size(width720p - 10, height720p)
+
+        assertThat(videoCapabilities.findHighestSupportedEncoderProfilesFor(belowLowestSize, SDR))
+            .isEqualTo(validatedProfiles720p)
+    }
+
+    @Test
+    fun findHighestSupportedEncoderProfilesFor_returnsExactProfile_whenExactSizeGiven() {
+        val exactSize720p = RESOLUTION_720P
+
+        assertThat(videoCapabilities.findHighestSupportedEncoderProfilesFor(exactSize720p, SDR))
+            .isEqualTo(validatedProfiles720p)
+    }
+}
\ No newline at end of file
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/compat/quirk/DeviceQuirks.java b/camera/camera-video/src/test/java/androidx/camera/video/internal/compat/quirk/DeviceQuirks.java
index 30a53cc..6db867b 100644
--- a/camera/camera-video/src/test/java/androidx/camera/video/internal/compat/quirk/DeviceQuirks.java
+++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/compat/quirk/DeviceQuirks.java
@@ -19,6 +19,7 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.camera.core.impl.Quirk;
+import androidx.camera.core.impl.Quirks;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -39,6 +40,12 @@
     private DeviceQuirks() {
     }
 
+    /** Returns all video specific quirks loaded on the current device. */
+    @NonNull
+    public static Quirks getAll() {
+        return new Quirks(DeviceQuirksLoader.loadQuirks());
+    }
+
     /**
      * Retrieves a specific device {@link Quirk} instance given its type.
      *