[go: nahoru, domu]

Merge "Implement ResolutionMerger" into androidx-main
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/SupportedOutputSizesSorter.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/SupportedOutputSizesSorter.java
index 45efcbd..9498690 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/SupportedOutputSizesSorter.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/SupportedOutputSizesSorter.java
@@ -74,7 +74,7 @@
     private final Rational mFullFovRatio;
     private final SupportedOutputSizesSorterLegacy mSupportedOutputSizesSorterLegacy;
 
-    SupportedOutputSizesSorter(@NonNull CameraInfoInternal cameraInfoInternal,
+    public SupportedOutputSizesSorter(@NonNull CameraInfoInternal cameraInfoInternal,
             @Nullable Size activeArraySize) {
         mCameraInfoInternal = cameraInfoInternal;
         mSensorOrientation = mCameraInfoInternal.getSensorRotationDegrees();
@@ -120,7 +120,7 @@
      * will be sorted according to the legacy resolution API settings and logic.
      */
     @NonNull
-    List<Size> getSortedSupportedOutputSizes(@NonNull UseCaseConfig<?> useCaseConfig) {
+    public List<Size> getSortedSupportedOutputSizes(@NonNull UseCaseConfig<?> useCaseConfig) {
         ImageOutputConfig imageOutputConfig = (ImageOutputConfig) useCaseConfig;
         List<Size> customOrderedResolutions = imageOutputConfig.getCustomOrderedResolutions(null);
 
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/ResolutionsMerger.java b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/ResolutionsMerger.java
new file mode 100644
index 0000000..f97ebed
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/ResolutionsMerger.java
@@ -0,0 +1,478 @@
+/*
+ * 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.core.streamsharing;
+
+
+import static androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE;
+import static androidx.camera.core.impl.ImageOutputConfig.OPTION_SUPPORTED_RESOLUTIONS;
+import static androidx.camera.core.impl.utils.AspectRatioUtil.ASPECT_RATIO_16_9;
+import static androidx.camera.core.impl.utils.AspectRatioUtil.ASPECT_RATIO_4_3;
+import static androidx.camera.core.impl.utils.AspectRatioUtil.hasMatchingAspectRatio;
+import static androidx.camera.core.impl.utils.TransformUtils.rectToSize;
+
+import static java.lang.Math.sqrt;
+
+import android.util.Pair;
+import android.util.Rational;
+import android.util.Size;
+import android.view.Surface;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
+import androidx.camera.core.Logger;
+import androidx.camera.core.impl.CameraInfoInternal;
+import androidx.camera.core.impl.CameraInternal;
+import androidx.camera.core.impl.MutableConfig;
+import androidx.camera.core.impl.UseCaseConfig;
+import androidx.camera.core.impl.utils.CompareSizesByArea;
+import androidx.camera.core.internal.SupportedOutputSizesSorter;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A class for calculating parent resolutions based on the children's configs.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class ResolutionsMerger {
+
+    private static final String TAG = "ResolutionsMerger";
+    // The width to height ratio that has same area when cropping into 4:3 and 16:9.
+    private static final double SAME_AREA_WIDTH_HEIGHT_RATIO = sqrt(4.0 / 3.0 * 16.0 / 9.0);
+
+    @NonNull
+    private final Rational mSensorAspectRatio;
+    @NonNull
+    private final Rational mFallbackAspectRatio;
+    @NonNull
+    private final Set<UseCaseConfig<?>> mChildrenConfigs;
+    @NonNull
+    private final SupportedOutputSizesSorter mSizeSorter;
+    @NonNull
+    private final List<Size> mCameraSupportedSizes;
+    @NonNull
+    private final Map<UseCaseConfig<?>, List<Size>> mChildSizesCache = new HashMap<>();
+
+    ResolutionsMerger(@NonNull CameraInternal cameraInternal,
+            @NonNull Set<UseCaseConfig<?>> childrenConfigs) {
+        this(rectToSize(cameraInternal.getCameraControlInternal().getSensorRect()),
+                cameraInternal.getCameraInfoInternal(), childrenConfigs);
+    }
+
+    private ResolutionsMerger(@NonNull Size sensorSize, @NonNull CameraInfoInternal cameraInfo,
+            @NonNull Set<UseCaseConfig<?>> childrenConfigs) {
+        this(sensorSize, childrenConfigs, new SupportedOutputSizesSorter(cameraInfo, sensorSize),
+                cameraInfo.getSupportedResolutions(INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE));
+    }
+
+    @VisibleForTesting
+    ResolutionsMerger(@NonNull Size sensorSize, @NonNull Set<UseCaseConfig<?>> childrenConfigs,
+            @NonNull SupportedOutputSizesSorter supportedOutputSizesSorter,
+            @NonNull List<Size> cameraSupportedResolutions) {
+        mSensorAspectRatio = getSensorAspectRatio(sensorSize);
+        mFallbackAspectRatio = getFallbackAspectRatio(mSensorAspectRatio);
+        mChildrenConfigs = childrenConfigs;
+        mSizeSorter = supportedOutputSizesSorter;
+        mCameraSupportedSizes = cameraSupportedResolutions;
+    }
+
+    /**
+     * Returns a list of {@link Surface} resolution sorted by priority.
+     *
+     * <p> This method calculates the resolution for the parent {@link StreamSharing} based on 1)
+     * the supported PRIV resolutions, 2) the sensor size and 3) the children's configs.
+     */
+    @NonNull
+    List<Size> getMergedResolutions(@NonNull MutableConfig parentConfig) {
+        List<Size> candidateSizes = mCameraSupportedSizes;
+
+        // Use parent config's supported resolutions when it is set (e.g. Extensions may have
+        // its limitations on resolutions).
+        List<Pair<Integer, Size[]>> parentSupportedSizesMap =
+                parentConfig.retrieveOption(OPTION_SUPPORTED_RESOLUTIONS, null);
+        if (parentSupportedSizesMap != null) {
+            candidateSizes = getSupportedPrivResolutions(parentSupportedSizesMap);
+        }
+
+        return mergeChildrenResolutions(candidateSizes);
+    }
+
+    /**
+     * Returns the preferred child size with considering parent size and child's configuration.
+     *
+     * <p>Returns the first size in the child's ordered size list that can be cropped from {@code
+     * parentSize} without upscaling it and causing double-cropping, or {@code parentSize} if no
+     * matching is found.
+     *
+     * <p>Notes that the input {@code childConfig} is expected to be one of the values that use to
+     * construct the {@link ResolutionsMerger}, if not an IllegalArgumentException will be thrown.
+     */
+    @NonNull
+    Size getPreferredChildSize(@NonNull Size parentSize, @NonNull UseCaseConfig<?> childConfig) {
+        boolean isParentCropped = !isSensorAspectRatio(parentSize);
+
+        List<Size> candidateChildSizes = getSortedChildSizes(childConfig);
+        for (Size childSize : candidateChildSizes) {
+            // Skip child sizes that need another cropping when parent is already cropped.
+            if (isParentCropped) {
+                boolean needAnotherCropping = !(isFallbackAspectRatio(parentSize)
+                        && isFallbackAspectRatio(childSize));
+                if (needAnotherCropping) {
+                    continue;
+                }
+            }
+
+            if (!hasUpscaling(childSize, parentSize)) {
+                return childSize;
+            }
+        }
+
+        return parentSize;
+    }
+
+    @NonNull
+    private List<Size> mergeChildrenResolutions(@NonNull List<Size> candidateParentResolutions) {
+        // The following sequence of parent resolution selection is used to prevent double-cropping
+        // from happening:
+        // 1. Add sensor aspect-ratio resolutions, which will not cause double-cropping when the
+        // child resolution is in any aspect-ratio. This is to provide parent resolution that can
+        // be accepted by children in general cases.
+        // 2. Add fallback aspect-ratio resolutions, which will not cause double-cropping only when
+        // the child resolution is in fallback aspect-ratio.
+        List<Size> result = new ArrayList<>();
+
+        // Add resolutions for sensor aspect-ratio.
+        if (needToAddSensorResolutions()) {
+            result.addAll(mergeChildrenResolutionsByAspectRatio(mSensorAspectRatio,
+                    candidateParentResolutions));
+        }
+
+        // Add resolutions for fallback aspect-ratio if needed.
+        if (needToAddFallbackResolutions()) {
+            result.addAll(mergeChildrenResolutionsByAspectRatio(mFallbackAspectRatio,
+                    candidateParentResolutions));
+        }
+
+        // TODO(b/315098647): When the resulting parent resolution list is empty, consider adding
+        //  resolutions that are neither 4:3 nor 16:9, but have a high overlap area (e.g. 80%)
+        //  compared to the sensor size, which do not cause severe reduction of FOV, to prevent
+        //  binding failures in some edge cases.
+
+        Logger.d(TAG, "Parent resolutions: " + result);
+
+        return result;
+    }
+
+    private List<Size> mergeChildrenResolutionsByAspectRatio(@NonNull Rational aspectRatio,
+            @NonNull List<Size> candidateParentResolutions) {
+        List<Size> candidates = filterResolutionsByAspectRatio(aspectRatio,
+                candidateParentResolutions);
+        sortInDescendingOrder(candidates);
+
+        // Filter resolutions that are too small and track resolutions that might be too large.
+        Set<Size> sizesTooLarge = new HashSet<>(candidates);
+        for (UseCaseConfig<?> childConfig : mChildrenConfigs) {
+            List<Size> childSizes = getSortedChildSizes(childConfig);
+            candidates = filterOutParentSizeThatIsTooSmall(childSizes, candidates);
+            sizesTooLarge.retainAll(getParentSizesThatAreTooLarge(childSizes, candidates));
+        }
+
+        // Filter out sizes that are too large.
+        List<Size> result = new ArrayList<>();
+        for (Size candidate : candidates) {
+            if (!sizesTooLarge.contains(candidate)) {
+                result.add(candidate);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Gets child sizes sorted by {@link SupportedOutputSizesSorter}.
+     *
+     * <p>Notes that the input {@code childConfig} is expected to be one of the values that use to
+     * construct the {@link ResolutionsMerger}, if not an IllegalArgumentException will be thrown.
+     *
+     * <p>When {@link SupportedOutputSizesSorter#getSortedSupportedOutputSizes(UseCaseConfig)}
+     * returns an empty list, which means no child required resolution is supported, an
+     * IllegalArgumentException will also be thrown.
+     */
+    @NonNull
+    private List<Size> getSortedChildSizes(@NonNull UseCaseConfig<?> childConfig) {
+        if (!mChildrenConfigs.contains(childConfig)) {
+            throw new IllegalArgumentException("Invalid child config: " + childConfig);
+        }
+
+        // Since getSortedSupportedOutputSizes() might be time consuming, use caching to improve
+        // the performance.
+        if (mChildSizesCache.containsKey(childConfig)) {
+            return Objects.requireNonNull(mChildSizesCache.get(childConfig));
+        }
+
+        List<Size> childSizes = mSizeSorter.getSortedSupportedOutputSizes(childConfig);
+        if (childSizes.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "No supported resolution for child config: " + childConfig);
+        }
+        mChildSizesCache.put(childConfig, childSizes);
+
+        return childSizes;
+    }
+
+    private boolean needToAddSensorResolutions() {
+        // Need to add sensor resolutions if any required resolution is not fallback aspect-ratio.
+        for (Size size : getChildrenRequiredResolutions()) {
+            if (!hasMatchingAspectRatio(size, mFallbackAspectRatio)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean needToAddFallbackResolutions() {
+        // Need to add fallback resolutions if any required resolution is fallback aspect-ratio.
+        for (Size size : getChildrenRequiredResolutions()) {
+            if (hasMatchingAspectRatio(size, mFallbackAspectRatio)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @NonNull
+    private Set<Size> getChildrenRequiredResolutions() {
+        Set<Size> result = new HashSet<>();
+        for (UseCaseConfig<?> childConfig : mChildrenConfigs) {
+            List<Size> childSizes = getSortedChildSizes(childConfig);
+            result.addAll(childSizes);
+        }
+
+        return result;
+    }
+
+    private boolean isSensorAspectRatio(@NonNull Size size) {
+        return hasMatchingAspectRatio(size, mSensorAspectRatio);
+    }
+
+    private boolean isFallbackAspectRatio(@NonNull Size size) {
+        return hasMatchingAspectRatio(size, mFallbackAspectRatio);
+    }
+
+    @NonNull
+    private static List<Size> getSupportedPrivResolutions(
+            @NonNull List<Pair<Integer, Size[]>> supportedResolutionsMap) {
+        for (Pair<Integer, Size[]> pair : supportedResolutionsMap) {
+            if (pair.first.equals(INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE)) {
+                return Arrays.asList(pair.second);
+            }
+        }
+
+        return new ArrayList<>();
+    }
+
+    /**
+     * Returns the aspect-ratio of 4:3 or 16:9 that is closer to the sensor size.
+     *
+     * <p>Parent resolutions with sensor aspect-ratio are considered to be non-cropped, so child
+     * resolution can have a different aspect-ratio than the parents without causing
+     * double-cropping.
+     */
+    @NonNull
+    private static Rational getSensorAspectRatio(@NonNull Size sensorSize) {
+        Rational result = findCloserAspectRatio(sensorSize);
+        Logger.d(TAG, "The closer aspect ratio to the sensor size (" + sensorSize + ") is "
+                + result + ".");
+
+        return result;
+    }
+
+    @NonNull
+    private static Rational findCloserAspectRatio(@NonNull Size size) {
+        double widthHeightRatio = size.getWidth() / (double) size.getHeight();
+
+        if (widthHeightRatio > SAME_AREA_WIDTH_HEIGHT_RATIO) {
+            return ASPECT_RATIO_16_9;
+        } else {
+            return ASPECT_RATIO_4_3;
+        }
+    }
+
+    /**
+     * Returns the aspect-ratio of 4:3 or 16:9 that is not the sensor aspect-ratio.
+     *
+     * <p>Parent resolutions with fallback aspect-ratio are considered to be cropped, so child
+     * resolution should not different to the parent or double-cropping will happen.
+     */
+    @NonNull
+    private static Rational getFallbackAspectRatio(@NonNull Rational sensorAspectRatio) {
+        if (sensorAspectRatio.equals(ASPECT_RATIO_4_3)) {
+            return ASPECT_RATIO_16_9;
+        } else if (sensorAspectRatio.equals(ASPECT_RATIO_16_9)) {
+            return ASPECT_RATIO_4_3;
+        } else {
+            throw new IllegalArgumentException("Invalid sensor aspect-ratio: " + sensorAspectRatio);
+        }
+    }
+
+    /**
+     * Sorts the input resolutions in descending order.
+     */
+    @VisibleForTesting
+    static void sortInDescendingOrder(@NonNull List<Size> resolutions) {
+        Collections.sort(resolutions, new CompareSizesByArea(true));
+    }
+
+    /**
+     * Returns a list of resolution that all resolutions are with the input aspect-ratio.
+     *
+     * <p>The order of the {@code resolutionsToFilter} will be preserved in the resulting list.
+     */
+    @VisibleForTesting
+    @NonNull
+    static List<Size> filterResolutionsByAspectRatio(@NonNull Rational aspectRatio,
+            @NonNull List<Size> resolutionsToFilter) {
+        List<Size> result = new ArrayList<>();
+        for (Size resolution : resolutionsToFilter) {
+            if (hasMatchingAspectRatio(resolution, aspectRatio)) {
+                result.add(resolution);
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * Filters out the parent size that is too small with consider target children sizes.
+     *
+     * <p>A size is too small if it cannot find any child size that can be cropped out without
+     * upscaling.
+     *
+     * <p>The order of the {@code sortedParentSizes} will be preserved in the resulting list.
+     *
+     * <p>Assuming {@code sortedParentSizes} is sorted in descending order and all sizes have same
+     * aspect-ratio.
+     */
+    @VisibleForTesting
+    @NonNull
+    static List<Size> filterOutParentSizeThatIsTooSmall(
+            @NonNull Collection<Size> childSizes, @NonNull List<Size> sortedParentSizes) {
+        int n = sortedParentSizes.size();
+
+        // Find the smallest parent size that can be cropped to at least one child size without
+        // upscaling by using binary search.
+        int lo = 0;
+        int hi = n - 1;
+        while (lo < hi) {
+            int mid = lo + (hi - lo + 1) / 2;
+            Size parentSize = sortedParentSizes.get(mid);
+            if (isAnyChildSizeCanBeCroppedOutWithoutUpscalingParent(childSizes, parentSize)) {
+                lo = mid;
+            } else {
+                hi = mid - 1;
+            }
+        }
+
+        // Add all parent sizes that can be cropped to at least one child size.
+        List<Size> result = new ArrayList<>();
+        for (int i = 0; i <= lo; i++) {
+            result.add(sortedParentSizes.get(i));
+        }
+
+        return result;
+    }
+
+    private static boolean isAnyChildSizeCanBeCroppedOutWithoutUpscalingParent(
+            @NonNull Collection<Size> childSizes, @NonNull Size parentSize) {
+        for (Size childSize : childSizes) {
+            if (!hasUpscaling(childSize, parentSize)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Returns resolutions that are too large with consider target children sizes.
+     *
+     * <p>A size is too large if there is another size smaller than that size and all children
+     * sizes can be cropped from that other size without upscaling.
+     *
+     * <p>The order of the {@code sortedParentSizes} will be preserved in the resulting list.
+     *
+     * <p>Assuming {@code sortedParentSizes} is sorted in descending order and all sizes have same
+     * aspect-ratio.
+     */
+    @VisibleForTesting
+    @NonNull
+    static List<Size> getParentSizesThatAreTooLarge(@NonNull Collection<Size> childSizes,
+            @NonNull List<Size> sortedParentSizes) {
+        int n = sortedParentSizes.size();
+
+        // Find the smallest parent size that can be cropped to all child sizes without upscaling
+        // by using binary search.
+        int lo = 0;
+        int hi = n - 1;
+        while (lo < hi) {
+            int mid = lo + (hi - lo + 1) / 2;
+            Size parentSize = sortedParentSizes.get(mid);
+            if (isAllChildSizesCanBeCroppedOutWithoutUpscalingParent(childSizes, parentSize)) {
+                lo = mid;
+            } else {
+                hi = mid - 1;
+            }
+        }
+
+        // Add all parent sizes that can be cropped to all child sizes, except the smallest one.
+        List<Size> result = new ArrayList<>();
+        for (int i = 0; i < lo; i++) {
+            result.add(sortedParentSizes.get(i));
+        }
+
+        return result;
+    }
+
+    private static boolean isAllChildSizesCanBeCroppedOutWithoutUpscalingParent(
+            @NonNull Collection<Size> childSizes, @NonNull Size parentSize) {
+        for (Size childSize : childSizes) {
+            if (hasUpscaling(childSize, parentSize)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Whether the parent size needs upscaling to fill the child size.
+     */
+    @VisibleForTesting
+    static boolean hasUpscaling(@NonNull Size childSize, @NonNull Size parentSize) {
+        // Upscaling is needed if child size is larger than the parent.
+        return childSize.getHeight() > parentSize.getHeight()
+                || childSize.getWidth() > parentSize.getWidth();
+    }
+}
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/ResolutionsMergerTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/ResolutionsMergerTest.kt
new file mode 100644
index 0000000..6caf41a
--- /dev/null
+++ b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/ResolutionsMergerTest.kt
@@ -0,0 +1,348 @@
+/*
+ * 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.core.streamsharing
+
+import android.os.Build
+import android.util.Size
+import androidx.camera.core.impl.MutableOptionsBundle
+import androidx.camera.core.impl.UseCaseConfig
+import androidx.camera.core.impl.utils.AspectRatioUtil.ASPECT_RATIO_16_9
+import androidx.camera.core.impl.utils.AspectRatioUtil.ASPECT_RATIO_4_3
+import androidx.camera.core.internal.SupportedOutputSizesSorter
+import androidx.camera.core.streamsharing.ResolutionsMerger.filterOutParentSizeThatIsTooSmall
+import androidx.camera.core.streamsharing.ResolutionsMerger.filterResolutionsByAspectRatio
+import androidx.camera.core.streamsharing.ResolutionsMerger.getParentSizesThatAreTooLarge
+import androidx.camera.core.streamsharing.ResolutionsMerger.hasUpscaling
+import androidx.camera.testing.fakes.FakeCameraInfoInternal
+import androidx.camera.testing.impl.fakes.FakeUseCaseConfig
+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
+
+/**
+ * Unit tests for [ResolutionsMerger].
+ */
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class ResolutionsMergerTest {
+
+    @Test(expected = IllegalArgumentException::class)
+    fun getMergedResolutions_whenNoSupportedChildSize_throwsException() {
+        // Arrange.
+        val sensorSize = SIZE_3264_2448 // 4:3
+        val config1 = createUseCaseConfig()
+        val config2 = createUseCaseConfig()
+        val childConfigs = setOf(config1, config2)
+        val candidateChildSizes1 = listOf(SIZE_1920_1080, SIZE_1280_720) // 16:9
+        val candidateChildSizes2 = emptyList<Size>() // no supported size
+        val sorter = FakeSupportedOutputSizesSorter(
+            mapOf(
+                config1 to candidateChildSizes1,
+                config2 to candidateChildSizes2
+            )
+        )
+        val merger = ResolutionsMerger(sensorSize, childConfigs, sorter, CAMERA_SUPPORTED_SIZES)
+
+        // Act & Assert.
+        val parentConfig = MutableOptionsBundle.create()
+        merger.getMergedResolutions(parentConfig)
+    }
+
+    @Test
+    fun getMergedResolutions_whenChildRequiresSensorAndNonSensorAspectRatio_canReturnCorrectly() {
+        // Arrange.
+        val sensorSize = SIZE_3264_2448 // 4:3
+        val config1 = createUseCaseConfig()
+        val config2 = createUseCaseConfig()
+        val childConfigs = setOf(config1, config2)
+        val candidateChildSizes1 = listOf(SIZE_1920_1080, SIZE_1280_720) // 16:9
+        val candidateChildSizes2 = listOf(SIZE_1280_960, SIZE_960_720, SIZE_640_480) // 4:3
+        val sorter = FakeSupportedOutputSizesSorter(
+            mapOf(
+                config1 to candidateChildSizes1,
+                config2 to candidateChildSizes2
+            )
+        )
+        val merger = ResolutionsMerger(sensorSize, childConfigs, sorter, CAMERA_SUPPORTED_SIZES)
+
+        // Act & Assert, should returns a list that concatenates 4:3 resolutions before 16:9
+        // resolutions and removes resolutions that are too large (no need for multiple resolutions
+        // that can be cropped to all child sizes) and too small (causing upscaling).
+        val parentConfig = MutableOptionsBundle.create()
+        assertThat(merger.getMergedResolutions(parentConfig)).containsExactly(
+            SIZE_1920_1440, SIZE_1280_960, SIZE_1920_1080, SIZE_1280_720
+        ).inOrder()
+    }
+
+    @Test
+    fun getMergedResolutions_whenChildRequiresOnlySensorAspectRatio_canReturnCorrectly() {
+        // Arrange.
+        val sensorSize = SIZE_3264_2448 // 4:3
+        val config1 = createUseCaseConfig()
+        val config2 = createUseCaseConfig()
+        val childConfigs = setOf(config1, config2)
+        val candidateChildSizes1 = listOf(SIZE_2560_1920, SIZE_1920_1440) // 4:3
+        val candidateChildSizes2 = listOf(SIZE_1280_960, SIZE_960_720) // 4:3
+        val sorter = FakeSupportedOutputSizesSorter(
+            mapOf(
+                config1 to candidateChildSizes1,
+                config2 to candidateChildSizes2
+            )
+        )
+        val merger = ResolutionsMerger(sensorSize, childConfigs, sorter, CAMERA_SUPPORTED_SIZES)
+
+        // Act & Assert, should returns a list of 4:3 resolutions and removes resolutions that are
+        // too large and too small.
+        val parentConfig = MutableOptionsBundle.create()
+        assertThat(merger.getMergedResolutions(parentConfig)).containsExactly(
+            SIZE_2560_1920, SIZE_1920_1440
+        ).inOrder()
+    }
+
+    @Test
+    fun getMergedResolutions_whenChildRequiresOnlyNonSensorAspectRatio_canReturnCorrectly() {
+        // Arrange.
+        val sensorSize = SIZE_3264_2448 // 4:3
+        val config1 = createUseCaseConfig()
+        val config2 = createUseCaseConfig()
+        val childConfigs = setOf(config1, config2)
+        val candidateChildSizes1 = listOf(SIZE_2560_1440, SIZE_1280_720) // 16:9
+        val candidateChildSizes2 = listOf(SIZE_1920_1080, SIZE_960_540) // 16:9
+        val sorter = FakeSupportedOutputSizesSorter(
+            mapOf(
+                config1 to candidateChildSizes1,
+                config2 to candidateChildSizes2
+            )
+        )
+        val merger = ResolutionsMerger(sensorSize, childConfigs, sorter, CAMERA_SUPPORTED_SIZES)
+
+        // Act & Assert, should returns a list of 16:9 resolutions and removes resolutions that are
+        // too large and too small.
+        val parentConfig = MutableOptionsBundle.create()
+        assertThat(merger.getMergedResolutions(parentConfig)).containsExactly(
+            SIZE_2560_1440, SIZE_1920_1080, SIZE_1280_720
+        ).inOrder()
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun getPreferredChildSize_withConfigNotPassedToConstructor_throwsException() {
+        // Arrange.
+        val config = createUseCaseConfig()
+        val sorter = FakeSupportedOutputSizesSorter(mapOf(config to SIZES_16_9))
+        val merger = ResolutionsMerger(SENSOR_SIZE, setOf(config), sorter, CAMERA_SUPPORTED_SIZES)
+
+        // Act.
+        val useCaseConfigNotPassedToConstructor = createUseCaseConfig()
+        merger.getPreferredChildSize(SIZE_1920_1440, useCaseConfigNotPassedToConstructor)
+    }
+
+    @Test
+    fun getPreferredChildSize_whenParentSizeIsSensorAspectRatio_canReturnCorrectly() {
+        // Arrange.
+        val config = createUseCaseConfig()
+        val candidateChildSizes = listOf(SIZE_2560_1440, SIZE_1920_1080, SIZE_960_540) // 16:9
+        val sorter = FakeSupportedOutputSizesSorter(mapOf(config to candidateChildSizes))
+        val merger = ResolutionsMerger(SENSOR_SIZE, setOf(config), sorter, CAMERA_SUPPORTED_SIZES)
+
+        // Act & Assert, should returns the first child size that do not need upscale.
+        assertThat(merger.getPreferredChildSize(SIZE_1920_1440, config)).isEqualTo(SIZE_1920_1080)
+        assertThat(merger.getPreferredChildSize(SIZE_1280_960, config)).isEqualTo(SIZE_960_540)
+
+        // Act & Assert, should returns parent size when no matching.
+        assertThat(merger.getPreferredChildSize(SIZE_640_480, config)).isEqualTo(SIZE_640_480)
+    }
+
+    @Test
+    fun getPreferredChildSize_whenParentSizeIsNotSensorAspectRatio_canReturnCorrectly() {
+        // Arrange.
+        val config = createUseCaseConfig()
+        val candidateChildSizes = listOf(
+            // 4:3
+            SIZE_2560_1920,
+            SIZE_1920_1440,
+            SIZE_1280_960,
+            SIZE_960_720,
+            // 16:9
+            SIZE_1920_1080,
+            SIZE_960_540
+        )
+        val sorter = FakeSupportedOutputSizesSorter(mapOf(config to candidateChildSizes))
+        val merger = ResolutionsMerger(SENSOR_SIZE, setOf(config), sorter, CAMERA_SUPPORTED_SIZES)
+
+        // Act & Assert, should returns the first child size that do not need upscale and cause
+        // double-cropping.
+        assertThat(merger.getPreferredChildSize(SIZE_2560_1440, config)).isEqualTo(SIZE_1920_1080)
+        assertThat(merger.getPreferredChildSize(SIZE_1280_720, config)).isEqualTo(SIZE_960_540)
+
+        // Act & Assert, should returns parent size when no matching.
+        assertThat(merger.getPreferredChildSize(SIZE_192_108, config)).isEqualTo(SIZE_192_108)
+    }
+
+    @Test
+    fun filterResolutionsByAspectRatio_canFilter_4_3() {
+        val sizes = SIZES_4_3 + SIZES_16_9 + SIZES_OTHER_ASPECT_RATIO
+        assertThat(filterResolutionsByAspectRatio(ASPECT_RATIO_4_3, sizes)).containsExactly(
+            *SIZES_4_3.toTypedArray()
+        ).inOrder()
+    }
+
+    @Test
+    fun filterResolutionsByAspectRatio_canFilter_16_9() {
+        val sizes = SIZES_4_3 + SIZES_16_9 + SIZES_OTHER_ASPECT_RATIO
+        assertThat(filterResolutionsByAspectRatio(ASPECT_RATIO_16_9, sizes)).containsExactly(
+            *SIZES_16_9.toTypedArray()
+        ).inOrder()
+    }
+
+    @Test
+    fun filterOutParentSizeThatIsTooSmall_canFilterOutSmallSizes() {
+        val parentSizes = listOf(
+            SIZE_3264_2448,
+            SIZE_2560_1920,
+            SIZE_1920_1440,
+            SIZE_1280_960,
+            SIZE_960_720,
+            SIZE_640_480,
+            SIZE_320_240
+        )
+        val childSizes = setOf(SIZE_1920_1080, SIZE_1280_720, SIZE_960_540)
+        assertThat(filterOutParentSizeThatIsTooSmall(childSizes, parentSizes)).containsExactly(
+            SIZE_3264_2448,
+            SIZE_2560_1920,
+            SIZE_1920_1440,
+            SIZE_1280_960,
+            SIZE_960_720,
+        ).inOrder()
+    }
+
+    @Test
+    fun getParentSizesThatAreTooLarge_canReturnLargeSizes() {
+        val parentSizes = listOf(
+            SIZE_3264_2448,
+            SIZE_2560_1920,
+            SIZE_1920_1440,
+            SIZE_1280_960,
+            SIZE_960_720,
+            SIZE_640_480,
+            SIZE_320_240
+        )
+        val childSizes = setOf(SIZE_1920_1080, SIZE_1280_720, SIZE_960_540)
+        assertThat(getParentSizesThatAreTooLarge(childSizes, parentSizes)).containsExactly(
+            SIZE_3264_2448,
+            SIZE_2560_1920
+        ).inOrder()
+    }
+
+    @Test
+    fun hasUpscaling_return_false_whenTwoSizesAreEqualed() {
+        assertThat(hasUpscaling(SIZE_1280_960, SIZE_1280_960)).isFalse()
+        assertThat(hasUpscaling(SIZE_1920_1080, SIZE_1920_1080)).isFalse()
+    }
+
+    @Test
+    fun hasUpscaling_return_false_whenChildSizeIsSmaller() {
+        assertThat(hasUpscaling(SIZE_1280_960, SIZE_1920_1440)).isFalse()
+        assertThat(hasUpscaling(SIZE_1280_720, SIZE_1920_1080)).isFalse()
+    }
+
+    @Test
+    fun hasUpscaling_return_true_whenChildSizeIsLarger() {
+        assertThat(hasUpscaling(SIZE_1920_1440, SIZE_1280_960)).isTrue()
+        assertThat(hasUpscaling(SIZE_1920_1080, SIZE_1280_720)).isTrue()
+    }
+
+    @Test
+    fun hasUpscaling_return_true_whenChildSizeIsLargerOnWidth() {
+        assertThat(hasUpscaling(SIZE_1440_720, SIZE_1280_960)).isTrue()
+        assertThat(hasUpscaling(SIZE_1440_720, SIZE_1280_720)).isTrue()
+    }
+
+    @Test
+    fun hasUpscaling_return_true_whenChildSizeIsLargerOnHeight() {
+        assertThat(hasUpscaling(SIZE_800_800, SIZE_1280_720)).isTrue()
+        assertThat(hasUpscaling(SIZE_720_720, SIZE_960_540)).isTrue()
+    }
+
+    private fun createUseCaseConfig(): UseCaseConfig<*> {
+        return FakeUseCaseConfig.Builder().useCaseConfig
+    }
+
+    /**
+     * A fake implementation of [SupportedOutputSizesSorter] for testing.
+     */
+    private class FakeSupportedOutputSizesSorter(
+        private val supportedOutputSizes: Map<UseCaseConfig<*>, List<Size>>
+    ) : SupportedOutputSizesSorter(FakeCameraInfoInternal(), null) {
+        override fun getSortedSupportedOutputSizes(useCaseConfig: UseCaseConfig<*>): List<Size> {
+            return supportedOutputSizes[useCaseConfig]!!
+        }
+    }
+
+    companion object {
+        // 4:3 resolutions.
+        private val SIZE_3264_2448 = Size(3264, 2448)
+        private val SIZE_2560_1920 = Size(2560, 1920)
+        private val SIZE_1920_1440 = Size(1920, 1440)
+        private val SIZE_1280_960 = Size(1280, 960)
+        private val SIZE_960_720 = Size(960, 720)
+        private val SIZE_640_480 = Size(640, 480)
+        private val SIZE_320_240 = Size(320, 240)
+        private val SIZES_4_3 = listOf(
+            SIZE_3264_2448,
+            SIZE_2560_1920,
+            SIZE_1920_1440,
+            SIZE_1280_960,
+            SIZE_960_720,
+            SIZE_640_480,
+            SIZE_320_240
+        )
+        // 16:9 resolutions.
+        private val SIZE_3840_2160 = Size(3840, 2160)
+        private val SIZE_2560_1440 = Size(2560, 1440)
+        private val SIZE_1920_1080 = Size(1920, 1080)
+        private val SIZE_1280_720 = Size(1280, 720)
+        private val SIZE_960_540 = Size(960, 540)
+        private val SIZE_192_108 = Size(192, 108)
+        private val SIZES_16_9 = listOf(
+            SIZE_3840_2160,
+            SIZE_2560_1440,
+            SIZE_1920_1080,
+            SIZE_1280_720,
+            SIZE_960_540,
+            SIZE_192_108
+        )
+        // Other aspect-ratio resolutions.
+        private val SIZE_1440_720 = Size(1440, 720)
+        private val SIZE_800_800 = Size(800, 800)
+        private val SIZE_720_720 = Size(720, 720)
+        private val SIZE_500_400 = Size(500, 400)
+        private val SIZE_176_144 = Size(176, 144)
+        private val SIZES_OTHER_ASPECT_RATIO = listOf(
+            SIZE_1440_720,
+            SIZE_800_800,
+            SIZE_720_720,
+            SIZE_500_400,
+            SIZE_176_144
+        )
+        private val CAMERA_SUPPORTED_SIZES = SIZES_4_3 + SIZES_16_9 + SIZES_OTHER_ASPECT_RATIO
+        private val SENSOR_SIZE = SIZE_3264_2448 // 4:3
+    }
+}