From e602ce372b80b555e5aa879685d7ba67809e3338 Mon Sep 17 00:00:00 2001 From: leo huang Date: Thu, 25 Dec 2025 11:36:08 +0800 Subject: [PATCH 1/6] Refactor: Extract EncoderProfilesProvider resolution into a separate class Extracts the logic for resolving the appropriate EncoderProfilesProvider from RecorderVideoCapabilities into a new EncoderProfilesProviderResolver class. This improves code modularity by decoupling resolution logic from the capabilities class. RecorderVideoCapabilities is now purely dependent on a pre-resolved EncoderProfilesProvider, no longer responsible for exploring qualities, injecting default qualities and create backup HDR profiles. Bug: 471353211 Test: ./gradlew camera:camera-video:testRelease Change-Id: Ib8da4095fb80eecbe5334b64c7b01a71dd0c317b --- .../androidx/camera/video/RecorderTest.kt | 45 ++++- .../video/RecorderVideoCapabilitiesTest.kt | 148 ---------------- .../video/EncoderProfilesProviderResolver.kt | 125 +++++++++++++ .../java/androidx/camera/video/Recorder.java | 26 ++- .../video/RecorderVideoCapabilities.java | 139 ++------------- .../EncoderProfilesProviderResolverTest.kt | 166 ++++++++++++++++++ .../video/RecorderVideoCapabilitiesTest.kt | 99 +++-------- 7 files changed, 391 insertions(+), 357 deletions(-) delete mode 100644 camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt create mode 100644 camera/camera-video/src/main/java/androidx/camera/video/EncoderProfilesProviderResolver.kt create mode 100644 camera/camera-video/src/test/java/androidx/camera/video/EncoderProfilesProviderResolverTest.kt diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt index e95f56aeab356..6fb5386366950 100644 --- a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt +++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt @@ -32,6 +32,7 @@ import android.media.MediaCodec import android.media.MediaMetadataRetriever import android.media.MediaRecorder import android.net.Uri +import android.os.Build import android.os.ParcelFileDescriptor import android.provider.MediaStore import android.util.Size @@ -44,6 +45,7 @@ import androidx.camera.core.DynamicRange import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest import androidx.camera.core.impl.AdapterCameraInfo +import androidx.camera.core.impl.CameraInfoInternal import androidx.camera.core.impl.ImageFormatConstants import androidx.camera.core.impl.Observable.Observer import androidx.camera.core.impl.utils.executor.CameraXExecutors.directExecutor @@ -193,6 +195,8 @@ class RecorderTest(private val implName: String, private val cameraConfig: Camer private lateinit var cameraProvider: ProcessCameraProvider private lateinit var camera: Camera private lateinit var cameraSelector: CameraSelector + private lateinit var cameraInfoInternal: CameraInfoInternal + private lateinit var videoCapabilities: VideoCapabilities private lateinit var preview: Preview private lateinit var surfaceTexturePreview: Preview @@ -220,7 +224,8 @@ class RecorderTest(private val implName: String, private val cameraConfig: Camer // Using Preview so that the surface provider could be set to control when to issue the // surface request. val cameraInfo = camera.cameraInfo - val videoCapabilities = Recorder.getVideoCapabilities(cameraInfo) + cameraInfoInternal = cameraInfo as CameraInfoInternal + videoCapabilities = Recorder.getVideoCapabilities(cameraInfo) val candidates = mutableSetOf().apply { if (testName.methodName == "setFileSizeLimit") { @@ -1392,6 +1397,44 @@ class RecorderTest(private val implName: String, private val cameraConfig: Camer assertThat(capabilities1).isNotSameInstanceAs(capabilities2) } + @Test + fun getVideoCapabilities_supportStandardDynamicRange() { + assumeFalse(isDeviceWithCamcorderProfileResolutionMismatch()) + + assertThat(videoCapabilities.supportedDynamicRanges).contains(DynamicRange.SDR) + } + + @Test + fun getVideoCapabilities_supportedQualitiesOfSdrIsNotEmpty() { + assumeFalse(isDeviceWithCamcorderProfileResolutionMismatch()) + + assertThat(videoCapabilities.getSupportedQualities(DynamicRange.SDR)).isNotEmpty() + } + + /** + * Checks if the device has a known mismatch between CamcorderProfile resolutions and the + * camera's supported output sizes (b/231903433). + * + * See go/camerax-camcorder-profile-no-matching-resolutions + */ + private fun isDeviceWithCamcorderProfileResolutionMismatch(): Boolean { + 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 + } + + @Test + fun getHighSpeedVideoCapabilities_whenCameraDoesNotSupportHighSpeed_returnNull() { + assumeFalse(cameraInfoInternal.isHighSpeedSupported) + + val videoCapabilities = Recorder.getHighSpeedVideoCapabilities(camera.cameraInfo) + + assertThat(videoCapabilities).isNull() + } + private fun testRecorderIsConfiguredBasedOnTargetVideoEncodingBitrate(targetBitrate: Int) { // Arrange. val recorder = createRecorder(targetBitrate = targetBitrate) 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 deleted file mode 100644 index 1110fac96eb98..0000000000000 --- a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * 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.CameraXConfig -import androidx.camera.core.DynamicRange.SDR -import androidx.camera.core.impl.CameraInfoInternal -import androidx.camera.testing.impl.AndroidUtil.isEmulator -import androidx.camera.testing.impl.CameraPipeConfigTestRule -import androidx.camera.testing.impl.CameraUtil -import androidx.camera.testing.impl.CameraXUtil -import androidx.camera.video.Quality.QUALITY_SOURCE_REGULAR -import androidx.camera.video.Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE -import androidx.camera.video.Recorder.VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES -import androidx.camera.video.internal.encoder.VideoEncoderInfoImpl -import androidx.test.core.app.ApplicationProvider -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.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized - -@RunWith(Parameterized::class) -@SmallTest -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.grantCameraPermissionAndPreTestAndPostTest( - 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 lateinit var cameraInfo: CameraInfoInternal - private lateinit var videoCapabilities: RecorderVideoCapabilities - - @Before - fun setUp() { - // Skip for b/264902324 - assumeFalse( - "Emulator API 30 crashes running this test.", - Build.VERSION.SDK_INT == 30 && isEmulator(), - ) - - val cameraSelector = CameraUtil.assumeFirstAvailableCameraSelector() - - CameraXUtil.initialize(context, cameraConfig).get() - - cameraInfo = - CameraUtil.createCameraUseCaseAdapter(context, cameraSelector).cameraInfo - as CameraInfoInternal - videoCapabilities = - RecorderVideoCapabilities( - VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, - cameraInfo, - QUALITY_SOURCE_REGULAR, - VideoEncoderInfoImpl.FINDER, - ) - } - - @After - fun tearDown() { - CameraXUtil.shutdown().get(10, TimeUnit.SECONDS) - } - - @Test - fun supportStandardDynamicRange() { - assumeFalse(isSpecificSkippedDevice()) - assertThat(videoCapabilities.supportedDynamicRanges).contains(SDR) - } - - @Test - fun supportedQualitiesOfSdrIsNotEmpty() { - assumeFalse(isSpecificSkippedDevice()) - assertThat(videoCapabilities.getSupportedQualities(SDR)).isNotEmpty() - } - - @Test - fun whenCameraDoesNotSupportHighSpeed_highSpeedVideoCapabilitiesIsEmpty() { - assumeFalse(cameraInfo.isHighSpeedSupported) - - for (sourceType in - listOf( - VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, - VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES, - )) { - val capabilities = - RecorderVideoCapabilities( - sourceType, - cameraInfo, - Recorder.VIDEO_RECORDING_TYPE_HIGH_SPEED, - VideoEncoderInfoImpl.FINDER, - ) - assertThat(capabilities.supportedDynamicRanges).isEmpty() - } - } - - 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 - } -} diff --git a/camera/camera-video/src/main/java/androidx/camera/video/EncoderProfilesProviderResolver.kt b/camera/camera-video/src/main/java/androidx/camera/video/EncoderProfilesProviderResolver.kt new file mode 100644 index 0000000000000..91f715465ac66 --- /dev/null +++ b/camera/camera-video/src/main/java/androidx/camera/video/EncoderProfilesProviderResolver.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2025 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 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.ImageFormatConstants +import androidx.camera.video.internal.BackupHdrProfileEncoderProfilesProvider +import androidx.camera.video.internal.QualityExploredEncoderProfilesProvider +import androidx.camera.video.internal.compat.quirk.DeviceQuirks +import androidx.camera.video.internal.encoder.VideoEncoderInfo +import androidx.camera.video.internal.workaround.DefaultEncoderProfilesProvider +import androidx.camera.video.internal.workaround.QualityAddedEncoderProfilesProvider +import androidx.camera.video.internal.workaround.QualityResolutionModifiedEncoderProfilesProvider +import androidx.camera.video.internal.workaround.QualityValidatedEncoderProfilesProvider +import java.util.Collections + +/** Resolver for providing [EncoderProfilesProvider] used by [Recorder]. */ +internal object EncoderProfilesProviderResolver { + + private const val TAG = "EncoderProfilesResolver" + + /** Resolves the [EncoderProfilesProvider] based on the camera info and source. */ + @JvmStatic + fun resolve( + cameraInfo: CameraInfoInternal, + @Recorder.VideoCapabilitiesSource videoCapabilitiesSource: Int, + @Quality.QualitySource qualitySource: Int, + videoEncoderInfoFinder: VideoEncoderInfo.Finder, + ): EncoderProfilesProvider { + require( + videoCapabilitiesSource == Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE || + videoCapabilitiesSource == Recorder.VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES + ) { + "Not a supported video capabilities source: $videoCapabilitiesSource" + } + + var provider = cameraInfo.encoderProfilesProvider + + if (qualitySource == Quality.QUALITY_SOURCE_HIGH_SPEED) { + if (!cameraInfo.isHighSpeedSupported) { + return EncoderProfilesProvider.EMPTY + } + + // TODO(b/399585664): explore high speed quality when video source is + // VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES + return provider + } + + if (!CapabilitiesByQuality.containsSupportedQuality(provider, qualitySource)) { + Logger.w(TAG, "Camera EncoderProfilesProvider doesn't contain any supported Quality.") + // Limit maximum supported video resolution to 1080p(FHD). + // While 2160p(UHD) may be reported as supported by the Camera and MediaCodec APIs, + // testing on lab devices has shown that recording at this resolution is not always + // reliable. This aligns with the Android 5.1 CDD, which recommends 1080p as the + // supported resolution. + // See: https://source.android.com/static/docs/compatibility/5.1/android-5.1-cdd.pdf, + // 5.2. Video Encoding. + val targetQualities = listOf(Quality.FHD, Quality.HD, Quality.SD) + provider = + DefaultEncoderProfilesProvider(cameraInfo, targetQualities, videoEncoderInfoFinder) + } + + val deviceQuirks = DeviceQuirks.getAll() + + // Decorate with extra supported qualities + provider = + QualityAddedEncoderProfilesProvider( + provider, + deviceQuirks, + cameraInfo, + videoEncoderInfoFinder, + ) + + if (videoCapabilitiesSource == Recorder.VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES) { + provider = + QualityExploredEncoderProfilesProvider( + provider, + Quality.getSortedQualities(), + Collections.singleton(DynamicRange.SDR), + cameraInfo.getSupportedResolutions( + ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE + ), + videoEncoderInfoFinder, + ) + } + + // Modify matching resolutions based on camera support + provider = QualityResolutionModifiedEncoderProfilesProvider(provider, deviceQuirks) + + // Add backup HDR profiles (HLG10) + if (cameraInfo.isHlg10Supported) { + provider = BackupHdrProfileEncoderProfilesProvider(provider, videoEncoderInfoFinder) + } + + // Filter for validated qualities + provider = QualityValidatedEncoderProfilesProvider(provider, cameraInfo, deviceQuirks) + + return provider + } + + /** Extension property to check HLG10 support from supported dynamic ranges. */ + private val CameraInfoInternal.isHlg10Supported: Boolean + get() = + supportedDynamicRanges.any { + it.encoding == DynamicRange.ENCODING_HLG && + it.bitDepth == DynamicRange.BIT_DEPTH_10_BIT + } +} 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 5a0fe35e41678..9c2e1556eed63 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 @@ -77,6 +77,7 @@ import androidx.camera.core.impl.AdapterCameraInfo; import androidx.camera.core.impl.CameraConfig; import androidx.camera.core.impl.CameraInfoInternal; +import androidx.camera.core.impl.EncoderProfilesProvider; import androidx.camera.core.impl.MutableStateObservable; import androidx.camera.core.impl.Observable; import androidx.camera.core.impl.StateObservable; @@ -3159,9 +3160,7 @@ private static int supportedMuxerFormatOrDefaultFrom( @NonNull CameraInfo cameraInfo, @VideoCapabilitiesSource int videoCapabilitiesSource) { if (shouldSkipCapabilitiesCache(cameraInfo)) { - return new RecorderVideoCapabilities(videoCapabilitiesSource, - (CameraInfoInternal) cameraInfo, videoRecordingType, - VideoEncoderInfoImpl.FINDER); + return createVideoCapabilities(videoRecordingType, cameraInfo, videoCapabilitiesSource); } AdapterCameraInfo adapterCameraInfo = (AdapterCameraInfo) cameraInfo; @@ -3175,15 +3174,30 @@ private static int supportedMuxerFormatOrDefaultFrom( VideoCapabilities capabilities = sVideoCapabilitiesCache.get(key); if (capabilities == null) { - capabilities = new RecorderVideoCapabilities(videoCapabilitiesSource, - (CameraInfoInternal) cameraInfo, videoRecordingType, - VideoEncoderInfoImpl.FINDER); + capabilities = createVideoCapabilities(videoRecordingType, cameraInfo, + videoCapabilitiesSource); sVideoCapabilitiesCache.put(key, capabilities); } return capabilities; } } + private static @NonNull VideoCapabilities createVideoCapabilities( + @VideoRecordingType int videoRecordingType, + @NonNull CameraInfo cameraInfo, + @VideoCapabilitiesSource int videoCapabilitiesSource) { + @Quality.QualitySource + int qualitySource = videoRecordingType == Recorder.VIDEO_RECORDING_TYPE_HIGH_SPEED + ? Quality.QUALITY_SOURCE_HIGH_SPEED : Quality.QUALITY_SOURCE_REGULAR; + + EncoderProfilesProvider resolvedProvider = EncoderProfilesProviderResolver.resolve( + (CameraInfoInternal) cameraInfo, videoCapabilitiesSource, qualitySource, + VideoEncoderInfoImpl.FINDER); + + return new RecorderVideoCapabilities(resolvedProvider, qualitySource, + (CameraInfoInternal) cameraInfo); + } + /** * Checks whether the video capabilities for a given camera should be cached. * 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 index ad38d0532fa4a..999e80d4da83b 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/RecorderVideoCapabilities.java +++ b/camera/camera-video/src/main/java/androidx/camera/video/RecorderVideoCapabilities.java @@ -16,52 +16,24 @@ package androidx.camera.video; -import static androidx.camera.core.DynamicRange.ENCODING_HLG; -import static androidx.camera.core.DynamicRange.SDR; -import static androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE; -import static androidx.camera.video.CapabilitiesByQuality.containsSupportedQuality; -import static androidx.camera.video.Quality.FHD; -import static androidx.camera.video.Quality.HD; -import static androidx.camera.video.Quality.QUALITY_SOURCE_HIGH_SPEED; -import static androidx.camera.video.Quality.QUALITY_SOURCE_REGULAR; -import static androidx.camera.video.Quality.SD; -import static androidx.camera.video.Quality.getSortedQualities; -import static androidx.camera.video.Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE; -import static androidx.camera.video.Recorder.VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES; -import static androidx.camera.video.Recorder.VIDEO_RECORDING_TYPE_HIGH_SPEED; -import static androidx.core.util.Preconditions.checkArgument; - -import static java.util.Collections.singleton; - import android.util.Size; import androidx.annotation.RestrictTo; 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.DynamicRanges; 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.video.Quality.QualitySource; -import androidx.camera.video.internal.BackupHdrProfileEncoderProfilesProvider; import androidx.camera.video.internal.DynamicRangeMatchedEncoderProfilesProvider; -import androidx.camera.video.internal.QualityExploredEncoderProfilesProvider; import androidx.camera.video.internal.VideoValidatedEncoderProfilesProxy; -import androidx.camera.video.internal.compat.quirk.DeviceQuirks; -import androidx.camera.video.internal.encoder.VideoEncoderInfo; -import androidx.camera.video.internal.workaround.DefaultEncoderProfilesProvider; -import androidx.camera.video.internal.workaround.QualityAddedEncoderProfilesProvider; -import androidx.camera.video.internal.workaround.QualityResolutionModifiedEncoderProfilesProvider; -import androidx.camera.video.internal.workaround.QualityValidatedEncoderProfilesProvider; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -71,16 +43,12 @@ * RecorderVideoCapabilities is used to query video recording capabilities related to Recorder. * *

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. + * {@link VideoProfileProxy}s matches with the target {@link DynamicRange}. * * @see Recorder#getVideoCapabilities(CameraInfo) */ @RestrictTo(RestrictTo.Scope.LIBRARY) public class RecorderVideoCapabilities implements VideoCapabilities { - private static final String TAG = "RecorderVideoCapabilities"; - private final EncoderProfilesProvider mProfilesProvider; private final boolean mIsStabilizationSupported; private final @QualitySource int mQualitySource; @@ -98,28 +66,19 @@ public class RecorderVideoCapabilities implements VideoCapabilities { /** * Creates a RecorderVideoCapabilities. * - * @param videoCapabilitiesSource the video capabilities source. Possible values include - * {@link Recorder#VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE} - * and - * {@link Recorder#VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES}. + * @param profilesProvider the encoder profiles provider. * @param cameraInfo the cameraInfo. - * @param videoEncoderInfoFinder the VideoEncoderInfo finder. - * @param videoCaptureType the video capture type. + * @param qualitySource the quality source. * @throws IllegalArgumentException if unable to get the capability information from the * CameraInfo or the videoCapabilitiesSource is not supported. */ - RecorderVideoCapabilities(@Recorder.VideoCapabilitiesSource int videoCapabilitiesSource, - @NonNull CameraInfoInternal cameraInfo, - @Recorder.VideoRecordingType int videoCaptureType, - VideoEncoderInfo.@NonNull Finder videoEncoderInfoFinder) { - checkArgument(videoCapabilitiesSource == VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE - || videoCapabilitiesSource == VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES, - "Not a supported video capabilities source: " + videoCapabilitiesSource); - - mQualitySource = videoCaptureType == VIDEO_RECORDING_TYPE_HIGH_SPEED - ? QUALITY_SOURCE_HIGH_SPEED : QUALITY_SOURCE_REGULAR; - mProfilesProvider = getEncoderProfilesProvider(videoCapabilitiesSource, cameraInfo, - videoEncoderInfoFinder, mQualitySource); + RecorderVideoCapabilities( + @NonNull EncoderProfilesProvider profilesProvider, + @Quality.QualitySource int qualitySource, + @NonNull CameraInfoInternal cameraInfo + ) { + mQualitySource = qualitySource; + mProfilesProvider = profilesProvider; // Group by dynamic range. for (DynamicRange dynamicRange : cameraInfo.getSupportedDynamicRanges()) { @@ -193,70 +152,6 @@ public boolean isStabilizationSupported() { : capabilities.findNearestHigherSupportedQualityFor(size); } - private static @NonNull EncoderProfilesProvider getEncoderProfilesProvider( - @Recorder.VideoCapabilitiesSource int videoCapabilitiesSource, - @NonNull CameraInfoInternal cameraInfo, - VideoEncoderInfo.@NonNull Finder videoEncoderInfoFinder, - @QualitySource int qualitySource) { - - EncoderProfilesProvider encoderProfilesProvider = cameraInfo.getEncoderProfilesProvider(); - - if (qualitySource == QUALITY_SOURCE_HIGH_SPEED) { - - if (!cameraInfo.isHighSpeedSupported()) { - return EncoderProfilesProvider.EMPTY; - } - - // TODO(b/399585664): explore high speed quality when video source is - // VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES - return encoderProfilesProvider; - } - - if (!containsSupportedQuality(encoderProfilesProvider, qualitySource)) { - Logger.w(TAG, "Camera EncoderProfilesProvider doesn't contain any supported Quality."); - // Limit maximum supported video resolution to 1080p(FHD). - // While 2160p(UHD) may be reported as supported by the Camera and MediaCodec APIs, - // testing on lab devices has shown that recording at this resolution is not always - // reliable. This aligns with the Android 5.1 CDD, which recommends 1080p as the - // supported resolution. - // See: https://source.android.com/static/docs/compatibility/5.1/android-5.1-cdd.pdf, - // 5.2. Video Encoding. - List targetQualities = Arrays.asList(FHD, HD, SD); - encoderProfilesProvider = new DefaultEncoderProfilesProvider(cameraInfo, - targetQualities, videoEncoderInfoFinder); - } - - Quirks deviceQuirks = DeviceQuirks.getAll(); - // Add extra supported quality. - encoderProfilesProvider = new QualityAddedEncoderProfilesProvider(encoderProfilesProvider, - deviceQuirks, cameraInfo, videoEncoderInfoFinder); - - if (videoCapabilitiesSource == VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES) { - encoderProfilesProvider = new QualityExploredEncoderProfilesProvider( - encoderProfilesProvider, - getSortedQualities(), - singleton(SDR), - cameraInfo.getSupportedResolutions(INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE), - videoEncoderInfoFinder); - } - - // Modify qualities' matching resolution to the value supported by camera. - encoderProfilesProvider = new QualityResolutionModifiedEncoderProfilesProvider( - encoderProfilesProvider, deviceQuirks); - - // Add backup HDR video information. In the initial version, only HLG10 profile is added. - if (isHlg10SupportedByCamera(cameraInfo)) { - encoderProfilesProvider = new BackupHdrProfileEncoderProfilesProvider( - encoderProfilesProvider, videoEncoderInfoFinder); - } - - // Filter out unsupported qualities. - encoderProfilesProvider = new QualityValidatedEncoderProfilesProvider( - encoderProfilesProvider, cameraInfo, deviceQuirks); - - return encoderProfilesProvider; - } - private @Nullable CapabilitiesByQuality getCapabilities(@NonNull DynamicRange dynamicRange) { if (dynamicRange.isFullySpecified()) { return mCapabilitiesMapForFullySpecifiedDynamicRange.get(dynamicRange); @@ -273,20 +168,6 @@ public boolean isStabilizationSupported() { } } - private static boolean isHlg10SupportedByCamera( - @NonNull CameraInfoInternal cameraInfoInternal) { - Set dynamicRanges = cameraInfoInternal.getSupportedDynamicRanges(); - for (DynamicRange dynamicRange : dynamicRanges) { - Integer encoding = dynamicRange.getEncoding(); - int bitDepth = dynamicRange.getBitDepth(); - if (encoding.equals(ENCODING_HLG) && bitDepth == DynamicRange.BIT_DEPTH_10_BIT) { - return true; - } - } - - return false; - } - private @Nullable CapabilitiesByQuality generateCapabilitiesForNonFullySpecifiedDynamicRange( @NonNull DynamicRange dynamicRange) { if (!DynamicRanges.canResolve(dynamicRange, getSupportedDynamicRanges())) { diff --git a/camera/camera-video/src/test/java/androidx/camera/video/EncoderProfilesProviderResolverTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/EncoderProfilesProviderResolverTest.kt new file mode 100644 index 0000000000000..c6c5cd7b217fc --- /dev/null +++ b/camera/camera-video/src/test/java/androidx/camera/video/EncoderProfilesProviderResolverTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2025 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.QUALITY_1080P +import android.media.CamcorderProfile.QUALITY_2160P +import android.media.CamcorderProfile.QUALITY_HIGH_SPEED_HIGH +import android.media.EncoderProfiles.VideoProfile +import androidx.camera.core.DynamicRange +import androidx.camera.core.impl.EncoderProfilesProvider +import androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE +import androidx.camera.testing.fakes.FakeCameraInfoInternal +import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_1080P +import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_1080P +import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_2160P +import androidx.camera.testing.impl.fakes.FakeEncoderProfilesProvider +import androidx.camera.testing.impl.fakes.FakeVideoEncoderInfo +import androidx.camera.video.Quality.QUALITY_SOURCE_HIGH_SPEED +import androidx.camera.video.Quality.QUALITY_SOURCE_REGULAR +import androidx.camera.video.Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE +import androidx.camera.video.Recorder.VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES +import androidx.camera.video.internal.encoder.VideoEncoderInfo +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 + +@RunWith(RobolectricTestRunner::class) +@DoNotInstrument +@Config(sdk = [Config.ALL_SDKS]) +class EncoderProfilesProviderResolverTest { + + private val videoEncoderInfoFinder = VideoEncoderInfo.Finder { FakeVideoEncoderInfo() } + + @Test + fun resolve_highSpeed_returnsProviderIfSupported() { + val cameraInfo = + FakeCameraInfoInternal().apply { + isHighSpeedSupported = true + encoderProfilesProvider = + FakeEncoderProfilesProvider.Builder() + .add(QUALITY_HIGH_SPEED_HIGH, PROFILES_1080P) + .build() + } + + val provider = + EncoderProfilesProviderResolver.resolve( + cameraInfo, + VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + QUALITY_SOURCE_HIGH_SPEED, + videoEncoderInfoFinder, + ) + + assertThat(provider).isSameInstanceAs(cameraInfo.encoderProfilesProvider) + } + + @Test + fun resolve_highSpeed_returnsEmptyIfNotSupported() { + val cameraInfo = + FakeCameraInfoInternal().apply { + isHighSpeedSupported = false + encoderProfilesProvider = + FakeEncoderProfilesProvider.Builder() + .add(QUALITY_HIGH_SPEED_HIGH, PROFILES_1080P) + .build() + } + + val provider = + EncoderProfilesProviderResolver.resolve( + cameraInfo, + VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + QUALITY_SOURCE_HIGH_SPEED, + videoEncoderInfoFinder, + ) + + assertThat(provider).isSameInstanceAs(EncoderProfilesProvider.EMPTY) + } + + @Test + fun resolve_noSupportedQuality_usesDefaultProvider() { + val cameraInfo = + FakeCameraInfoInternal().apply { + encoderProfilesProvider = EncoderProfilesProvider.EMPTY + setSupportedResolutions( + INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE, + listOf(RESOLUTION_1080P), + ) + } + + val provider = + EncoderProfilesProviderResolver.resolve( + cameraInfo, + VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + QUALITY_SOURCE_REGULAR, + videoEncoderInfoFinder, + ) + + // Assert: 1080p is added. + assertThat(provider.hasProfile(QUALITY_1080P)).isTrue() + } + + @Test + fun resolve_codecCapabilitiesSource_usesQualityExploredProvider() { + val cameraInfo = + FakeCameraInfoInternal().apply { + encoderProfilesProvider = + FakeEncoderProfilesProvider.Builder().add(QUALITY_1080P, PROFILES_1080P).build() + setSupportedResolutions( + INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE, + listOf(RESOLUTION_1080P, RESOLUTION_2160P), + ) + } + + val provider = + EncoderProfilesProviderResolver.resolve( + cameraInfo, + VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES, + QUALITY_SOURCE_REGULAR, + videoEncoderInfoFinder, + ) + + // Assert: 2160P is explored. + assertThat(provider.hasProfile(QUALITY_2160P)).isTrue() + } + + @Test + fun resolve_hlg10Supported_usesBackupHdrProvider() { + val cameraInfo = + FakeCameraInfoInternal().apply { + encoderProfilesProvider = + FakeEncoderProfilesProvider.Builder().add(QUALITY_1080P, PROFILES_1080P).build() + supportedDynamicRanges = setOf(DynamicRange.HLG_10_BIT) + } + + val provider = + EncoderProfilesProviderResolver.resolve( + cameraInfo, + VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + QUALITY_SOURCE_REGULAR, + videoEncoderInfoFinder, + ) + + assertThat( + provider.getAll(QUALITY_1080P)!!.videoProfiles.any { + it.hdrFormat == VideoProfile.HDR_HLG + } + ) + .isTrue() + } +} 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 index bc8d50723e721..0331c82e8da54 100644 --- a/camera/camera-video/src/test/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt +++ b/camera/camera-video/src/test/java/androidx/camera/video/RecorderVideoCapabilitiesTest.kt @@ -37,7 +37,6 @@ import androidx.camera.core.DynamicRange.HDR_UNSPECIFIED_10_BIT import androidx.camera.core.DynamicRange.HLG_10_BIT import androidx.camera.core.DynamicRange.SDR import androidx.camera.core.DynamicRange.UNSPECIFIED -import androidx.camera.core.impl.EncoderProfilesProvider import androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE import androidx.camera.testing.fakes.FakeCameraInfoInternal import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_2160P @@ -46,6 +45,7 @@ import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_1080P import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_2160P import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_480P import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_720P +import androidx.camera.testing.impl.EncoderProfilesUtil.createFakeEncoderProfilesProxy import androidx.camera.testing.impl.EncoderProfilesUtil.createFakeHighSpeedEncoderProfilesProxy import androidx.camera.testing.impl.FrameRateUtil.FPS_120_120 import androidx.camera.testing.impl.FrameRateUtil.FPS_240 @@ -56,22 +56,18 @@ import androidx.camera.testing.impl.FrameRateUtil.FPS_30_480 import androidx.camera.testing.impl.FrameRateUtil.FPS_480 import androidx.camera.testing.impl.FrameRateUtil.FPS_480_480 import androidx.camera.testing.impl.fakes.FakeEncoderProfilesProvider -import androidx.camera.testing.impl.fakes.FakeVideoEncoderInfo 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.QUALITY_SOURCE_HIGH_SPEED +import androidx.camera.video.Quality.QUALITY_SOURCE_REGULAR import androidx.camera.video.Quality.SD import androidx.camera.video.Quality.UHD -import androidx.camera.video.Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE -import androidx.camera.video.Recorder.VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES -import androidx.camera.video.Recorder.VIDEO_RECORDING_TYPE_HIGH_SPEED -import androidx.camera.video.Recorder.VIDEO_RECORDING_TYPE_REGULAR 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.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner @@ -86,16 +82,15 @@ private val DOLBY_VISION_UNSPECIFIED = DynamicRange(ENCODING_DOLBY_VISION, BIT_D @RunWith(ParameterizedRobolectricTestRunner::class) @DoNotInstrument @Config(sdk = [Config.ALL_SDKS]) -class RecorderVideoCapabilitiesTest(private val videoCaptureType: Int) { +class RecorderVideoCapabilitiesTest(private val qualitySource: Int) { companion object { @JvmStatic - @ParameterizedRobolectricTestRunner.Parameters(name = "videoCaptureType={0}") - fun data() = - listOf(arrayOf(VIDEO_RECORDING_TYPE_REGULAR), arrayOf(VIDEO_RECORDING_TYPE_HIGH_SPEED)) + @ParameterizedRobolectricTestRunner.Parameters(name = "qualitySource={0}") + fun data() = listOf(arrayOf(QUALITY_SOURCE_REGULAR), arrayOf(QUALITY_SOURCE_HIGH_SPEED)) } - private val isHighSpeed = videoCaptureType == VIDEO_RECORDING_TYPE_HIGH_SPEED + private val isHighSpeed = qualitySource == QUALITY_SOURCE_HIGH_SPEED private val defaultProfilesProvider = FakeEncoderProfilesProvider.Builder() @@ -119,11 +114,20 @@ class RecorderVideoCapabilitiesTest(private val videoCaptureType: Int) { add(QUALITY_HIGH_SPEED_720P, profile720p480fpsSdrHlg) // HD (720p) add(QUALITY_HIGH_SPEED_LOW, profile720p480fpsSdrHlg) } else { - // HLG profiles will be generated by BackupHdrProfileEncoderProfilesProvider - add(QUALITY_HIGH, PROFILES_2160P) // UHD (2160p) per above definition - add(QUALITY_2160P, PROFILES_2160P) // UHD (2160p) - add(QUALITY_720P, PROFILES_720P) // HD (720p) - add(QUALITY_LOW, PROFILES_720P) // HD (720p) per above definition + val profile2160pSdrHlg = + createFakeEncoderProfilesProxy( + videoResolution = RESOLUTION_2160P, + dynamicRanges = setOf(SDR, HLG_10_BIT), + ) + val profile720pSdrHlg = + createFakeEncoderProfilesProxy( + videoResolution = RESOLUTION_720P, + dynamicRanges = setOf(SDR, HLG_10_BIT), + ) + add(QUALITY_HIGH, profile2160pSdrHlg) // UHD (2160p) per above definition + add(QUALITY_2160P, profile2160pSdrHlg) // UHD (2160p) + add(QUALITY_720P, profile720pSdrHlg) // HD (720p) + add(QUALITY_LOW, profile720pSdrHlg) // HD (720p) per above definition } .build() } @@ -182,13 +186,7 @@ class RecorderVideoCapabilitiesTest(private val videoCaptureType: Int) { else PROFILES_720P ) private val videoCapabilities = - RecorderVideoCapabilities( - VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, - cameraInfo, - videoCaptureType, - ) { - FakeVideoEncoderInfo() - } + RecorderVideoCapabilities(cameraInfo.encoderProfilesProvider, qualitySource, cameraInfo) @Test fun canGetSupportedDynamicRanges() { @@ -265,7 +263,7 @@ class RecorderVideoCapabilitiesTest(private val videoCaptureType: Int) { } @Test - fun isQualitySupported_hlg10WithBackupProfile() { + fun isQualitySupported_hlg10() { assertThat(videoCapabilities.isQualitySupported(HIGHEST, HLG_10_BIT)).isTrue() assertThat(videoCapabilities.isQualitySupported(LOWEST, HLG_10_BIT)).isTrue() assertThat(videoCapabilities.isQualitySupported(UHD, HLG_10_BIT)).isTrue() @@ -275,7 +273,7 @@ class RecorderVideoCapabilitiesTest(private val videoCaptureType: Int) { } @Test - fun isQualitySupported_hdrUnspecified10BitWithBackupProfile() { + fun isQualitySupported_hdrUnspecified10Bit() { 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() @@ -295,7 +293,7 @@ class RecorderVideoCapabilitiesTest(private val videoCaptureType: Int) { } @Test - fun canGetNonNullHdrUnspecifiedBackupProfile_whenSdrProfileExisted() { + fun canGetNonNullHdrUnspecified() { 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() @@ -305,7 +303,7 @@ class RecorderVideoCapabilitiesTest(private val videoCaptureType: Int) { } @Test - fun canGetNonNullHlg10BackupProfile_whenSdrProfileExisted() { + fun canGetNonNullHlg10() { assertThat(videoCapabilities.getProfiles(HIGHEST, HLG_10_BIT)).isNotNull() assertThat(videoCapabilities.getProfiles(LOWEST, HLG_10_BIT)).isNotNull() assertThat(videoCapabilities.getProfiles(UHD, HLG_10_BIT)).isNotNull() @@ -400,49 +398,4 @@ class RecorderVideoCapabilitiesTest(private val videoCaptureType: Int) { ) .isEqualTo(validatedProfiles720p) } - - @Test - fun createBySourceCodecCapabilities_additionalQualitiesAreSupported() { - // TODO(b/399585664): Remove this assumption when high speed quality exploration is - // supported. - assumeTrue("High speed mode does not yet support quality exploration", !isHighSpeed) - - val codecVideoCapabilities = - RecorderVideoCapabilities( - VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES, - cameraInfo, - videoCaptureType, - ) { - FakeVideoEncoderInfo() - } - - // FHD and SD should become supported. - assertThat(videoCapabilities.isQualitySupported(FHD, SDR)).isFalse() - assertThat(videoCapabilities.isQualitySupported(SD, SDR)).isFalse() - assertThat(codecVideoCapabilities.isQualitySupported(FHD, SDR)).isTrue() - assertThat(codecVideoCapabilities.isQualitySupported(SD, SDR)).isTrue() - } - - @Test - fun noSupportedQuality_shouldCreateDefaultEncoderProfilesProvider() { - assumeTrue("High speed mode doesn't adopt DefaultEncoderProfilesProvider", !isHighSpeed) - - cameraInfo.encoderProfilesProvider = EncoderProfilesProvider.EMPTY - val codecVideoCapabilities = - RecorderVideoCapabilities( - VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, - cameraInfo, - videoCaptureType, - ) { - FakeVideoEncoderInfo() - } - - assertThat(codecVideoCapabilities.isQualitySupported(HIGHEST, SDR)).isTrue() - assertThat(codecVideoCapabilities.isQualitySupported(LOWEST, SDR)).isTrue() - // The target quality is [FHD, HD, SD] - assertThat(codecVideoCapabilities.isQualitySupported(UHD, SDR)).isFalse() - assertThat(codecVideoCapabilities.isQualitySupported(FHD, SDR)).isTrue() - assertThat(codecVideoCapabilities.isQualitySupported(HD, SDR)).isTrue() - assertThat(codecVideoCapabilities.isQualitySupported(SD, SDR)).isTrue() - } } From 77bb05659c62ab173927d7a6c37c5faa59a672f4 Mon Sep 17 00:00:00 2001 From: leo huang Date: Thu, 25 Dec 2025 10:16:18 +0800 Subject: [PATCH 2/6] Refactor: Extract VideoCapabilities creation into a separate class Refactors the creation logic for VideoCapabilities from the Recorder class into a new, dedicated RecorderVideoCapabilitiesFactory. This refactoring improves code modularity by decoupling the construction and caching of VideoCapabilities from the Recorder implementation. Bug: 471353211 Test: ./gradlew camera:camera-video:testRelease Change-Id: I8eeacd034592ccee9c5f704be07b8d5303e6d85b --- .../androidx/camera/video/RecorderTest.kt | 138 ---------- .../video/EncoderProfilesProviderResolver.kt | 1 - .../java/androidx/camera/video/Recorder.java | 87 +------ .../video/RecorderVideoCapabilitiesFactory.kt | 106 ++++++++ .../RecorderVideoCapabilitiesFactoryTest.kt | 235 ++++++++++++++++++ 5 files changed, 343 insertions(+), 224 deletions(-) create mode 100644 camera/camera-video/src/main/java/androidx/camera/video/RecorderVideoCapabilitiesFactory.kt create mode 100644 camera/camera-video/src/test/java/androidx/camera/video/RecorderVideoCapabilitiesFactoryTest.kt diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt index 6fb5386366950..abbb7bf4258d9 100644 --- a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt +++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt @@ -44,7 +44,6 @@ import androidx.camera.core.CameraXConfig import androidx.camera.core.DynamicRange import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest -import androidx.camera.core.impl.AdapterCameraInfo import androidx.camera.core.impl.CameraInfoInternal import androidx.camera.core.impl.ImageFormatConstants import androidx.camera.core.impl.Observable.Observer @@ -52,7 +51,6 @@ import androidx.camera.core.impl.utils.executor.CameraXExecutors.directExecutor import androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.awaitInstance -import androidx.camera.testing.fakes.FakeCameraInfoInternal import androidx.camera.testing.impl.AudioUtil import androidx.camera.testing.impl.CameraPipeConfigTestRule import androidx.camera.testing.impl.CameraUtil @@ -62,7 +60,6 @@ import androidx.camera.testing.impl.IgnoreVideoRecordingProblematicDeviceRule import androidx.camera.testing.impl.LabTestRule import androidx.camera.testing.impl.SurfaceTextureProvider import androidx.camera.testing.impl.asFlow -import androidx.camera.testing.impl.fakes.FakeCameraConfig import androidx.camera.testing.impl.fakes.FakeLifecycleOwner import androidx.camera.testing.impl.fakes.FakeSessionProcessor import androidx.camera.testing.impl.fakes.NoOpMuxer @@ -1262,141 +1259,6 @@ class RecorderTest(private val implName: String, private val cameraConfig: Camer assertThat(capabilities.isStabilizationSupported).isTrue() } - @Test - fun getVideoCapabilities_returnsCachedInstanceForSameCameraInfo() { - // Arrange - val cameraInfo = cameraProvider.getCameraInfo(cameraSelector) - - // Act - val capabilities1 = Recorder.getVideoCapabilities(cameraInfo) - val capabilities2 = Recorder.getVideoCapabilities(cameraInfo) - - // Assert - assertThat(capabilities1).isSameInstanceAs(capabilities2) - } - - @Test - fun getVideoCapabilities_returnsDifferentInstanceForDifferentCameraInfos() { - // Arrange - val cameraInfos = cameraProvider.availableCameraInfos - assumeTrue("The device must have at least 2 cameras.", cameraInfos.size >= 2) - val cameraInfo1 = cameraInfos[0] - val cameraInfo2 = cameraInfos[1] - - // Act - val capabilities1 = Recorder.getVideoCapabilities(cameraInfo1) - val capabilities2 = Recorder.getVideoCapabilities(cameraInfo2) - - // Assert - assertThat(capabilities1).isNotSameInstanceAs(capabilities2) - } - - @Test - fun getVideoCapabilities_returnsCachedInstanceForDifferentCameraInfoWithSameIdAndConfig() { - // Arrange - val cameraConfig = FakeCameraConfig() - val cameraInfo1 = AdapterCameraInfo(FakeCameraInfoInternal("0"), cameraConfig) - val cameraInfo2 = AdapterCameraInfo(FakeCameraInfoInternal("0"), cameraConfig) - - // Act - val capabilities1 = Recorder.getVideoCapabilities(cameraInfo1) - val capabilities2 = Recorder.getVideoCapabilities(cameraInfo2) - - // Assert - assertThat(capabilities1).isSameInstanceAs(capabilities2) - } - - @Test - fun getVideoCapabilities_returnsCachedInstanceForCameraInfoOfNewBinding() = runBlocking { - // Arrange & act - val capabilities1 = Recorder.getVideoCapabilities(camera.cameraInfo) - val capabilities2 = - Recorder.getVideoCapabilities( - withContext(Dispatchers.Main) { - cameraProvider - .bindToLifecycle( - FakeLifecycleOwner().also { it.startAndResume() }, - cameraSelector, - Preview.Builder().build(), - ) - .cameraInfo - } - ) - - // Assert - assertThat(capabilities1).isSameInstanceAs(capabilities2) - } - - @Test - fun getVideoCapabilities_doesNotCacheForExternalCamera() { - // Arrange - val cameraInfo = - AdapterCameraInfo( - FakeCameraInfoInternal("0", CameraSelector.LENS_FACING_EXTERNAL), - FakeCameraConfig(), - ) - - // Act - val capabilities1 = Recorder.getVideoCapabilities(cameraInfo) - val capabilities2 = Recorder.getVideoCapabilities(cameraInfo) - - // Assert - assertThat(capabilities1).isNotSameInstanceAs(capabilities2) - } - - @Test - fun getVideoCapabilities_doesNotCacheForUnknownLensFacingCamera() { - // Arrange - val cameraInfo = - AdapterCameraInfo( - FakeCameraInfoInternal("0", CameraSelector.LENS_FACING_UNKNOWN), - FakeCameraConfig(), - ) - - // Act - val capabilities1 = Recorder.getVideoCapabilities(cameraInfo) - val capabilities2 = Recorder.getVideoCapabilities(cameraInfo) - - // Assert - assertThat(capabilities1).isNotSameInstanceAs(capabilities2) - } - - @Test - fun getVideoCapabilities_returnsDifferentInstancesForDifferentCameraConfigs() { - // Arrange - val cameraInfo1 = AdapterCameraInfo(FakeCameraInfoInternal("0"), FakeCameraConfig()) - val cameraInfo2 = AdapterCameraInfo(FakeCameraInfoInternal("0"), FakeCameraConfig()) - - // Act - val capabilities1 = Recorder.getVideoCapabilities(cameraInfo1) - val capabilities2 = Recorder.getVideoCapabilities(cameraInfo2) - - // Assert - assertThat(capabilities1).isNotSameInstanceAs(capabilities2) - } - - @Test - fun getVideoCapabilities_returnsDifferentInstancesForExtensionCameraSelector() { - // Arrange - val cameraInfo1 = cameraProvider.getCameraInfo(cameraSelector) - - val sessionProcessor = FakeSessionProcessor() - val cameraSelector2 = - ExtensionsUtil.getCameraSelectorWithSessionProcessor( - cameraProvider, - cameraSelector, - sessionProcessor, - ) - val cameraInfo2 = cameraProvider.getCameraInfo(cameraSelector2) - - // Act - val capabilities1 = Recorder.getVideoCapabilities(cameraInfo1) - val capabilities2 = Recorder.getVideoCapabilities(cameraInfo2) - - // Assert - assertThat(capabilities1).isNotSameInstanceAs(capabilities2) - } - @Test fun getVideoCapabilities_supportStandardDynamicRange() { assumeFalse(isDeviceWithCamcorderProfileResolutionMismatch()) diff --git a/camera/camera-video/src/main/java/androidx/camera/video/EncoderProfilesProviderResolver.kt b/camera/camera-video/src/main/java/androidx/camera/video/EncoderProfilesProviderResolver.kt index 91f715465ac66..f3038635fc302 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/EncoderProfilesProviderResolver.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/EncoderProfilesProviderResolver.kt @@ -37,7 +37,6 @@ internal object EncoderProfilesProviderResolver { private const val TAG = "EncoderProfilesResolver" /** Resolves the [EncoderProfilesProvider] based on the camera info and source. */ - @JvmStatic fun resolve( cameraInfo: CameraInfoInternal, @Recorder.VideoCapabilitiesSource videoCapabilitiesSource: Int, 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 9c2e1556eed63..9383eaa29579a 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 @@ -55,7 +55,6 @@ import android.os.Build; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; -import android.util.LruCache; import android.util.Range; import android.util.Rational; import android.util.Size; @@ -70,14 +69,9 @@ import androidx.annotation.VisibleForTesting; import androidx.camera.core.AspectRatio; import androidx.camera.core.CameraInfo; -import androidx.camera.core.CameraSelector; import androidx.camera.core.DynamicRange; import androidx.camera.core.Logger; import androidx.camera.core.SurfaceRequest; -import androidx.camera.core.impl.AdapterCameraInfo; -import androidx.camera.core.impl.CameraConfig; -import androidx.camera.core.impl.CameraInfoInternal; -import androidx.camera.core.impl.EncoderProfilesProvider; import androidx.camera.core.impl.MutableStateObservable; import androidx.camera.core.impl.Observable; import androidx.camera.core.impl.StateObservable; @@ -110,7 +104,6 @@ import androidx.camera.video.internal.encoder.OutputConfig; import androidx.camera.video.internal.encoder.VideoEncoderConfig; import androidx.camera.video.internal.encoder.VideoEncoderInfo; -import androidx.camera.video.internal.encoder.VideoEncoderInfoImpl; import androidx.camera.video.internal.muxer.MediaMuxerImpl; import androidx.camera.video.internal.muxer.Muxer; import androidx.camera.video.internal.muxer.MuxerException; @@ -382,11 +375,6 @@ enum AudioState { "Insufficient storage space. The available storage (%d bytes) is below the required " + "threshold of %d bytes."; - @GuardedBy("sVideoCapabilitiesCache") - // A size of 16 is likely more than enough for all camera/config combinations on a device. - private static final LruCache - sVideoCapabilitiesCache = new LruCache<>(16); - @VisibleForTesting static int sRetrySetupVideoMaxCount = RETRY_SETUP_VIDEO_MAX_COUNT; @VisibleForTesting @@ -3159,79 +3147,8 @@ private static int supportedMuxerFormatOrDefaultFrom( @VideoRecordingType int videoRecordingType, @NonNull CameraInfo cameraInfo, @VideoCapabilitiesSource int videoCapabilitiesSource) { - if (shouldSkipCapabilitiesCache(cameraInfo)) { - return createVideoCapabilities(videoRecordingType, cameraInfo, videoCapabilitiesSource); - } - - AdapterCameraInfo adapterCameraInfo = (AdapterCameraInfo) cameraInfo; - - String cameraId = adapterCameraInfo.getCameraId(); - CameraConfig cameraConfig = adapterCameraInfo.getCameraConfig(); - VideoCapabilitiesCacheKey key = VideoCapabilitiesCacheKey.create(cameraId, cameraConfig, - videoRecordingType, videoCapabilitiesSource); - - synchronized (sVideoCapabilitiesCache) { - VideoCapabilities capabilities = sVideoCapabilitiesCache.get(key); - - if (capabilities == null) { - capabilities = createVideoCapabilities(videoRecordingType, cameraInfo, - videoCapabilitiesSource); - sVideoCapabilitiesCache.put(key, capabilities); - } - return capabilities; - } - } - - private static @NonNull VideoCapabilities createVideoCapabilities( - @VideoRecordingType int videoRecordingType, - @NonNull CameraInfo cameraInfo, - @VideoCapabilitiesSource int videoCapabilitiesSource) { - @Quality.QualitySource - int qualitySource = videoRecordingType == Recorder.VIDEO_RECORDING_TYPE_HIGH_SPEED - ? Quality.QUALITY_SOURCE_HIGH_SPEED : Quality.QUALITY_SOURCE_REGULAR; - - EncoderProfilesProvider resolvedProvider = EncoderProfilesProviderResolver.resolve( - (CameraInfoInternal) cameraInfo, videoCapabilitiesSource, qualitySource, - VideoEncoderInfoImpl.FINDER); - - return new RecorderVideoCapabilities(resolvedProvider, qualitySource, - (CameraInfoInternal) cameraInfo); - } - - /** - * Checks whether the video capabilities for a given camera should be cached. - * - *

Caching is skipped for external cameras or cameras with an unknown lens facing, as their - * properties may not be stable across device reboots or during camera hot-plugging. - */ - private static boolean shouldSkipCapabilitiesCache(@NonNull CameraInfo cameraInfo) { - if (cameraInfo instanceof AdapterCameraInfo) { - AdapterCameraInfo adapterCameraInfo = (AdapterCameraInfo) cameraInfo; - return adapterCameraInfo.isExternalCamera() - || adapterCameraInfo.getLensFacing() == CameraSelector.LENS_FACING_UNKNOWN; - } - // If we can't determine the camera properties (e.g., not an AdapterCameraInfo), - // it's safer to skip caching. - return true; - } - - @SuppressWarnings("unused") // Use as a key class, which methods are not called directly. - @AutoValue - abstract static class VideoCapabilitiesCacheKey { - static VideoCapabilitiesCacheKey create(@NonNull String cameraId, - @NonNull CameraConfig cameraConfig, @VideoRecordingType int videoRecordingType, - @VideoCapabilitiesSource int videoCapabilitiesSource) { - return new AutoValue_Recorder_VideoCapabilitiesCacheKey(cameraId, cameraConfig, - videoRecordingType, videoCapabilitiesSource); - } - - abstract @NonNull String getCameraId(); - - abstract @NonNull CameraConfig getCameraConfig(); - - abstract @VideoRecordingType int getVideoRecordingType(); - - abstract @VideoCapabilitiesSource int getVideoCapabilitiesSource(); + return RecorderVideoCapabilitiesFactory.getCapabilities(cameraInfo, videoRecordingType, + videoCapabilitiesSource); } @AutoValue diff --git a/camera/camera-video/src/main/java/androidx/camera/video/RecorderVideoCapabilitiesFactory.kt b/camera/camera-video/src/main/java/androidx/camera/video/RecorderVideoCapabilitiesFactory.kt new file mode 100644 index 0000000000000..1bc41297e5fd8 --- /dev/null +++ b/camera/camera-video/src/main/java/androidx/camera/video/RecorderVideoCapabilitiesFactory.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2025 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.LruCache +import androidx.annotation.GuardedBy +import androidx.camera.core.CameraInfo +import androidx.camera.core.CameraSelector +import androidx.camera.core.impl.AdapterCameraInfo +import androidx.camera.core.impl.CameraInfoInternal +import androidx.camera.video.internal.encoder.VideoEncoderInfoImpl + +/** Factory for creating and caching [VideoCapabilities] instances. */ +internal object RecorderVideoCapabilitiesFactory { + @GuardedBy("capabilitiesCache") + private val capabilitiesCache = LruCache(16) + + /** Gets or creates [VideoCapabilities] for the given camera and configuration. */ + @JvmStatic + fun getCapabilities( + cameraInfo: CameraInfo, + @Recorder.VideoRecordingType videoRecordingType: Int, + @Recorder.VideoCapabilitiesSource videoCapabilitiesSource: Int, + ): VideoCapabilities { + if (shouldSkipCache(cameraInfo)) { + return createCapabilities(cameraInfo, videoRecordingType, videoCapabilitiesSource) + } + + val adapterInfo = cameraInfo as AdapterCameraInfo + val key = + CacheKey( + adapterInfo.cameraId, + adapterInfo.cameraConfig, + videoRecordingType, + videoCapabilitiesSource, + ) + + synchronized(capabilitiesCache) { + return capabilitiesCache.get(key) + ?: createCapabilities(cameraInfo, videoRecordingType, videoCapabilitiesSource) + .also { capabilitiesCache.put(key, it) } + } + } + + private fun createCapabilities( + cameraInfo: CameraInfo, + videoRecordingType: Int, + videoCapabilitiesSource: Int, + ): VideoCapabilities { + val cameraInfoInternal = cameraInfo as CameraInfoInternal + val qualitySource = + if (videoRecordingType == Recorder.VIDEO_RECORDING_TYPE_HIGH_SPEED) { + Quality.QUALITY_SOURCE_HIGH_SPEED + } else { + Quality.QUALITY_SOURCE_REGULAR + } + + val resolvedProvider = + EncoderProfilesProviderResolver.resolve( + cameraInfo = cameraInfoInternal, + videoCapabilitiesSource = videoCapabilitiesSource, + qualitySource = qualitySource, + videoEncoderInfoFinder = VideoEncoderInfoImpl.FINDER, + ) + + return RecorderVideoCapabilities(resolvedProvider, videoRecordingType, cameraInfoInternal) + } + + /** + * Checks whether the video capabilities for a given camera should be cached. + * + * Caching is skipped for external cameras or cameras with an unknown lens facing, as their + * properties may not be stable across device reboots or during camera hot-plugging. + */ + private fun shouldSkipCache(cameraInfo: CameraInfo): Boolean { + return if (cameraInfo is AdapterCameraInfo) { + cameraInfo.isExternalCamera || + cameraInfo.lensFacing == CameraSelector.LENS_FACING_UNKNOWN + } else { + // If we can't determine the camera properties (e.g., not an AdapterCameraInfo), + // it's safer to skip caching. + true + } + } + + private data class CacheKey( + val cameraId: String, + val cameraConfig: Any, + val videoRecordingType: Int, + val videoCapabilitiesSource: Int, + ) +} diff --git a/camera/camera-video/src/test/java/androidx/camera/video/RecorderVideoCapabilitiesFactoryTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/RecorderVideoCapabilitiesFactoryTest.kt new file mode 100644 index 0000000000000..087657403211b --- /dev/null +++ b/camera/camera-video/src/test/java/androidx/camera/video/RecorderVideoCapabilitiesFactoryTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2025 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 androidx.camera.core.CameraSelector +import androidx.camera.core.impl.AdapterCameraInfo +import androidx.camera.testing.fakes.FakeCameraInfoInternal +import androidx.camera.testing.impl.fakes.FakeCameraConfig +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 + +@RunWith(RobolectricTestRunner::class) +@DoNotInstrument +@Config(sdk = [Config.ALL_SDKS]) +class RecorderVideoCapabilitiesFactoryTest { + + @Test + fun getCapabilities_returnsCachedInstanceForSameCamera() { + val cameraInfo = + AdapterCameraInfo( + FakeCameraInfoInternal("0", CameraSelector.LENS_FACING_BACK), + FakeCameraConfig(), + ) + + val capabilities1 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + val capabilities2 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + + assertThat(capabilities1).isSameInstanceAs(capabilities2) + } + + @Test + fun getCapabilities_returnsNewInstanceForDifferentCamera() { + val cameraInfo1 = + AdapterCameraInfo( + FakeCameraInfoInternal("0", CameraSelector.LENS_FACING_BACK), + FakeCameraConfig(), + ) + val cameraInfo2 = + AdapterCameraInfo( + FakeCameraInfoInternal("1", CameraSelector.LENS_FACING_BACK), + FakeCameraConfig(), + ) + + val capabilities1 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo1, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + val capabilities2 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo2, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + + assertThat(capabilities1).isNotSameInstanceAs(capabilities2) + } + + @Test + fun getCapabilities_returnsCachedInstanceForDifferentCameraInfoWithSameIdAndConfig() { + val cameraConfig = FakeCameraConfig() + val cameraInfo1 = + AdapterCameraInfo( + FakeCameraInfoInternal("0", CameraSelector.LENS_FACING_BACK), + cameraConfig, + ) + val cameraInfo2 = + AdapterCameraInfo( + FakeCameraInfoInternal("0", CameraSelector.LENS_FACING_BACK), + cameraConfig, + ) + + val capabilities1 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo1, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + val capabilities2 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo2, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + + assertThat(capabilities1).isSameInstanceAs(capabilities2) + } + + @Test + fun getCapabilities_returnsNewInstanceForDifferentRecordingType() { + val cameraInfo = + AdapterCameraInfo( + FakeCameraInfoInternal("0", CameraSelector.LENS_FACING_BACK), + FakeCameraConfig(), + ) + + val capabilities1 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + val capabilities2 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_HIGH_SPEED, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + + assertThat(capabilities1).isNotSameInstanceAs(capabilities2) + } + + @Test + fun getCapabilities_returnsNewInstanceForDifferentSource() { + val cameraInfo = + AdapterCameraInfo( + FakeCameraInfoInternal("0", CameraSelector.LENS_FACING_BACK), + FakeCameraConfig(), + ) + + val capabilities1 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + val capabilities2 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES, + ) + + assertThat(capabilities1).isNotSameInstanceAs(capabilities2) + } + + @Test + fun getCapabilities_doesNotCacheForExternalCamera() { + val cameraInfo = + AdapterCameraInfo( + FakeCameraInfoInternal("external", CameraSelector.LENS_FACING_EXTERNAL), + FakeCameraConfig(), + ) + + val capabilities1 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + val capabilities2 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + + assertThat(capabilities1).isNotSameInstanceAs(capabilities2) + } + + @Test + fun getCapabilities_doesNotCacheForUnknownLensFacingCamera() { + val cameraInfo = + AdapterCameraInfo( + FakeCameraInfoInternal("unknown", CameraSelector.LENS_FACING_UNKNOWN), + FakeCameraConfig(), + ) + + val capabilities1 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + val capabilities2 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + + assertThat(capabilities1).isNotSameInstanceAs(capabilities2) + } + + @Test + fun getCapabilities_returnsDifferentInstancesForDifferentCameraConfigs() { + val cameraInfo1 = AdapterCameraInfo(FakeCameraInfoInternal("0"), FakeCameraConfig()) + val cameraInfo2 = AdapterCameraInfo(FakeCameraInfoInternal("0"), FakeCameraConfig()) + + val capabilities1 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo1, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + val capabilities2 = + RecorderVideoCapabilitiesFactory.getCapabilities( + cameraInfo2, + Recorder.VIDEO_RECORDING_TYPE_REGULAR, + Recorder.VIDEO_CAPABILITIES_SOURCE_CAMCORDER_PROFILE, + ) + + // Assert + assertThat(capabilities1).isNotSameInstanceAs(capabilities2) + } +} From 78921cf74d7dfd6ab24c72fe7ac212244466d961 Mon Sep 17 00:00:00 2001 From: Piotr Kaliciak Date: Thu, 8 Jan 2026 14:11:57 +0000 Subject: [PATCH 3/6] [Showcase] Stop requesting notifications not available on device's android version Test: run showcase on AAP and AAOS with Android API versions both <32 and >=32 Change-Id: I95014286d76808cea242a8ef359c0da173cd96e1 --- .../RequestPermissionScreen.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionScreen.java index 78485919ba4d5..36f4031ac2881 100644 --- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionScreen.java +++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/userinteractions/RequestPermissionScreen.java @@ -190,6 +190,13 @@ private List findMissingPermissions() throws PackageManager.NameNotFound continue; } + if (!isPermissionSupportedBySdk(permission)) { + Log.d( + TAG, String.format(Locale.US, + "Permission ignored (not supported by SDK): %s", permission)); + continue; + } + Log.d(TAG, String.format(Locale.US, "Found missing permission: %s", permission)); missingPermissions.add(permission); } @@ -213,6 +220,22 @@ private boolean isPermissionGranted(String permission) { return true; } + /** + * Checks if a permission is supported by the current Android SDK version by querying the + * PackageManager. + */ + private boolean isPermissionSupportedBySdk(String permission) { + try { + getCarContext().getPackageManager().getPermissionInfo(permission, 0); + } catch (PackageManager.NameNotFoundException e) { + // Permission is not supported by the SDK + return false; + } + + // Permission is supported by the SDK + return true; + } + private boolean needsLocationPermission() { LocationManager locationManager = (LocationManager) getCarContext().getSystemService(Context.LOCATION_SERVICE); From 53f657dd08d62ba09eecfc8dd064c8ba6588ab41 Mon Sep 17 00:00:00 2001 From: faizannaikwade Date: Thu, 8 Jan 2026 10:29:21 +0000 Subject: [PATCH 4/6] [ObjectEraser] Replace PdfAnnotation with KeyedPdfAnnotation in AnnotationSelectionHandler. Bug: 470857248 Test: ./gradlew :pdf:integration-tests:connectedAndroidTest Change-Id: I242be32113961b1056265472bd39ddba6d19eed1 --- .../AnnotationSelectionTouchHandler.kt | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandler.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandler.kt index 887b8e06c797e..8489d52067ce4 100644 --- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandler.kt +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandler.kt @@ -55,10 +55,7 @@ internal class AnnotationSelectionTouchHandler() { val selectedAnnotation = findAnnotationAtPoint(annotationsView, pageInfo, event) if (selectedAnnotation != null) { - // TODO(b/470857248): Replace with actual keyedPdfAnnotation from - // annotationsView. - val keyedPdfAnnotation = KeyedPdfAnnotation(KEY, selectedAnnotation) - onAnnotationSelectedListener?.onAnnotationSelected(keyedPdfAnnotation) + onAnnotationSelectedListener?.onAnnotationSelected(selectedAnnotation) return true } false @@ -72,7 +69,7 @@ internal class AnnotationSelectionTouchHandler() { annotationsView: AnnotationsView, pageInfo: PageInfoProvider.PageInfo, event: MotionEvent, - ): PdfAnnotation? { + ): KeyedPdfAnnotation? { // Use the system's touch slop for a density-aware tolerance. val touchSlop: Int = ViewConfiguration.get(annotationsView.context).scaledTouchSlop @@ -89,14 +86,12 @@ internal class AnnotationSelectionTouchHandler() { pageInfo.viewToPageTransform.mapRect(touchRectPdf, touchRectView) val touchRegion = touchRectPdf.toRegion() - val annotations = - annotationsView.annotations.get(pageInfo.pageNum)?.keyedAnnotations?.map { - it.annotation - } ?: return null + val keyedPdfAnnotations = + annotationsView.annotations.get(pageInfo.pageNum)?.keyedAnnotations ?: return null // Iterate in reverse Z-order to find the top-most annotation. - return annotations.asReversed().firstOrNull { annotation -> - isAnnotationHit(annotation, touchRegion, touchRectPdf) + return keyedPdfAnnotations.asReversed().firstOrNull { keyedPdfAnnotation -> + isAnnotationHit(keyedPdfAnnotation.annotation, touchRegion, touchRectPdf) } } @@ -147,8 +142,4 @@ internal class AnnotationSelectionTouchHandler() { ceil(bottom).toInt(), ) } - - private companion object { - const val KEY = "key" - } } From cea0ed3122890b4f615bfa29b46b3890f669938b Mon Sep 17 00:00:00 2001 From: Bhavya Jain Date: Mon, 12 Jan 2026 07:11:49 +0000 Subject: [PATCH 5/6] Enabling erasing annotations in eraser mode. Bug: 473955799 Test: Manual Change-Id: Ieb7ac768e03f6b91de7d52a66ea3e8f7bb4ef7c8 --- .../main/kotlin/androidx/pdf/ink/EditablePdfViewerFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/EditablePdfViewerFragment.kt b/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/EditablePdfViewerFragment.kt index a63458f1601ab..5992c7f07bf0c 100644 --- a/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/EditablePdfViewerFragment.kt +++ b/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/EditablePdfViewerFragment.kt @@ -240,7 +240,7 @@ public open class EditablePdfViewerFragment : PdfViewerFragment { object : OnAnnotationSelectedListener { override fun onAnnotationSelected(keyedPdfAnnotation: KeyedPdfAnnotation) { if (documentViewModel.drawingMode.value == AnnotationDrawingMode.EraserMode) { - // TODO: (b/473955799) Call removeAnnotation on EditableDocumentViewModel. + documentViewModel.removeAnnotation(keyedPdfAnnotation.key) } } } From 3fb79902783b6e9e6112201c77c77cbbca893372 Mon Sep 17 00:00:00 2001 From: Palak Sharma Date: Fri, 9 Jan 2026 14:12:24 +0530 Subject: [PATCH 6/6] Fix: Enable stylus and mouse input for ink annotations Previously, stylus and mouse input did not work for creating ink annotations. This was because a simplified "fake" MotionEvent was being created and dispatched, which omitted critical details such as tool type, pressure, orientation, and historical data. This change corrects the behavior by forwarding the original, unaltered MotionEvent to the InProgressStrokesView. This ensures that all input data is preserved, allowing stylus and mouse events to be processed correctly for a natural drawing experience. Bug: 474308849 Test: build and run on test device Change-Id: I81d7081bc45883b6292acbab82b668cc300ac41f --- .../pdf/ink/PdfContentLayoutTouchListener.kt | 39 ++++++------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/PdfContentLayoutTouchListener.kt b/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/PdfContentLayoutTouchListener.kt index 4ade46acbdd56..ce98d9ec6bdcc 100644 --- a/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/PdfContentLayoutTouchListener.kt +++ b/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/PdfContentLayoutTouchListener.kt @@ -124,36 +124,19 @@ internal class PdfContentLayoutTouchListener( } val primaryPointerIndex = event.findPointerIndex(primaryPointerId) - if (isSingleTouchCommitted && currentDispatcher == annotationsTouchEventDispatcher) { - if (primaryPointerIndex != -1) { - val x = event.getX(primaryPointerIndex) - val y = event.getY(primaryPointerIndex) - val singlePointerMove = - MotionEvent.obtain( - event.downTime, - event.eventTime, - event.action, - x, - y, - event.metaState, - ) - currentDispatcher?.dispatchTouchEvent(singlePointerMove) - singlePointerMove.recycle() - } - } else { - if ( - currentDispatcher == annotationsTouchEventDispatcher && - !isSingleTouchCommitted && - primaryPointerIndex != -1 - ) { - val dx = event.getX(primaryPointerIndex) - downX - val dy = event.getY(primaryPointerIndex) - downY - if (dx * dx + dy * dy > touchSlop * touchSlop) { - isSingleTouchCommitted = true - } + if (primaryPointerIndex == MotionEvent.INVALID_POINTER_ID) { + return + } + + if (currentDispatcher == annotationsTouchEventDispatcher && !isSingleTouchCommitted) { + val dx = event.getX(primaryPointerIndex) - downX + val dy = event.getY(primaryPointerIndex) - downY + if (dx * dx + dy * dy > touchSlop * touchSlop) { + isSingleTouchCommitted = true } - currentDispatcher?.dispatchTouchEvent(event) } + + currentDispatcher?.dispatchTouchEvent(event) } private fun handlePointerUp(event: MotionEvent) {