From ff7d040ed15d77117946dfd46e5bc23425b61046 Mon Sep 17 00:00:00 2001 From: WenHung_Teng Date: Thu, 8 Jan 2026 13:22:38 +0800 Subject: [PATCH 1/7] Use Providers for UseCaseCamera dependencies to defer initialization This change refactors UseCaseCamera, UseCaseCameraRequestControl, and CapturePipeline to utilize javax.inject.Provider for their dependencies instead of direct object injection. Key changes include: * Updated constructors for `UseCaseCamera`, `UseCaseCameraRequestControl`, and `CapturePipeline` to accept `Provider` or lambda providers. * Converted internal properties to use `by lazy` initialization, ensuring components are only instantiated when accessed. * Cleaned up `UseCaseCameraConfig` by removing the `useCases` list and unnecessary intermediate providers, reducing configuration overhead. This refactoring helps break circular dependencies between the CameraControl, CameraState, and CapturePipeline, and ensures that heavy components are initialized lazily rather than at the moment of graph configuration. Bug: 448593362 Test: UseCaseCameraRequestControlTest, UseCaseManagerTest, StillCaptureRequestControlTest Change-Id: Ibef6101fa8ac83fcc160f16b0be099f0fa6110b0 --- .../integration/testing/TestUseCaseCamera.kt | 25 ++++------- .../CapturePipelineTorchCorrection.kt | 6 ++- .../integration/config/UseCaseCameraConfig.kt | 45 ++++--------------- .../pipe/integration/impl/CapturePipeline.kt | 7 ++- .../pipe/integration/impl/UseCaseCamera.kt | 16 ++++--- .../impl/UseCaseCameraRequestControl.kt | 13 ++++-- .../pipe/integration/impl/UseCaseManager.kt | 3 -- .../integration/impl/CapturePipelineTest.kt | 4 +- .../impl/StillCaptureRequestControlTest.kt | 11 ++--- .../impl/UseCaseCameraRequestControlTest.kt | 22 +++++---- .../integration/impl/UseCaseManagerTest.kt | 7 --- .../integration/testing/FakeUseCaseCamera.kt | 1 - .../camera2/testing/TestUseCaseCamera.kt | 25 ++++------- .../CapturePipelineTorchCorrection.kt | 6 ++- .../camera2/config/UseCaseCameraConfig.kt | 45 ++++--------------- .../camera/camera2/impl/CapturePipeline.kt | 7 ++- .../camera/camera2/impl/UseCaseCamera.kt | 16 ++++--- .../impl/UseCaseCameraRequestControl.kt | 13 ++++-- .../camera/camera2/impl/UseCaseManager.kt | 3 -- .../camera2/impl/CapturePipelineTest.kt | 4 +- .../impl/StillCaptureRequestControlTest.kt | 11 ++--- .../impl/UseCaseCameraRequestControlTest.kt | 22 +++++---- .../camera/camera2/impl/UseCaseManagerTest.kt | 7 --- .../camera2/testing/FakeUseCaseCamera.kt | 1 - 24 files changed, 132 insertions(+), 188 deletions(-) diff --git a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/testing/TestUseCaseCamera.kt b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/testing/TestUseCaseCamera.kt index 1fe85c3f14201..b558fe878c4b1 100644 --- a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/testing/TestUseCaseCamera.kt +++ b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/testing/TestUseCaseCamera.kt @@ -103,25 +103,16 @@ class TestUseCaseCamera( ) val cameraStateAdapter = CameraStateAdapter() - val graphStateToCameraStateAdapter = GraphStateToCameraStateAdapter(cameraStateAdapter) val useCaseCameraConfig = UseCaseCameraConfig.create( - useCases = useCases, cameraGraphConfigProvider = configProvider, cameraGraphFactory = { config -> cameraPipe.createCameraGraph(config) }, - graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, + graphStateToCameraStateAdapter = GraphStateToCameraStateAdapter(cameraStateAdapter), sessionConfigAdapter = sessionConfigAdapter, isExtensions = false, sessionProcessor = null, ) - useCaseGraphContext = - UseCaseGraphContext( - cameraGraphProvider = { useCaseCameraConfig.provideCameraGraph() }, - cameraStateAdapter = cameraStateAdapter, - streamConfigMapProvider = { useCaseCameraConfig.provideStreamConfigMap() }, - graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, - ) - + useCaseGraphContext = useCaseCameraConfig.provideUseCaseGraphContext(cameraStateAdapter) sessionConfigAdapter.getValidSessionConfigOrNull()?.let { sessionConfig -> CameraInteropStateCallbackRepository().updateCallbacks(sessionConfig) } @@ -129,7 +120,7 @@ class TestUseCaseCamera( override val requestControl: UseCaseCameraRequestControl = UseCaseCameraRequestControlImpl( - capturePipeline = + capturePipelineProvider = { object : CapturePipeline { override var template: Int = CameraDevice.TEMPLATE_PREVIEW @@ -149,14 +140,16 @@ class TestUseCaseCamera( flashMode: Int, flashType: Int, ): CameraCapturePipeline = FakeCameraCapturePipeline() - }, - state = + } + }, + useCaseCameraStateProvider = { UseCaseCameraState( useCaseGraphContext, templateParamsOverride = NoOpTemplateParamsOverride, - ), + ) + }, useCaseGraphContext = useCaseGraphContext, - useCaseSurfaceManager = useCaseSurfaceManager, + useCaseSurfaceManagerProvider = { useCaseSurfaceManager }, threads = threads, ) .apply { diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/CapturePipelineTorchCorrection.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/CapturePipelineTorchCorrection.kt index 3e57c6702b68e..0f53b1d3ece79 100644 --- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/CapturePipelineTorchCorrection.kt +++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/CapturePipelineTorchCorrection.kt @@ -36,6 +36,7 @@ import androidx.camera.core.imagecapture.CameraCapturePipeline import androidx.camera.core.impl.CaptureConfig import androidx.camera.core.impl.Config import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.Deferred import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @@ -52,11 +53,12 @@ public class CapturePipelineTorchCorrection @Inject constructor( cameraProperties: CameraProperties, - private val capturePipelineImpl: CapturePipelineImpl, + private val capturePipelineImplProvider: Provider, private val threads: UseCaseThreads, private val torchControl: TorchControl, ) : CapturePipeline { - private val isLegacyDevice = cameraProperties.metadata.isHardwareLevelLegacy + private val isLegacyDevice by lazy { cameraProperties.metadata.isHardwareLevelLegacy } + private val capturePipelineImpl by lazy { capturePipelineImplProvider.get() } override suspend fun submitStillCaptures( configs: List, diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/UseCaseCameraConfig.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/UseCaseCameraConfig.kt index 62ad685f579d2..5207b0c7e24e5 100644 --- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/UseCaseCameraConfig.kt +++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/UseCaseCameraConfig.kt @@ -55,14 +55,14 @@ public abstract class UseCaseCameraModule { @UseCaseCameraScope @Provides public fun provideCapturePipeline( - capturePipelineImpl: CapturePipelineImpl, - capturePipelineTorchCorrection: CapturePipelineTorchCorrection, + capturePipelineImplProvider: Provider, + capturePipelineTorchCorrectionProvider: Provider, ): CapturePipeline { if (CapturePipelineTorchCorrection.isEnabled) { - return capturePipelineTorchCorrection + return capturePipelineTorchCorrectionProvider.get() } - return capturePipelineImpl + return capturePipelineImplProvider.get() } } } @@ -70,35 +70,15 @@ public abstract class UseCaseCameraModule { /** Dagger module for binding the [UseCase]'s to the [UseCaseCamera]. */ @Module public data class UseCaseCameraConfig( - private val useCases: List, private val cameraGraphFactory: (CameraGraph.Config) -> CameraGraph, private val graphStateToCameraStateAdapter: GraphStateToCameraStateAdapter, private val sessionConfigAdapter: SessionConfigAdapter, - private val isExtensions: Boolean, private val sessionProcessor: SessionProcessor?, private val lazyCreationResult: Lazy, ) { public val cameraGraphConfig: CameraGraph.Config get() = lazyCreationResult.value.config - @UseCaseCameraScope - @Provides - public fun provideCameraGraph(): CameraGraph { - return cameraGraphFactory(cameraGraphConfig) - } - - @UseCaseCameraScope - @Provides - public fun provideStreamConfigMap(): Map { - return lazyCreationResult.value.streamConfigMap - } - - @UseCaseCameraScope - @Provides - public fun provideUseCaseList(): java.util.ArrayList { - return java.util.ArrayList(useCases) - } - @UseCaseCameraScope @Provides public fun provideSessionConfigAdapter(): SessionConfigAdapter { @@ -118,15 +98,13 @@ public data class UseCaseCameraConfig( @UseCaseCameraScope @Provides public fun provideUseCaseGraphContext( - cameraStateAdapter: CameraStateAdapter, - streamConfigMapProvider: Provider>, - cameraGraphProvider: Provider, + cameraStateAdapter: CameraStateAdapter ): UseCaseGraphContext { Camera2Logger.debug { "Prepared UseCaseGraphContext (Deferred)" } return UseCaseGraphContext( - cameraGraphProvider = cameraGraphProvider, + cameraGraphProvider = { cameraGraphFactory(cameraGraphConfig) }, cameraStateAdapter = cameraStateAdapter, - streamConfigMapProvider = streamConfigMapProvider, + streamConfigMapProvider = { lazyCreationResult.value.streamConfigMap }, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, ) } @@ -144,10 +122,8 @@ public data class UseCaseCameraConfig( other as UseCaseCameraConfig - if (useCases != other.useCases) return false if (sessionConfigAdapter != other.sessionConfigAdapter) return false if (graphStateToCameraStateAdapter != other.graphStateToCameraStateAdapter) return false - if (isExtensions != other.isExtensions) return false if (sessionProcessor != other.sessionProcessor) return false // Intentionally exclude: @@ -157,10 +133,8 @@ public data class UseCaseCameraConfig( } override fun hashCode(): Int { - var result = useCases.hashCode() - result = 31 * result + sessionConfigAdapter.hashCode() + var result = sessionConfigAdapter.hashCode() result = 31 * result + graphStateToCameraStateAdapter.hashCode() - result = 31 * result + isExtensions.hashCode() result = 31 * result + (sessionProcessor?.hashCode() ?: 0) // Intentionally exclude lazyCreationResult and cameraGraphFactory from hash return result @@ -168,7 +142,6 @@ public data class UseCaseCameraConfig( public companion object { public fun create( - useCases: List, sessionConfigAdapter: SessionConfigAdapter, cameraGraphConfigProvider: CameraGraphConfigProvider, cameraGraphFactory: (CameraGraph.Config) -> CameraGraph, @@ -212,12 +185,10 @@ public data class UseCaseCameraConfig( } return UseCaseCameraConfig( - useCases = useCases, sessionConfigAdapter = sessionConfigAdapter, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, cameraGraphFactory = cameraGraphFactory, sessionProcessor = sessionProcessor, - isExtensions = isExtensions, lazyCreationResult = lazyResult, ) } diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/CapturePipeline.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/CapturePipeline.kt index 58d4d7f7bbed4..646dd2454a027 100644 --- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/CapturePipeline.kt +++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/CapturePipeline.kt @@ -86,6 +86,7 @@ import androidx.camera.core.impl.ConvergenceUtils import com.google.common.util.concurrent.ListenableFuture import java.util.concurrent.TimeUnit import javax.inject.Inject +import javax.inject.Provider import kotlin.reflect.KClass import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -133,7 +134,7 @@ constructor( private val requestListener: ComboRequestListener, private val useTorchAsFlash: UseTorchAsFlash, cameraProperties: CameraProperties, - private val useCaseCameraState: UseCaseCameraState, + private val useCaseCameraStateProvider: Provider, private val useCaseGraphContext: UseCaseGraphContext, ) : CapturePipeline { private enum class PipelineTask { @@ -149,7 +150,9 @@ constructor( ) // If there is no flash unit, skip the flash related task instead of failing the pipeline. - private val hasFlashUnit = cameraProperties.isFlashAvailable() + private val hasFlashUnit by lazy { cameraProperties.isFlashAvailable() } + + private val useCaseCameraState by lazy { useCaseCameraStateProvider.get() } override var template: Int = CameraDevice.TEMPLATE_PREVIEW diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCamera.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCamera.kt index f01b8e75c7a91..3ff8e383d651b 100644 --- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCamera.kt +++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCamera.kt @@ -34,6 +34,7 @@ import dagger.Module import java.util.concurrent.CancellationException import java.util.concurrent.TimeUnit import javax.inject.Inject +import javax.inject.Provider import kotlinx.atomicfu.atomic import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job @@ -73,21 +74,24 @@ public class UseCaseCameraImpl @Inject constructor( private val useCaseGraphContext: UseCaseGraphContext, - private val useCases: java.util.ArrayList, - private val useCaseSurfaceManager: UseCaseSurfaceManager, private val threads: UseCaseThreads, - private val sessionConfigAdapter: SessionConfigAdapter, - override val requestControl: UseCaseCameraRequestControl, - private val capturePipeline: CapturePipeline, private val sessionProcessor: SessionProcessor?, + override val requestControl: UseCaseCameraRequestControl, + private val useCaseSurfaceManagerProvider: Provider, + private val sessionConfigAdapterProvider: Provider, + private val capturePipelineProvider: Provider, ) : UseCaseCamera { private val debugId = useCaseCameraIds.incrementAndGet() private val closed = atomic(false) init { - Camera2Logger.debug { "Configured $this for $useCases" } + Camera2Logger.debug { "Configured $this" } } + private val useCaseSurfaceManager by lazy { useCaseSurfaceManagerProvider.get() } + private val sessionConfigAdapter by lazy { sessionConfigAdapterProvider.get() } + private val capturePipeline by lazy { capturePipelineProvider.get() } + override fun start() { threads.confineLaunch { if (closed.value) { diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControl.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControl.kt index 36a663b7edf99..b884cfb69881f 100644 --- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControl.kt +++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControl.kt @@ -48,6 +48,7 @@ import dagger.Binds import dagger.Module import java.util.concurrent.Executor import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart @@ -274,16 +275,20 @@ public interface UseCaseCameraRequestControl { public class UseCaseCameraRequestControlImpl @Inject constructor( - private val capturePipeline: CapturePipeline, - private val state: UseCaseCameraState, + private val capturePipelineProvider: Provider, + private val useCaseCameraStateProvider: Provider, private val useCaseGraphContext: UseCaseGraphContext, - private val useCaseSurfaceManager: UseCaseSurfaceManager, + private val useCaseSurfaceManagerProvider: Provider, private val threads: UseCaseThreads, private val cameraXConfig: CameraXConfig? = null, ) : UseCaseCameraRequestControl { @Volatile private var closed = false + private val capturePipeline by lazy { capturePipelineProvider.get() } + private val useCaseSurfaceManager by lazy { useCaseSurfaceManagerProvider.get() } + private val useCaseCameraState by lazy { useCaseCameraStateProvider.get() } + private data class InfoBundle( val options: Camera2ImplConfig.Builder = Camera2ImplConfig.Builder(), val tags: MutableMap = mutableMapOf(), @@ -625,7 +630,7 @@ constructor( DEFAULT_REQUEST_TEMPLATE } - state.updateAsync( + useCaseCameraState.updateAsync( parameters = options.build().toParameters(), appendParameters = false, internalParameters = mapOf(CAMERAX_TAG_BUNDLE to toTagBundle()), diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt index b24afb7c4156d..47d35826c8488 100644 --- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt +++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt @@ -390,7 +390,6 @@ constructor( } tryResumeUseCaseManager( createUseCaseCameraConfig( - newUseCases = useCases, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, sessionConfigAdapter = sessionConfigAdapter, isExtensions = useCamera2Extension, @@ -400,13 +399,11 @@ constructor( @VisibleForTesting internal fun createUseCaseCameraConfig( - newUseCases: List, sessionConfigAdapter: SessionConfigAdapter, graphStateToCameraStateAdapter: GraphStateToCameraStateAdapter, isExtensions: Boolean = false, ): UseCaseCameraConfig { return UseCaseCameraConfig.create( - useCases = newUseCases, cameraGraphConfigProvider = cameraGraphConfigProvider, sessionConfigAdapter = sessionConfigAdapter, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/CapturePipelineTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/CapturePipelineTest.kt index 1e54b6d1ac059..7557c3d3f322e 100644 --- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/CapturePipelineTest.kt +++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/CapturePipelineTest.kt @@ -1138,7 +1138,7 @@ class CapturePipelineTest { val capturePipelineTorchCorrection = CapturePipelineTorchCorrection( cameraProperties = FakeCameraProperties(), - capturePipelineImpl = capturePipeline, + capturePipelineImplProvider = { capturePipeline }, threads = fakeUseCaseThreads, torchControl = torchControl, ) @@ -1315,7 +1315,7 @@ class CapturePipelineTest { threads = fakeUseCaseThreads, torchControl = torchControl, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseCameraState = fakeUseCaseCameraState, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useTorchAsFlash = useTorchAsFlash, flashControl = flashControl, videoUsageControl = VideoUsageControl(), diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/StillCaptureRequestControlTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/StillCaptureRequestControlTest.kt index 8dd205b71d889..0941fd0fd3e33 100644 --- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/StillCaptureRequestControlTest.kt +++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/StillCaptureRequestControlTest.kt @@ -465,7 +465,7 @@ class StillCaptureRequestControlTest { ) useCaseCameraRequestControl = UseCaseCameraRequestControlImpl( - capturePipeline = + capturePipelineProvider = { CapturePipelineImpl( configAdapter = fakeConfigAdapter, cameraProperties = fakeCameraProperties, @@ -473,7 +473,7 @@ class StillCaptureRequestControlTest { threads = fakeUseCaseThreads, torchControl = torchControl, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseCameraState = fakeUseCaseCameraState, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useTorchAsFlash = NotUseTorchAsFlash, flashControl = FlashControl( @@ -484,10 +484,11 @@ class StillCaptureRequestControlTest { useFlashModeTorchFor3aUpdate = NotUseFlashModeTorchFor3aUpdate, ), videoUsageControl = VideoUsageControl(), - ), - state = fakeUseCaseCameraState, + ) + }, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseSurfaceManager = useCaseSurfaceManager, + useCaseSurfaceManagerProvider = { useCaseSurfaceManager }, threads = fakeUseCaseThreads, ) } diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControlTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControlTest.kt index 35893ec43e13e..eb913dc394cde 100644 --- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControlTest.kt +++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControlTest.kt @@ -86,10 +86,10 @@ class UseCaseCameraRequestControlTest { ) private val requestControl = UseCaseCameraRequestControlImpl( - capturePipeline = FakeCapturePipeline(), - state = fakeUseCaseCameraState, + capturePipelineProvider = { FakeCapturePipeline() }, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseSurfaceManager = FakeUseCaseSurfaceManager(threads = useCaseThreads), + useCaseSurfaceManagerProvider = { FakeUseCaseSurfaceManager(threads = useCaseThreads) }, threads = useCaseThreads, ) @@ -380,10 +380,12 @@ class UseCaseCameraRequestControlTest { .build() val requestControl = UseCaseCameraRequestControlImpl( - capturePipeline = FakeCapturePipeline(), - state = fakeUseCaseCameraState, + capturePipelineProvider = { FakeCapturePipeline() }, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseSurfaceManager = FakeUseCaseSurfaceManager(threads = useCaseThreads), + useCaseSurfaceManagerProvider = { + FakeUseCaseSurfaceManager(threads = useCaseThreads) + }, threads = useCaseThreads, cameraXConfig = cameraXConfig, ) @@ -417,10 +419,12 @@ class UseCaseCameraRequestControlTest { .build() val requestControl = UseCaseCameraRequestControlImpl( - capturePipeline = FakeCapturePipeline(), - state = fakeUseCaseCameraState, + capturePipelineProvider = { FakeCapturePipeline() }, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseSurfaceManager = FakeUseCaseSurfaceManager(threads = useCaseThreads), + useCaseSurfaceManagerProvider = { + FakeUseCaseSurfaceManager(threads = useCaseThreads) + }, threads = useCaseThreads, cameraXConfig = cameraXConfig, ) diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManagerTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManagerTest.kt index b5049c89732b5..bf36533cce313 100644 --- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManagerTest.kt +++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManagerTest.kt @@ -437,7 +437,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -476,7 +475,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -516,7 +514,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -573,7 +570,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -607,7 +603,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -646,7 +641,6 @@ class UseCaseManagerTest { val cameraGraphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -722,7 +716,6 @@ class UseCaseManagerTest { // Act. useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeUseCaseCamera.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeUseCaseCamera.kt index 74773deed16f7..ffa0ea2795237 100644 --- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeUseCaseCamera.kt +++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/testing/FakeUseCaseCamera.kt @@ -89,7 +89,6 @@ class FakeUseCaseCameraComponentBuilder : UseCaseCameraComponent.Builder { private var config: UseCaseCameraConfig = UseCaseCameraConfig.create( - useCases = emptyList(), cameraGraphConfigProvider = configProvider, cameraGraphFactory = { _ -> cameraGraph }, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, diff --git a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/testing/TestUseCaseCamera.kt b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/testing/TestUseCaseCamera.kt index e89d9915268a3..19af64172e773 100644 --- a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/testing/TestUseCaseCamera.kt +++ b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/testing/TestUseCaseCamera.kt @@ -103,25 +103,16 @@ class TestUseCaseCamera( ) val cameraStateAdapter = CameraStateAdapter() - val graphStateToCameraStateAdapter = GraphStateToCameraStateAdapter(cameraStateAdapter) val useCaseCameraConfig = UseCaseCameraConfig.create( - useCases = useCases, cameraGraphConfigProvider = configProvider, cameraGraphFactory = { config -> cameraPipe.createCameraGraph(config) }, - graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, + graphStateToCameraStateAdapter = GraphStateToCameraStateAdapter(cameraStateAdapter), sessionConfigAdapter = sessionConfigAdapter, isExtensions = false, sessionProcessor = null, ) - useCaseGraphContext = - UseCaseGraphContext( - cameraGraphProvider = { useCaseCameraConfig.provideCameraGraph() }, - cameraStateAdapter = cameraStateAdapter, - streamConfigMapProvider = { useCaseCameraConfig.provideStreamConfigMap() }, - graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, - ) - + useCaseGraphContext = useCaseCameraConfig.provideUseCaseGraphContext(cameraStateAdapter) sessionConfigAdapter.getValidSessionConfigOrNull()?.let { sessionConfig -> CameraInteropStateCallbackRepository().updateCallbacks(sessionConfig) } @@ -129,7 +120,7 @@ class TestUseCaseCamera( override val requestControl: UseCaseCameraRequestControl = UseCaseCameraRequestControlImpl( - capturePipeline = + capturePipelineProvider = { object : CapturePipeline { override var template: Int = CameraDevice.TEMPLATE_PREVIEW @@ -149,14 +140,16 @@ class TestUseCaseCamera( flashMode: Int, flashType: Int, ): CameraCapturePipeline = FakeCameraCapturePipeline() - }, - state = + } + }, + useCaseCameraStateProvider = { UseCaseCameraState( useCaseGraphContext, templateParamsOverride = NoOpTemplateParamsOverride, - ), + ) + }, useCaseGraphContext = useCaseGraphContext, - useCaseSurfaceManager = useCaseSurfaceManager, + useCaseSurfaceManagerProvider = { useCaseSurfaceManager }, threads = threads, ) .apply { diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/workaround/CapturePipelineTorchCorrection.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/workaround/CapturePipelineTorchCorrection.kt index 5052e27713f46..c19524ac96ed3 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/workaround/CapturePipelineTorchCorrection.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/workaround/CapturePipelineTorchCorrection.kt @@ -36,6 +36,7 @@ import androidx.camera.core.imagecapture.CameraCapturePipeline import androidx.camera.core.impl.CaptureConfig import androidx.camera.core.impl.Config import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.Deferred import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @@ -52,11 +53,12 @@ public class CapturePipelineTorchCorrection @Inject constructor( cameraProperties: CameraProperties, - private val capturePipelineImpl: CapturePipelineImpl, + private val capturePipelineImplProvider: Provider, private val threads: UseCaseThreads, private val torchControl: TorchControl, ) : CapturePipeline { - private val isLegacyDevice = cameraProperties.metadata.isHardwareLevelLegacy + private val isLegacyDevice by lazy { cameraProperties.metadata.isHardwareLevelLegacy } + private val capturePipelineImpl by lazy { capturePipelineImplProvider.get() } override suspend fun submitStillCaptures( configs: List, diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/config/UseCaseCameraConfig.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/config/UseCaseCameraConfig.kt index fc65f4a242809..54f53a5c90a25 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/config/UseCaseCameraConfig.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/config/UseCaseCameraConfig.kt @@ -55,14 +55,14 @@ public abstract class UseCaseCameraModule { @UseCaseCameraScope @Provides public fun provideCapturePipeline( - capturePipelineImpl: CapturePipelineImpl, - capturePipelineTorchCorrection: CapturePipelineTorchCorrection, + capturePipelineImplProvider: Provider, + capturePipelineTorchCorrectionProvider: Provider, ): CapturePipeline { if (CapturePipelineTorchCorrection.isEnabled) { - return capturePipelineTorchCorrection + return capturePipelineTorchCorrectionProvider.get() } - return capturePipelineImpl + return capturePipelineImplProvider.get() } } } @@ -70,35 +70,15 @@ public abstract class UseCaseCameraModule { /** Dagger module for binding the [UseCase]'s to the [UseCaseCamera]. */ @Module public data class UseCaseCameraConfig( - private val useCases: List, private val cameraGraphFactory: (CameraGraph.Config) -> CameraGraph, private val graphStateToCameraStateAdapter: GraphStateToCameraStateAdapter, private val sessionConfigAdapter: SessionConfigAdapter, - private val isExtensions: Boolean, private val sessionProcessor: SessionProcessor?, private val lazyCreationResult: Lazy, ) { public val cameraGraphConfig: CameraGraph.Config get() = lazyCreationResult.value.config - @UseCaseCameraScope - @Provides - public fun provideCameraGraph(): CameraGraph { - return cameraGraphFactory(cameraGraphConfig) - } - - @UseCaseCameraScope - @Provides - public fun provideStreamConfigMap(): Map { - return lazyCreationResult.value.streamConfigMap - } - - @UseCaseCameraScope - @Provides - public fun provideUseCaseList(): java.util.ArrayList { - return java.util.ArrayList(useCases) - } - @UseCaseCameraScope @Provides public fun provideSessionConfigAdapter(): SessionConfigAdapter { @@ -118,15 +98,13 @@ public data class UseCaseCameraConfig( @UseCaseCameraScope @Provides public fun provideUseCaseGraphContext( - cameraStateAdapter: CameraStateAdapter, - streamConfigMapProvider: Provider>, - cameraGraphProvider: Provider, + cameraStateAdapter: CameraStateAdapter ): UseCaseGraphContext { Camera2Logger.debug { "Prepared UseCaseGraphContext (Deferred)" } return UseCaseGraphContext( - cameraGraphProvider = cameraGraphProvider, + cameraGraphProvider = { cameraGraphFactory(cameraGraphConfig) }, cameraStateAdapter = cameraStateAdapter, - streamConfigMapProvider = streamConfigMapProvider, + streamConfigMapProvider = { lazyCreationResult.value.streamConfigMap }, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, ) } @@ -144,10 +122,8 @@ public data class UseCaseCameraConfig( other as UseCaseCameraConfig - if (useCases != other.useCases) return false if (sessionConfigAdapter != other.sessionConfigAdapter) return false if (graphStateToCameraStateAdapter != other.graphStateToCameraStateAdapter) return false - if (isExtensions != other.isExtensions) return false if (sessionProcessor != other.sessionProcessor) return false // Intentionally exclude: @@ -157,10 +133,8 @@ public data class UseCaseCameraConfig( } override fun hashCode(): Int { - var result = useCases.hashCode() - result = 31 * result + sessionConfigAdapter.hashCode() + var result = sessionConfigAdapter.hashCode() result = 31 * result + graphStateToCameraStateAdapter.hashCode() - result = 31 * result + isExtensions.hashCode() result = 31 * result + (sessionProcessor?.hashCode() ?: 0) // Intentionally exclude lazyCreationResult and cameraGraphFactory from hash return result @@ -168,7 +142,6 @@ public data class UseCaseCameraConfig( public companion object { public fun create( - useCases: List, sessionConfigAdapter: SessionConfigAdapter, cameraGraphConfigProvider: CameraGraphConfigProvider, cameraGraphFactory: (CameraGraph.Config) -> CameraGraph, @@ -212,12 +185,10 @@ public data class UseCaseCameraConfig( } return UseCaseCameraConfig( - useCases = useCases, sessionConfigAdapter = sessionConfigAdapter, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, cameraGraphFactory = cameraGraphFactory, sessionProcessor = sessionProcessor, - isExtensions = isExtensions, lazyCreationResult = lazyResult, ) } diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/CapturePipeline.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/CapturePipeline.kt index 99049bb0b0340..e2effa32485f4 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/CapturePipeline.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/CapturePipeline.kt @@ -86,6 +86,7 @@ import androidx.camera.core.impl.ConvergenceUtils import com.google.common.util.concurrent.ListenableFuture import java.util.concurrent.TimeUnit import javax.inject.Inject +import javax.inject.Provider import kotlin.reflect.KClass import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -133,7 +134,7 @@ constructor( private val requestListener: ComboRequestListener, private val useTorchAsFlash: UseTorchAsFlash, cameraProperties: CameraProperties, - private val useCaseCameraState: UseCaseCameraState, + private val useCaseCameraStateProvider: Provider, private val useCaseGraphContext: UseCaseGraphContext, ) : CapturePipeline { private enum class PipelineTask { @@ -149,7 +150,9 @@ constructor( ) // If there is no flash unit, skip the flash related task instead of failing the pipeline. - private val hasFlashUnit = cameraProperties.isFlashAvailable() + private val hasFlashUnit by lazy { cameraProperties.isFlashAvailable() } + + private val useCaseCameraState by lazy { useCaseCameraStateProvider.get() } override var template: Int = CameraDevice.TEMPLATE_PREVIEW diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCamera.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCamera.kt index b371b97caf16d..e8de86e0faef2 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCamera.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCamera.kt @@ -34,6 +34,7 @@ import dagger.Module import java.util.concurrent.CancellationException import java.util.concurrent.TimeUnit import javax.inject.Inject +import javax.inject.Provider import kotlinx.atomicfu.atomic import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job @@ -73,21 +74,24 @@ public class UseCaseCameraImpl @Inject constructor( private val useCaseGraphContext: UseCaseGraphContext, - private val useCases: java.util.ArrayList, - private val useCaseSurfaceManager: UseCaseSurfaceManager, private val threads: UseCaseThreads, - private val sessionConfigAdapter: SessionConfigAdapter, - override val requestControl: UseCaseCameraRequestControl, - private val capturePipeline: CapturePipeline, private val sessionProcessor: SessionProcessor?, + override val requestControl: UseCaseCameraRequestControl, + private val useCaseSurfaceManagerProvider: Provider, + private val sessionConfigAdapterProvider: Provider, + private val capturePipelineProvider: Provider, ) : UseCaseCamera { private val debugId = useCaseCameraIds.incrementAndGet() private val closed = atomic(false) init { - Camera2Logger.debug { "Configured $this for $useCases" } + Camera2Logger.debug { "Configured $this" } } + private val useCaseSurfaceManager by lazy { useCaseSurfaceManagerProvider.get() } + private val sessionConfigAdapter by lazy { sessionConfigAdapterProvider.get() } + private val capturePipeline by lazy { capturePipelineProvider.get() } + override fun start() { threads.confineLaunch { if (closed.value) { diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCameraRequestControl.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCameraRequestControl.kt index 7ade159435420..ce84f914c8970 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCameraRequestControl.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCameraRequestControl.kt @@ -48,6 +48,7 @@ import dagger.Binds import dagger.Module import java.util.concurrent.Executor import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart @@ -274,16 +275,20 @@ public interface UseCaseCameraRequestControl { public class UseCaseCameraRequestControlImpl @Inject constructor( - private val capturePipeline: CapturePipeline, - private val state: UseCaseCameraState, + private val capturePipelineProvider: Provider, + private val useCaseCameraStateProvider: Provider, private val useCaseGraphContext: UseCaseGraphContext, - private val useCaseSurfaceManager: UseCaseSurfaceManager, + private val useCaseSurfaceManagerProvider: Provider, private val threads: UseCaseThreads, private val cameraXConfig: CameraXConfig? = null, ) : UseCaseCameraRequestControl { @Volatile private var closed = false + private val capturePipeline by lazy { capturePipelineProvider.get() } + private val useCaseSurfaceManager by lazy { useCaseSurfaceManagerProvider.get() } + private val useCaseCameraState by lazy { useCaseCameraStateProvider.get() } + private data class InfoBundle( val options: Camera2ImplConfig.Builder = Camera2ImplConfig.Builder(), val tags: MutableMap = mutableMapOf(), @@ -625,7 +630,7 @@ constructor( DEFAULT_REQUEST_TEMPLATE } - state.updateAsync( + useCaseCameraState.updateAsync( parameters = options.build().toParameters(), appendParameters = false, internalParameters = mapOf(CAMERAX_TAG_BUNDLE to toTagBundle()), diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseManager.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseManager.kt index bb359c8800855..0d2300535e06a 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseManager.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseManager.kt @@ -391,7 +391,6 @@ constructor( } tryResumeUseCaseManager( createUseCaseCameraConfig( - newUseCases = useCases, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, sessionConfigAdapter = sessionConfigAdapter, isExtensions = useCamera2Extension, @@ -401,13 +400,11 @@ constructor( @VisibleForTesting internal fun createUseCaseCameraConfig( - newUseCases: List, sessionConfigAdapter: SessionConfigAdapter, graphStateToCameraStateAdapter: GraphStateToCameraStateAdapter, isExtensions: Boolean = false, ): UseCaseCameraConfig { return UseCaseCameraConfig.create( - useCases = newUseCases, cameraGraphConfigProvider = cameraGraphConfigProvider, sessionConfigAdapter = sessionConfigAdapter, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/CapturePipelineTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/CapturePipelineTest.kt index b661df06407b6..0bc46c3453647 100644 --- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/CapturePipelineTest.kt +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/CapturePipelineTest.kt @@ -1139,7 +1139,7 @@ class CapturePipelineTest { val capturePipelineTorchCorrection = CapturePipelineTorchCorrection( cameraProperties = FakeCameraProperties(), - capturePipelineImpl = capturePipeline, + capturePipelineImplProvider = { capturePipeline }, threads = fakeUseCaseThreads, torchControl = torchControl, ) @@ -1316,7 +1316,7 @@ class CapturePipelineTest { threads = fakeUseCaseThreads, torchControl = torchControl, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseCameraState = fakeUseCaseCameraState, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useTorchAsFlash = useTorchAsFlash, flashControl = flashControl, videoUsageControl = VideoUsageControl(), diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/StillCaptureRequestControlTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/StillCaptureRequestControlTest.kt index 62a722f2a30a2..1f7d05d20e238 100644 --- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/StillCaptureRequestControlTest.kt +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/StillCaptureRequestControlTest.kt @@ -465,7 +465,7 @@ class StillCaptureRequestControlTest { ) useCaseCameraRequestControl = UseCaseCameraRequestControlImpl( - capturePipeline = + capturePipelineProvider = { CapturePipelineImpl( configAdapter = fakeConfigAdapter, cameraProperties = fakeCameraProperties, @@ -473,7 +473,7 @@ class StillCaptureRequestControlTest { threads = fakeUseCaseThreads, torchControl = torchControl, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseCameraState = fakeUseCaseCameraState, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useTorchAsFlash = NotUseTorchAsFlash, flashControl = FlashControl( @@ -484,10 +484,11 @@ class StillCaptureRequestControlTest { useFlashModeTorchFor3aUpdate = NotUseFlashModeTorchFor3aUpdate, ), videoUsageControl = VideoUsageControl(), - ), - state = fakeUseCaseCameraState, + ) + }, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseSurfaceManager = useCaseSurfaceManager, + useCaseSurfaceManagerProvider = { useCaseSurfaceManager }, threads = fakeUseCaseThreads, ) } diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/UseCaseCameraRequestControlTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/UseCaseCameraRequestControlTest.kt index 75a0dfebfb508..1643a84a70956 100644 --- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/UseCaseCameraRequestControlTest.kt +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/UseCaseCameraRequestControlTest.kt @@ -86,10 +86,10 @@ class UseCaseCameraRequestControlTest { ) private val requestControl = UseCaseCameraRequestControlImpl( - capturePipeline = FakeCapturePipeline(), - state = fakeUseCaseCameraState, + capturePipelineProvider = { FakeCapturePipeline() }, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseSurfaceManager = FakeUseCaseSurfaceManager(threads = useCaseThreads), + useCaseSurfaceManagerProvider = { FakeUseCaseSurfaceManager(threads = useCaseThreads) }, threads = useCaseThreads, ) @@ -380,10 +380,12 @@ class UseCaseCameraRequestControlTest { .build() val requestControl = UseCaseCameraRequestControlImpl( - capturePipeline = FakeCapturePipeline(), - state = fakeUseCaseCameraState, + capturePipelineProvider = { FakeCapturePipeline() }, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseSurfaceManager = FakeUseCaseSurfaceManager(threads = useCaseThreads), + useCaseSurfaceManagerProvider = { + FakeUseCaseSurfaceManager(threads = useCaseThreads) + }, threads = useCaseThreads, cameraXConfig = cameraXConfig, ) @@ -417,10 +419,12 @@ class UseCaseCameraRequestControlTest { .build() val requestControl = UseCaseCameraRequestControlImpl( - capturePipeline = FakeCapturePipeline(), - state = fakeUseCaseCameraState, + capturePipelineProvider = { FakeCapturePipeline() }, + useCaseCameraStateProvider = { fakeUseCaseCameraState }, useCaseGraphContext = fakeUseCaseGraphContext, - useCaseSurfaceManager = FakeUseCaseSurfaceManager(threads = useCaseThreads), + useCaseSurfaceManagerProvider = { + FakeUseCaseSurfaceManager(threads = useCaseThreads) + }, threads = useCaseThreads, cameraXConfig = cameraXConfig, ) diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/UseCaseManagerTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/UseCaseManagerTest.kt index 66d7787f65d39..bed278d93cdb4 100644 --- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/UseCaseManagerTest.kt +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/UseCaseManagerTest.kt @@ -437,7 +437,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -476,7 +475,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -516,7 +514,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -573,7 +570,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -607,7 +603,6 @@ class UseCaseManagerTest { val graphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -646,7 +641,6 @@ class UseCaseManagerTest { val cameraGraphConfig = useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) @@ -722,7 +716,6 @@ class UseCaseManagerTest { // Act. useCaseManager .createUseCaseCameraConfig( - listOf(fakeUseCase), sessionConfigAdapter, GraphStateToCameraStateAdapter(CameraStateAdapter()), ) diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/testing/FakeUseCaseCamera.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/testing/FakeUseCaseCamera.kt index 3fac1199ab63e..ed4330df6440e 100644 --- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/testing/FakeUseCaseCamera.kt +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/testing/FakeUseCaseCamera.kt @@ -89,7 +89,6 @@ class FakeUseCaseCameraComponentBuilder : UseCaseCameraComponent.Builder { private var config: UseCaseCameraConfig = UseCaseCameraConfig.create( - useCases = emptyList(), cameraGraphConfigProvider = configProvider, cameraGraphFactory = { _ -> cameraGraph }, graphStateToCameraStateAdapter = graphStateToCameraStateAdapter, From c5a8a44b225526264aae29ac00b837de42694cfb Mon Sep 17 00:00:00 2001 From: Aadish Goel Date: Tue, 13 Jan 2026 03:16:46 +0530 Subject: [PATCH 2/7] Add getTopPageObject in pdf document service This change introduces a new API getTopPageObjectAtPosition to the PdfDocumentRemote interface to query the topmost PDF object at a specific point on a page, filtered by object type. - A new PdfObject AIDL parcelable. - The getTopPageObjectAtPosition method in PdfDocumentRemote.aidl Test: SandboxedPdfDocumentTest Bug: 448072992 Change-Id: I6a862b2ad655aeabfe45f16a1c3f4b3dec3e6236 --- .../androidx/pdf/PdfDocumentRemote.aidl | 1 + .../pdf/annotation/models/PdfObject.aidl | 21 ++++++ .../pdf/service/PdfDocumentRemoteImpl.kt | 16 +++++ .../androidx/pdf/PdfDocumentRemote.aidl | 12 ++++ .../pdf/annotation/models/PdfObject.aidl | 4 ++ .../pdf/service/FakePdfDocumentRemote.kt | 10 +++ .../DefaultPdfObjectDrawerFactoryImpl.kt | 4 ++ .../drawer/StampPdfAnnotationDrawer.kt | 10 ++- .../pdf/annotation/models/ImagePdfObject.kt | 69 +++++++++++++++++++ .../pdf/annotation/models/PathPdfObject.kt | 11 +-- .../pdf/annotation/models/PdfObject.kt | 44 +++++++++++- .../pdf/annotation/models/StampAnnotation.kt | 2 +- 12 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 pdf/pdf-document-service/api/aidlRelease/current/androidx/pdf/annotation/models/PdfObject.aidl create mode 100644 pdf/pdf-document-service/src/main/stableAidl/androidx/pdf/annotation/models/PdfObject.aidl create mode 100644 pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/ImagePdfObject.kt diff --git a/pdf/pdf-document-service/api/aidlRelease/current/androidx/pdf/PdfDocumentRemote.aidl b/pdf/pdf-document-service/api/aidlRelease/current/androidx/pdf/PdfDocumentRemote.aidl index 8f37614a81709..4dc6eadec9b34 100644 --- a/pdf/pdf-document-service/api/aidlRelease/current/androidx/pdf/PdfDocumentRemote.aidl +++ b/pdf/pdf-document-service/api/aidlRelease/current/androidx/pdf/PdfDocumentRemote.aidl @@ -56,4 +56,5 @@ interface PdfDocumentRemote { androidx.pdf.annotation.models.PaginatedAnnotations getPageAnnotations(int pageNum); androidx.pdf.annotation.models.PaginatedAnnotations getBatchedPageAnnotations(int pageNum, in int batchIndex); androidx.pdf.DraftEditResult applyDraftEdits(in List operations); + androidx.pdf.annotation.models.PdfObject getTopPageObjectAtPosition(int pageNum, in android.graphics.PointF point, in int[] types); } diff --git a/pdf/pdf-document-service/api/aidlRelease/current/androidx/pdf/annotation/models/PdfObject.aidl b/pdf/pdf-document-service/api/aidlRelease/current/androidx/pdf/annotation/models/PdfObject.aidl new file mode 100644 index 0000000000000..8667d3ae5ffe1 --- /dev/null +++ b/pdf/pdf-document-service/api/aidlRelease/current/androidx/pdf/annotation/models/PdfObject.aidl @@ -0,0 +1,21 @@ +/////////////////////////////////////////////////////////////////////////////// +// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE. // +/////////////////////////////////////////////////////////////////////////////// + +// This file is a snapshot of an AIDL file. Do not edit it manually. There are +// two cases: +// 1). this is a frozen version file - do not edit this in any case. +// 2). this is a 'current' file. If you make a backwards compatible change to +// the interface (from the latest frozen version), the build system will +// prompt you to update this file with `m -update-api`. +// +// You must not make a backward incompatible change to any AIDL file built +// with the aidl_interface module type with versions property set. The module +// type is used to build AIDL files in a way that they can be used across +// independently updatable components of the system. If a device is shipped +// with such a backward incompatible change, it has a high risk of breaking +// later when a module using the interface is updated, e.g., Mainline modules. + +package androidx.pdf.annotation.models; +@JavaOnlyStableParcelable @JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)") +parcelable PdfObject; diff --git a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt index 6d7c7d1cef3bb..2f7db3cbbefe5 100644 --- a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt +++ b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt @@ -18,6 +18,7 @@ package androidx.pdf.service import android.graphics.Bitmap import android.graphics.Color +import android.graphics.PointF import android.graphics.Rect import android.graphics.pdf.content.PdfPageGotoLinkContent import android.graphics.pdf.content.PdfPageImageContent @@ -42,6 +43,7 @@ import androidx.pdf.adapter.PdfDocumentRendererFactory import androidx.pdf.adapter.PdfDocumentRendererFactoryImpl import androidx.pdf.annotation.PageAnnotationsProviderImpl import androidx.pdf.annotation.models.PaginatedAnnotations +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.annotation.processor.PageAnnotationsPaginator import androidx.pdf.annotation.processor.PdfRendererAnnotationsProcessor import androidx.pdf.models.Dimensions @@ -153,6 +155,20 @@ internal class PdfDocumentRemoteImpl( return rendererAdapter.withPage(pageNum) { page -> page.getFormWidgetInfos(types) } } + override fun getTopPageObjectAtPosition( + pageNum: Int, + point: PointF, + types: IntArray, + ): PdfObject? { + return rendererAdapter.withPage(pageNum) { page -> + val topObjectResult = page.getTopPageObjectAtPosition(point, types) + topObjectResult?.let { + // TODO: b/447328448 - Convert AOSP Image PdfPageObject to Jetpack Image PdfObject + return@withPage null + } + } + } + override fun applyEdit(pageNum: Int, editRecord: FormEditRecord): List? { return rendererAdapter.withPage(pageNum) { page -> page.applyEdit(editRecord) } } diff --git a/pdf/pdf-document-service/src/main/stableAidl/androidx/pdf/PdfDocumentRemote.aidl b/pdf/pdf-document-service/src/main/stableAidl/androidx/pdf/PdfDocumentRemote.aidl index 5e16a907d0422..89d4f7b487d60 100644 --- a/pdf/pdf-document-service/src/main/stableAidl/androidx/pdf/PdfDocumentRemote.aidl +++ b/pdf/pdf-document-service/src/main/stableAidl/androidx/pdf/PdfDocumentRemote.aidl @@ -27,6 +27,7 @@ import android.graphics.pdf.models.FormEditRecord; import android.graphics.pdf.models.FormWidgetInfo; import android.graphics.pdf.models.selection.PageSelection; import android.graphics.pdf.models.selection.SelectionBoundary; +import android.graphics.PointF; import android.os.ParcelFileDescriptor; import androidx.pdf.DraftEditOperation; import androidx.pdf.DraftEditResult; @@ -34,6 +35,7 @@ import androidx.pdf.models.Dimensions; import androidx.pdf.annotation.models.PdfAnnotation; import androidx.pdf.annotation.models.PaginatedAnnotations; import androidx.pdf.RenderParams; +import androidx.pdf.annotation.models.PdfObject; /** Remote interface for interacting with a PDF document */ @JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)") @@ -230,4 +232,14 @@ interface PdfDocumentRemote { */ DraftEditResult applyDraftEdits(in List operations); + /** + * Gets the topmost PDF object at a given position on a page, filtered by object types. + * + * @param point The position on the page to check, where coordinates are relative to the top-left + * of the page. + * @param types An array of integers representing the types of PDF objects to consider. + * @return The {@link PdfObject} found at the specified position, or null if no object is found. + */ + PdfObject getTopPageObjectAtPosition( int pageNum, in PointF point, in int[] types); + } \ No newline at end of file diff --git a/pdf/pdf-document-service/src/main/stableAidl/androidx/pdf/annotation/models/PdfObject.aidl b/pdf/pdf-document-service/src/main/stableAidl/androidx/pdf/annotation/models/PdfObject.aidl new file mode 100644 index 0000000000000..682101e87ad38 --- /dev/null +++ b/pdf/pdf-document-service/src/main/stableAidl/androidx/pdf/annotation/models/PdfObject.aidl @@ -0,0 +1,4 @@ +package androidx.pdf.annotation.models; + +@JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)") +@JavaOnlyStableParcelable parcelable PdfObject; diff --git a/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/service/FakePdfDocumentRemote.kt b/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/service/FakePdfDocumentRemote.kt index e88aeb6e7d6f8..bc839d0f2ec63 100644 --- a/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/service/FakePdfDocumentRemote.kt +++ b/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/service/FakePdfDocumentRemote.kt @@ -17,6 +17,7 @@ package androidx.pdf.service import android.graphics.Bitmap +import android.graphics.PointF import android.graphics.Rect import android.graphics.pdf.content.PdfPageGotoLinkContent import android.graphics.pdf.content.PdfPageImageContent @@ -34,6 +35,7 @@ import androidx.pdf.PdfDocumentRemote import androidx.pdf.RenderParams import androidx.pdf.TestDraftEditOperation import androidx.pdf.annotation.models.PaginatedAnnotations +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.models.Dimensions class FakePdfDocumentRemote : PdfDocumentRemote.Stub() { @@ -160,4 +162,12 @@ class FakePdfDocumentRemote : PdfDocumentRemote.Stub() { // Return next behavior, or reuse last one if we run out (or throw) return if (behaviors.size > 1) behaviors.removeFirst() else behaviors.first() } + + override fun getTopPageObjectAtPosition( + pageNum: Int, + point: PointF?, + types: IntArray?, + ): PdfObject? { + TODO("Not yet implemented") + } } diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/drawer/DefaultPdfObjectDrawerFactoryImpl.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/drawer/DefaultPdfObjectDrawerFactoryImpl.kt index 628fbbd52f01e..479121dc33069 100644 --- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/drawer/DefaultPdfObjectDrawerFactoryImpl.kt +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/drawer/DefaultPdfObjectDrawerFactoryImpl.kt @@ -24,6 +24,10 @@ internal object DefaultPdfObjectDrawerFactoryImpl : PdfObjectDrawerFactory { override fun create(pdfObject: PdfObject): PdfObjectDrawer { return when (pdfObject) { is PathPdfObject -> PathPdfObjectDrawer + else -> + throw UnsupportedOperationException( + "PdfObject :: ${this.javaClass.simpleName} is not supported!" + ) } } } diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/drawer/StampPdfAnnotationDrawer.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/drawer/StampPdfAnnotationDrawer.kt index b9ac6fd32fc83..fe1b41bb16141 100644 --- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/drawer/StampPdfAnnotationDrawer.kt +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/drawer/StampPdfAnnotationDrawer.kt @@ -27,9 +27,13 @@ internal class StampPdfAnnotationDrawer( ) : PdfAnnotationDrawer { override fun draw(pdfAnnotation: StampAnnotation, canvas: Canvas, transform: Matrix) { pdfAnnotation.pdfObjects.forEach { pdfObject -> - val drawer = pdfObjectDrawerFactory.create(pdfObject) - @Suppress("UNCHECKED_CAST") - (drawer as PdfObjectDrawer).draw(pdfObject, canvas, transform) + try { + val drawer = pdfObjectDrawerFactory.create(pdfObject) + @Suppress("UNCHECKED_CAST") + (drawer as PdfObjectDrawer).draw(pdfObject, canvas, transform) + } catch (e: UnsupportedOperationException) { + // TODO: b/440966572 - Handle Logging of Unsupported Annotations and Pfd Objects. + } } } } diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/ImagePdfObject.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/ImagePdfObject.kt new file mode 100644 index 0000000000000..9c2ee3602bf58 --- /dev/null +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/ImagePdfObject.kt @@ -0,0 +1,69 @@ +/* + * 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.pdf.annotation.models + +import android.graphics.Bitmap +import android.graphics.RectF +import android.os.Parcel +import android.os.Parcelable +import androidx.annotation.RestrictTo +import androidx.core.os.ParcelCompat + +/** + * Represents an image object within a PDF document. + * + * @property bitmap The [Bitmap] data of the image. + * @property bounds The rectangular boundaries of its position and size on the PDF page. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY) +public class ImagePdfObject(public val bitmap: Bitmap, public val bounds: RectF) : PdfObject { + + /** Flattens this object in to a Parcel. */ + public override fun writeToParcel(dest: Parcel, flags: Int) { + super.writeToParcel(dest, flags) + dest.writeFloat(bounds.left) + dest.writeFloat(bounds.top) + dest.writeFloat(bounds.right) + dest.writeFloat(bounds.bottom) + dest.writeParcelable(bitmap, flags) + } + + public companion object { + @JvmField + public val CREATOR: Parcelable.Creator = + object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ImagePdfObject { + val left = parcel.readFloat() + val top = parcel.readFloat() + val right = parcel.readFloat() + val bottom = parcel.readFloat() + val bitmap: Bitmap? = + ParcelCompat.readParcelable( + parcel, + Bitmap::class.java.classLoader, + Bitmap::class.java, + ) + if (bitmap != null) + return ImagePdfObject(bitmap, RectF(left, top, right, bottom)) + throw IllegalArgumentException("bitmap cannot be null") + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } +} diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/PathPdfObject.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/PathPdfObject.kt index a4800cba7d092..fe7dd9852f5e1 100644 --- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/PathPdfObject.kt +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/PathPdfObject.kt @@ -47,13 +47,14 @@ public class PathPdfObject( } /** Flattens this object in to a Parcel. */ - override fun writeToParcel(parcel: Parcel, flags: Int) { + public override fun writeToParcel(dest: Parcel, flags: Int) { + super.writeToParcel(dest, flags) val inputs: List = inputs - parcel.writeInt(brushColor) - parcel.writeFloat(brushWidth) - parcel.writeInt(inputs.size) + dest.writeInt(brushColor) + dest.writeFloat(brushWidth) + dest.writeInt(inputs.size) for (input in inputs) { - input.writeToParcel(parcel, flags) + input.writeToParcel(dest, flags) } } diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/PdfObject.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/PdfObject.kt index 0d18666a532fd..f30ed06108eb7 100644 --- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/PdfObject.kt +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/PdfObject.kt @@ -16,6 +16,7 @@ package androidx.pdf.annotation.models +import android.os.Parcel import android.os.Parcelable import androidx.annotation.RestrictTo @@ -26,17 +27,56 @@ import androidx.annotation.RestrictTo @RestrictTo(RestrictTo.Scope.LIBRARY) public sealed interface PdfObject : Parcelable { + override fun writeToParcel(dest: Parcel, flags: Int) { + when (this) { + is PathPdfObject -> { + dest.writeInt(TYPE_PATH_PDF_OBJECT) + } + is ImagePdfObject -> { + dest.writeInt(TYPE_IMAGE_PDF_OBJECT) + } + } + } + /** Companion object holding constants related to [PdfObject] types. */ public companion object { /** Constant representing an unknown PDF object type. Used as a default or error value. */ - public const val TYPE_UNKNOWN: Int = 0 + @JvmField public val TYPE_UNKNOWN: Int = 0 /** * Constant representing a path PDF object type. * * @see PathPdfObject */ - public const val TYPE_PATH_PDF_OBJECT: Int = 1 + @JvmField public val TYPE_PATH_PDF_OBJECT: Int = 1 + + /** + * Constant representing a image PDF object type. + * + * @see PathPdfObject + */ + @JvmField public val TYPE_IMAGE_PDF_OBJECT: Int = 2 + + /** Parcelable creator for [PdfObject]. */ + @JvmField + public val CREATOR: Parcelable.Creator = + object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): PdfObject? { + val type = parcel.readInt() + + return when (type) { + TYPE_PATH_PDF_OBJECT -> PathPdfObject.CREATOR.createFromParcel(parcel) + TYPE_IMAGE_PDF_OBJECT -> ImagePdfObject.CREATOR.createFromParcel(parcel) + else -> { + null + } + } + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } } /** Default implementation for [Parcelable.describeContents], returning 0. */ diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/StampAnnotation.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/StampAnnotation.kt index 09090e2262067..9561cf2b1cf32 100644 --- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/StampAnnotation.kt +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/models/StampAnnotation.kt @@ -66,9 +66,9 @@ public class StampAnnotation( for (pdfObject in pdfObjects) { when (pdfObject) { is PathPdfObject -> { - dest.writeInt(PdfObject.TYPE_PATH_PDF_OBJECT) pdfObject.writeToParcel(dest, flags) } + else -> {} } } } From a54631eeedf0809799b88f10efb2bb9b8e17194a Mon Sep 17 00:00:00 2001 From: Aadish Goel Date: Wed, 12 Nov 2025 11:55:13 +0530 Subject: [PATCH 3/7] Add getTopPageObjectAtPosition API in pdf document This change introduces the getTopPageObjectAtPosition functionality to the PdfDocument interface. includes: - Adds the new getTopPageObjectAtPosition suspend function to the PdfDocument interface. - Implementation in SandboxedPdfDocument`. - Adding placeholder implementations in various FakePdfDocument and FakeEditablePdfDocument classes used for testing. Test: SandboxedPdfDocumentTest Bug: 447328457 Change-Id: I342aa77bdd48554f9f3ec0ce30177e30122c47b9 --- .../kotlin/androidx/pdf/compose/FakePdfDocument.kt | 5 +++++ .../main/kotlin/androidx/pdf/SandboxedPdfDocument.kt | 8 ++++++++ .../kotlin/androidx/pdf/FakeEditablePdfDocument.kt | 5 +++++ .../kotlin/androidx/pdf/FakeEditablePdfDocument.kt | 5 +++++ .../androidx/pdf/viewer/document/FakePdfDocument.kt | 5 +++++ .../kotlin/androidx/pdf/view/FakePdfDocument.kt | 5 +++++ .../src/main/kotlin/androidx/pdf/PdfDocument.kt | 12 ++++++++++++ .../src/test/kotlin/androidx/pdf/FakePdfDocument.kt | 5 +++++ 8 files changed, 50 insertions(+) diff --git a/pdf/pdf-compose/src/androidTest/kotlin/androidx/pdf/compose/FakePdfDocument.kt b/pdf/pdf-compose/src/androidTest/kotlin/androidx/pdf/compose/FakePdfDocument.kt index 3ecad232d46a5..5c104b27fcd76 100644 --- a/pdf/pdf-compose/src/androidTest/kotlin/androidx/pdf/compose/FakePdfDocument.kt +++ b/pdf/pdf-compose/src/androidTest/kotlin/androidx/pdf/compose/FakePdfDocument.kt @@ -31,6 +31,7 @@ import androidx.annotation.RequiresExtension import androidx.pdf.PdfDocument import androidx.pdf.RenderParams import androidx.pdf.annotation.KeyedPdfAnnotation +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.content.PageMatchBounds import androidx.pdf.content.PageSelection import androidx.pdf.content.PdfPageGotoLinkContent @@ -184,6 +185,10 @@ internal open class FakePdfDocument( return listOf() } + override suspend fun getTopPageObjectAtPosition(pageNum: Int, point: PointF): PdfObject? { + return null + } + override fun addOnPdfContentInvalidatedListener( executor: Executor, listener: PdfDocument.OnPdfContentInvalidatedListener, diff --git a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt index 889a449512a8e..f44a27741c584 100644 --- a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt +++ b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfDocument.kt @@ -35,6 +35,7 @@ import androidx.pdf.PdfDocument.Companion.INCLUDE_FORM_WIDGET_INFO import androidx.pdf.PdfDocument.DocumentClosedException import androidx.pdf.PdfDocument.PdfPageContent import androidx.pdf.annotation.KeyedPdfAnnotation +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.annotation.processor.BatchPdfAnnotationsProcessor import androidx.pdf.content.PageMatchBounds import androidx.pdf.content.PageSelection @@ -234,6 +235,13 @@ public class SandboxedPdfDocument( } } + @RequiresExtension(extension = Build.VERSION_CODES.S, version = 19) + override suspend fun getTopPageObjectAtPosition(pageNum: Int, point: PointF): PdfObject? { + return withDocument { document -> + document.getTopPageObjectAtPosition(pageNum, point, intArrayOf()) + } + } + override fun addOnPdfContentInvalidatedListener( executor: Executor, listener: PdfDocument.OnPdfContentInvalidatedListener, diff --git a/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/FakeEditablePdfDocument.kt b/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/FakeEditablePdfDocument.kt index cf4a2dad63e0c..a7aed6e54dc96 100644 --- a/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/FakeEditablePdfDocument.kt +++ b/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/FakeEditablePdfDocument.kt @@ -25,6 +25,7 @@ import androidx.pdf.annotation.models.PdfAnnotation import androidx.pdf.annotation.models.PdfAnnotationData import androidx.pdf.annotation.models.PdfEdit import androidx.pdf.annotation.models.PdfEditEntry +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.content.PageMatchBounds import androidx.pdf.content.PageSelection import androidx.pdf.models.FormEditInfo @@ -136,6 +137,10 @@ internal class FakeEditablePdfDocument( TODO("Not yet implemented") } + override suspend fun getTopPageObjectAtPosition(pageNum: Int, point: PointF): PdfObject? { + return null + } + override suspend fun applyEdit(record: FormEditInfo) { TODO("Not yet implemented") } diff --git a/pdf/pdf-ink/src/androidTest/kotlin/androidx/pdf/FakeEditablePdfDocument.kt b/pdf/pdf-ink/src/androidTest/kotlin/androidx/pdf/FakeEditablePdfDocument.kt index a94d1a795be50..167e0350b9748 100644 --- a/pdf/pdf-ink/src/androidTest/kotlin/androidx/pdf/FakeEditablePdfDocument.kt +++ b/pdf/pdf-ink/src/androidTest/kotlin/androidx/pdf/FakeEditablePdfDocument.kt @@ -31,6 +31,7 @@ import androidx.annotation.OpenForTesting import androidx.annotation.RequiresExtension import androidx.pdf.annotation.KeyedPdfAnnotation import androidx.pdf.annotation.models.PdfAnnotation +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.content.PageMatchBounds import androidx.pdf.content.PageSelection import androidx.pdf.content.PdfPageGotoLinkContent @@ -105,6 +106,10 @@ internal open class FakeEditablePdfDocument( return pageFormWidgetInfos[pageNum]?.filter { it.widgetType in types } ?: emptyList() } + override suspend fun getTopPageObjectAtPosition(pageNum: Int, point: PointF): PdfObject? { + return null + } + override suspend fun applyEdit(record: FormEditInfo) { return } diff --git a/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/document/FakePdfDocument.kt b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/document/FakePdfDocument.kt index 36d0bcbabb0dc..ab5838f57e692 100644 --- a/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/document/FakePdfDocument.kt +++ b/pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/document/FakePdfDocument.kt @@ -30,6 +30,7 @@ import androidx.annotation.RequiresExtension import androidx.pdf.PdfDocument import androidx.pdf.RenderParams import androidx.pdf.annotation.KeyedPdfAnnotation +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.content.PageMatchBounds import androidx.pdf.content.PageSelection import androidx.pdf.content.SelectionBoundary @@ -76,6 +77,10 @@ internal open class FakePdfDocument( return listOf() } + override suspend fun getTopPageObjectAtPosition(pageNum: Int, point: PointF): PdfObject? { + return null + } + override fun addOnPdfContentInvalidatedListener( executor: Executor, listener: PdfDocument.OnPdfContentInvalidatedListener, diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt index 24fc245072927..150631a388793 100644 --- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt +++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt @@ -31,6 +31,7 @@ import androidx.annotation.RequiresExtension import androidx.pdf.PdfDocument import androidx.pdf.RenderParams import androidx.pdf.annotation.KeyedPdfAnnotation +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.content.PageMatchBounds import androidx.pdf.content.PageSelection import androidx.pdf.content.PdfPageGotoLinkContent @@ -123,6 +124,10 @@ internal open class FakePdfDocument( return pageFormWidgetInfos[pageNum]?.filter { it.widgetType in types } ?: emptyList() } + override suspend fun getTopPageObjectAtPosition(pageNum: Int, point: PointF): PdfObject? { + return null + } + override fun addOnPdfContentInvalidatedListener( executor: Executor, listener: PdfDocument.OnPdfContentInvalidatedListener, diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/PdfDocument.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/PdfDocument.kt index eb9d2264cd7f7..643b33480ef07 100644 --- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/PdfDocument.kt +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/PdfDocument.kt @@ -25,6 +25,7 @@ import android.util.SparseArray import androidx.annotation.IntDef import androidx.annotation.RestrictTo import androidx.pdf.annotation.KeyedPdfAnnotation +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.content.PageMatchBounds import androidx.pdf.content.PageSelection import androidx.pdf.content.PdfPageGotoLinkContent @@ -198,6 +199,17 @@ public interface PdfDocument : Closeable { types: IntArray = intArrayOf(), ): List + /** + * Returns the topmost page object at a specific point on the page. + * + * @param pageNum The page number (0-based). + * @param point The point on the page to query. + * @return The topmost [PdfObject] at the specified point or returns null if no page object is + * present. + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + public suspend fun getTopPageObjectAtPosition(pageNum: Int, point: PointF): PdfObject? + /** * Listener interface for receiving notifications when some regions of the pdf content are * invalidated. diff --git a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/FakePdfDocument.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/FakePdfDocument.kt index e0c0e92784f0b..36c3a487c74c1 100644 --- a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/FakePdfDocument.kt +++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/FakePdfDocument.kt @@ -29,6 +29,7 @@ import android.util.SparseArray import androidx.annotation.OpenForTesting import androidx.annotation.RequiresExtension import androidx.pdf.annotation.KeyedPdfAnnotation +import androidx.pdf.annotation.models.PdfObject import androidx.pdf.content.PageMatchBounds import androidx.pdf.content.PageSelection import androidx.pdf.content.PdfPageGotoLinkContent @@ -101,6 +102,10 @@ internal open class FakePdfDocument( return pageFormWidgetInfos[pageNum]?.filter { it.widgetType in types } ?: emptyList() } + override suspend fun getTopPageObjectAtPosition(pageNum: Int, point: PointF): PdfObject? { + TODO("Not yet implemented") + } + override fun addOnPdfContentInvalidatedListener( executor: Executor, listener: PdfDocument.OnPdfContentInvalidatedListener, From 7d75ecc24320faa8b92fafe3699a1923842d1688 Mon Sep 17 00:00:00 2001 From: Aadish Goel Date: Mon, 5 Jan 2026 04:25:16 +0530 Subject: [PATCH 4/7] Convert AOSP PdfPageObject to Jetpack PdfObject This change implements the conversion from the AOSP `PdfPageObject` to the Jetpack `PdfObject` within `PdfDocumentRemoteImpl`. - The `getTopPageObjectAtPosition` function now converts the AOSP `PdfPageObject` and `PdfPageImageObject` into the corresponding Jetpack `ImagePdfObject`, `PdfObject` - The bounding box for `ImagePdfObject` is calculated from the transformation matrix of the `PdfPageImageObject`. Test: SandboxedPdfDocumentTest Bug: 448072992 Change-Id: I12ea3eb08dc30235bff0a1b59845f91b626251ef --- .../src/androidTest/assets/acro_js.pdf | Bin 0 -> 83697 bytes .../androidx/pdf/SandboxedPdfDocumentTest.kt | 90 ++++++++++++++++++ .../androidx/pdf/utils/AnnotationUtilsTest.kt | 6 +- .../pdf/service/PdfDocumentRemoteImpl.kt | 6 +- .../kotlin/androidx/pdf/utils/pdfObjects.kt | 53 +++++++++++ .../androidx/pdf/util/PdfObjectsTest.kt | 79 +++++++++++++++ 6 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 pdf/pdf-document-service/src/androidTest/assets/acro_js.pdf create mode 100644 pdf/pdf-document-service/src/main/kotlin/androidx/pdf/utils/pdfObjects.kt create mode 100644 pdf/pdf-document-service/src/test/kotlin/androidx/pdf/util/PdfObjectsTest.kt diff --git a/pdf/pdf-document-service/src/androidTest/assets/acro_js.pdf b/pdf/pdf-document-service/src/androidTest/assets/acro_js.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ff79dd0b82f1a285be0ce115297c48e49e605d49 GIT binary patch literal 83697 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 z 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 |oA*iiw}>bPt2dzl(Uy9}ZV8|oL*|@G|c%tw6 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 + val pageNumber = 0 + val point = PointF(500f, 500f) + + val topObject = document.getTopPageObjectAtPosition(pageNumber, point) + + assertNotNull(topObject) + assertThat(topObject is ImagePdfObject).isTrue() + + (topObject as ImagePdfObject).let { topObject -> + assertNotNull(topObject.bitmap) + assertThat(topObject.bitmap.byteCount).isEqualTo(14960000) + assertThat(topObject.bounds.top).isNotEqualTo(topObject.bounds.bottom) + assertThat(topObject.bounds.left).isNotEqualTo(topObject.bounds.right) + } + } + } + + @RequiresExtension(extension = Build.VERSION_CODES.S, version = 19) + @Test + fun getPageTopObject_validImageObject_fetchMediumImage() = runTest { + if (!isRequiredSdkExtensionAvailable(19)) return@runTest + + withDocument(PDF_DOCUMENT_WITH_IMAGE) { document -> + val pageNumber = 0 + val point = PointF(150f, 300f) + + val topObject = document.getTopPageObjectAtPosition(pageNumber, point) + assertNotNull(topObject) + assertThat(topObject is ImagePdfObject).isTrue() + + (topObject as ImagePdfObject).let { topObject -> + assertNotNull(topObject.bitmap) + assertThat(topObject.bitmap.byteCount).isEqualTo(1839280) + assertThat(topObject.bounds.top).isNotEqualTo(topObject.bounds.bottom) + assertThat(topObject.bounds.left).isNotEqualTo(topObject.bounds.right) + } + } + } + + @RequiresExtension(extension = Build.VERSION_CODES.S, version = 19) + @Test + fun getPageTopObject_validImageObject_fetchSmallImage() = runTest { + if (!isRequiredSdkExtensionAvailable(19)) return@runTest + + withDocument(PDF_DOCUMENT_WITH_LINKS) { document -> + val pageNumber = 0 + val point = PointF(60f, 50f) + + val topObject = document.getTopPageObjectAtPosition(pageNumber, point) + assertNotNull(topObject) + assertThat(topObject is ImagePdfObject).isTrue() + + (topObject as ImagePdfObject).let { topObject -> + assertNotNull(topObject.bitmap) + assertThat(topObject.bitmap.byteCount).isEqualTo(23800) + assertThat(topObject.bounds.top).isNotEqualTo(topObject.bounds.bottom) + assertThat(topObject.bounds.left).isNotEqualTo(topObject.bounds.right) + } + } + } + + @RequiresExtension(extension = Build.VERSION_CODES.S, version = 19) + @Test + fun getPageTopObject_validImageObject_notPresent() = runTest { + if (!isRequiredSdkExtensionAvailable(19)) return@runTest + + withDocument(PDF_DOCUMENT_WITH_LINKS) { document -> + val pageNumber = 0 + val point1 = PointF(500f, 500f) + + val topObject1 = document.getTopPageObjectAtPosition(pageNumber, point1) + assertNull(topObject1) + + val point2 = PointF(300f, 300f) + + val topObject2 = document.getTopPageObjectAtPosition(pageNumber, point2) + assertNull(topObject2) + } + } + @Test fun getPageContent_pageWithOnlyText_returnsPageContentWithText() = runTest { withDocument(PDF_DOCUMENT) { document -> @@ -542,6 +630,8 @@ class SandboxedPdfDocumentTest { private const val PDF_DOCUMENT_PARTIALLY_CORRUPTED_FILE = "partially_corrupted.pdf" private const val PDF_DOCUMENT_WITH_TEXT_AND_IMAGE = "alt_text.pdf" + private const val PDF_DOCUMENT_WITH_IMAGE = "acro_js.pdf" + internal suspend fun withDocument(filename: String, block: suspend (PdfDocument) -> Unit) { val document = openDocument(filename) try { diff --git a/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/utils/AnnotationUtilsTest.kt b/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/utils/AnnotationUtilsTest.kt index afeadf3bf1aad..aea7d1a60b9d0 100644 --- a/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/utils/AnnotationUtilsTest.kt +++ b/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/utils/AnnotationUtilsTest.kt @@ -92,10 +92,12 @@ class AnnotationUtilsTest { } } - fun isRequiredSdkExtensionAvailable(): Boolean { + fun isRequiredSdkExtensionAvailable( + extensionVersion: Int = REQUIRED_EXTENSION_VERSION + ): Boolean { // Get the device's version for the specified SDK extension val deviceExtensionVersion = SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) - return deviceExtensionVersion >= REQUIRED_EXTENSION_VERSION + return deviceExtensionVersion >= extensionVersion } private const val TEST_ANNOTATIONS_FILE = "annotationsTest.json" diff --git a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt index 2f7db3cbbefe5..049137b13e46a 100644 --- a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt +++ b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt @@ -47,6 +47,7 @@ import androidx.pdf.annotation.models.PdfObject import androidx.pdf.annotation.processor.PageAnnotationsPaginator import androidx.pdf.annotation.processor.PdfRendererAnnotationsProcessor import androidx.pdf.models.Dimensions +import androidx.pdf.utils.toPdfObject @RestrictTo(RestrictTo.Scope.LIBRARY) internal class PdfDocumentRemoteImpl( @@ -155,6 +156,7 @@ internal class PdfDocumentRemoteImpl( return rendererAdapter.withPage(pageNum) { page -> page.getFormWidgetInfos(types) } } + @RequiresExtension(extension = Build.VERSION_CODES.S, version = 19) override fun getTopPageObjectAtPosition( pageNum: Int, point: PointF, @@ -163,8 +165,8 @@ internal class PdfDocumentRemoteImpl( return rendererAdapter.withPage(pageNum) { page -> val topObjectResult = page.getTopPageObjectAtPosition(point, types) topObjectResult?.let { - // TODO: b/447328448 - Convert AOSP Image PdfPageObject to Jetpack Image PdfObject - return@withPage null + val convertedObject: PdfObject? = topObjectResult.second.toPdfObject() + return@withPage convertedObject } } } diff --git a/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/utils/pdfObjects.kt b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/utils/pdfObjects.kt new file mode 100644 index 0000000000000..49e784671751c --- /dev/null +++ b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/utils/pdfObjects.kt @@ -0,0 +1,53 @@ +/* + * 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.pdf.utils + +import android.graphics.Matrix +import android.graphics.RectF +import android.graphics.pdf.component.PdfPageImageObject +import android.graphics.pdf.component.PdfPageObject +import android.os.Build +import androidx.annotation.RequiresExtension +import androidx.pdf.annotation.models.ImagePdfObject +import androidx.pdf.annotation.models.PdfObject + +@RequiresExtension(extension = Build.VERSION_CODES.S, version = 18) +internal fun PdfPageObject.toPdfObject(): PdfObject? { + return when (this) { + is PdfPageImageObject -> { + this.toImagePdfObject() + } + else -> null + } +} + +@RequiresExtension(extension = Build.VERSION_CODES.S, version = 18) +internal fun PdfPageImageObject.toImagePdfObject(): ImagePdfObject { + val matrixArray = this.matrix + val androidMatrix = Matrix() + androidMatrix.setValues(matrixArray) + + // Define the unit rectangle (0,0 to 1,1) + val unitRect = RectF(0f, 0f, 1f, 1f) + val transformedBounds = RectF() + + // mapRect transforms the src rect (unitRect) by the matrix + // and stores the axis-aligned bounding box of the result in dst rect (transformedBounds). + androidMatrix.mapRect(transformedBounds, unitRect) + + return ImagePdfObject(this.bitmap, transformedBounds) +} diff --git a/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/util/PdfObjectsTest.kt b/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/util/PdfObjectsTest.kt new file mode 100644 index 0000000000000..c210b9a79b224 --- /dev/null +++ b/pdf/pdf-document-service/src/test/kotlin/androidx/pdf/util/PdfObjectsTest.kt @@ -0,0 +1,79 @@ +/* + * 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.pdf.utils + +import android.graphics.Bitmap +import android.graphics.RectF +import android.graphics.pdf.component.PdfPageImageObject +import android.os.Build +import androidx.annotation.RequiresExtension +import androidx.pdf.annotation.models.ImagePdfObject +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [org.robolectric.annotation.Config.TARGET_SDK]) +class PdfObjectsTest { + + @RequiresExtension(extension = Build.VERSION_CODES.S, version = 18) + @Test + fun toPdfObject_withImageObject_returnsImagePdfObject() { + + // mocking bitmap so we don't have to create one + val expectedBitmap = mock() + + // Identity matrix translated by (10, 20) + val matrixValues = floatArrayOf(1f, 0f, 10f, 0f, 1f, 20f, 0f, 0f, 1f) + + val mockImageObject = mock() + whenever(mockImageObject.matrix).thenReturn(matrixValues) + whenever(mockImageObject.bitmap).thenReturn(expectedBitmap) + + val result = mockImageObject.toPdfObject() + + assertThat(result is ImagePdfObject).isTrue() + (result as ImagePdfObject).let { imagePdfObject -> + + // assert that bitmap is same + assertThat(imagePdfObject.bitmap).isEqualTo(expectedBitmap) + + // assert bounds are converted correctly + // Unit rect (0,0,1,1) translated by (10, 20) -> (10, 20, 11, 21) + assertThat(imagePdfObject.bounds).isEqualTo(RectF(10f, 20f, 11f, 21f)) + } + } + + @RequiresExtension(extension = Build.VERSION_CODES.S, version = 18) + @Test + fun toImagePdfObject_transformsBoundsCorrectly() { + // Scaling matrix: 100x200 + val matrixValues = floatArrayOf(100f, 0f, 0f, 0f, 200f, 0f, 0f, 0f, 1f) + val expectedBitmap = mock() + val expectedImageObject = mock() + whenever(expectedImageObject.matrix).thenReturn(matrixValues) + whenever(expectedImageObject.bitmap).thenReturn(expectedBitmap) + + val result = expectedImageObject.toImagePdfObject() + + assertThat(result.bounds).isEqualTo(RectF(0f, 0f, 100f, 200f)) + assertThat(result.bitmap).isEqualTo(expectedBitmap) + } +} From aa119ed1dfabc55ea34d63a1a13b62d00045c128 Mon Sep 17 00:00:00 2001 From: leo huang Date: Fri, 9 Jan 2026 14:26:29 +0800 Subject: [PATCH 5/7] Refactor: Rename *_AUTO to *_UNSPECIFIED in MediaSpec, VideoSpec, and AudioSpec The 'AUTO' naming was ambiguous in cases where the system needed to distinguish between "automatically selected" and "no preference provided." Transitioning to 'UNSPECIFIED' improves semantic clarity for configurations where no specific requirement is set, allowing the resolution engine to handle defaults more predictably. Key changes: - Renamed QUALITY_SELECTOR_DEFAULT to QUALITY_SELECTOR_UNSPECIFIED in VideoSpec and initialized it with a new QualitySelector.NONE constant. - Moved DEFAULT spec instances from MediaSpec companion object to their respective classes (VideoSpec.DEFAULT and AudioSpec.DEFAULT) for better encapsulation. - Updated Recorder.DEFAULT_QUALITY_SELECTOR to explicitly define its preferred quality list (FHD, HD, SD). - Removed unused AudioSpec.NO_AUDIO. Bug: 473996668 Test: ./gradlew camera:camera-video:testRelease Change-Id: Ifc908a042f4728b46705fe3469bc2db241a5175f --- .../androidx/camera/video/MediaSpecTest.kt | 14 +- .../camera/video/VideoCaptureDeviceTest.kt | 25 ++- .../java/androidx/camera/video/AudioSpec.kt | 74 ++++----- .../java/androidx/camera/video/MediaSpec.kt | 21 +-- .../camera/video/QualitySelector.java | 12 +- .../java/androidx/camera/video/Recorder.java | 21 ++- .../java/androidx/camera/video/VideoSpec.kt | 40 +++-- .../MimeMatchedEncoderProfilesProvider.kt | 15 +- .../video/internal/config/AudioConfigUtil.kt | 8 +- .../AudioEncoderConfigAudioProfileResolver.kt | 2 +- .../AudioEncoderConfigDefaultResolver.kt | 2 +- .../AudioSettingsAudioProfileResolver.kt | 4 +- .../config/AudioSettingsDefaultResolver.kt | 4 +- .../internal/config/FormatComboRegistry.kt | 14 +- .../video/internal/config/VideoConfigUtil.kt | 14 +- .../VideoEncoderConfigDefaultResolver.kt | 2 +- .../VideoEncoderConfigVideoProfileResolver.kt | 2 +- .../androidx/camera/video/AudioSpecTest.kt | 12 +- .../camera/video/QualitySelectorTest.kt | 13 ++ .../androidx/camera/video/VideoCaptureTest.kt | 149 ++++++++---------- .../androidx/camera/video/VideoSpecTest.kt | 8 +- .../config/FormatComboRegistryTest.kt | 18 ++- .../internal/config/VideoConfigUtilTest.kt | 8 +- 23 files changed, 241 insertions(+), 241 deletions(-) diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/MediaSpecTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/MediaSpecTest.kt index 09bc68c32274f..783ec1ccfb2c1 100644 --- a/camera/camera-video/src/androidTest/java/androidx/camera/video/MediaSpecTest.kt +++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/MediaSpecTest.kt @@ -42,7 +42,7 @@ class MediaSpecTest { val defaultVideoSpec = VideoSpec.builder().build() assertThat(mediaSpec.audioSpec).isEqualTo(defaultAudioSpec) assertThat(mediaSpec.videoSpec).isEqualTo(defaultVideoSpec) - assertThat(mediaSpec.outputFormat).isEqualTo(MediaSpec.OUTPUT_FORMAT_AUTO) + assertThat(mediaSpec.outputFormat).isEqualTo(MediaSpec.OUTPUT_FORMAT_UNSPECIFIED) } @Test @@ -73,16 +73,4 @@ class MediaSpecTest { assertThat(mediaSpec.audioSpec.channelCount).isEqualTo(AudioSpec.CHANNEL_COUNT_STEREO) } - - @Test - fun settingAudioSpecToNO_AUDIO_hasCHANNEL_COUNT_NONE() { - // Skip for b/264902324 - assumeFalse( - "Emulator API 30 crashes running this test.", - Build.VERSION.SDK_INT == 30 && isEmulator(), - ) - val mediaSpec = MediaSpec.builder().setAudioSpec(AudioSpec.NO_AUDIO).build() - - assertThat(mediaSpec.audioSpec.channelCount).isEqualTo(AudioSpec.CHANNEL_COUNT_NONE) - } } diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureDeviceTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureDeviceTest.kt index 80ec147690557..7a003f20acbc0 100644 --- a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureDeviceTest.kt +++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureDeviceTest.kt @@ -41,6 +41,9 @@ import androidx.camera.testing.impl.CameraUtil import androidx.camera.testing.impl.CameraXUtil import androidx.camera.testing.impl.GLUtil import androidx.camera.testing.impl.fakes.FakeVideoEncoderInfo +import androidx.camera.video.Quality.FHD +import androidx.camera.video.Quality.HD +import androidx.camera.video.Quality.SD import androidx.camera.video.VideoOutput.SourceState import androidx.concurrent.futures.await import androidx.test.core.app.ApplicationProvider @@ -112,6 +115,12 @@ class VideoCaptureDeviceTest( arrayOf(Camera2Config::class.simpleName, Camera2Config.defaultConfig()), arrayOf(CameraPipeConfig::class.simpleName, CameraPipeConfig.defaultConfig()), ) + + private val DEFAULT_QUALITY_SELECTOR = + QualitySelector.fromOrderedList( + listOf(FHD, HD, SD), + FallbackStrategy.higherQualityOrLowerThan(FHD), + ) } private val context: Context = ApplicationProvider.getApplicationContext() @@ -219,11 +228,7 @@ class VideoCaptureDeviceTest( val videoOutput = createTestVideoOutput( mediaSpec = - MediaSpec.builder() - .configureVideo { - it.setQualitySelector(QualitySelector.from(quality)) - } - .build(), + createMediaSpec(qualitySelector = QualitySelector.from(quality)), videoCapabilities = videoCapabilities, ) @@ -469,12 +474,20 @@ class VideoCaptureDeviceTest( private fun createTestVideoOutput( streamInfo: StreamInfo = StreamInfo.of(StreamInfo.STREAM_ID_ANY, StreamInfo.StreamState.ACTIVE), - mediaSpec: MediaSpec = MediaSpec.builder().build(), + mediaSpec: MediaSpec = createMediaSpec(), videoCapabilities: VideoCapabilities = Recorder.getVideoCapabilities(cameraInfo), ): TestVideoOutput { return TestVideoOutput(streamInfo, mediaSpec, videoCapabilities) } + private fun createMediaSpec( + qualitySelector: QualitySelector = DEFAULT_QUALITY_SELECTOR + ): MediaSpec { + return MediaSpec.builder() + .configureVideo { config -> config.setQualitySelector(qualitySelector) } + .build() + } + private class TestVideoOutput( streamInfo: StreamInfo, mediaSpec: MediaSpec, diff --git a/camera/camera-video/src/main/java/androidx/camera/video/AudioSpec.kt b/camera/camera-video/src/main/java/androidx/camera/video/AudioSpec.kt index 44182affabc13..bd007388b085e 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/AudioSpec.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/AudioSpec.kt @@ -28,12 +28,12 @@ import java.util.Objects public class AudioSpec @JvmOverloads constructor( - public val bitrate: Int = BITRATE_AUTO, - @get:SourceFormat public val sourceFormat: Int = SOURCE_FORMAT_AUTO, - @get:Source public val source: Int = SOURCE_AUTO, - public val sampleRate: Int = SAMPLE_RATE_AUTO, - @get:ChannelCount public val channelCount: Int = CHANNEL_COUNT_AUTO, - public val mimeType: String = MIME_TYPE_AUTO, + public val bitrate: Int = BITRATE_UNSPECIFIED, + @get:SourceFormat public val sourceFormat: Int = SOURCE_FORMAT_UNSPECIFIED, + @get:Source public val source: Int = SOURCE_UNSPECIFIED, + public val sampleRate: Int = SAMPLE_RATE_UNSPECIFIED, + @get:ChannelCount public val channelCount: Int = CHANNEL_COUNT_UNSPECIFIED, + public val mimeType: String = MIME_TYPE_UNSPECIFIED, ) { /** Returns a [Builder] instance with the same property values as this instance. */ public fun toBuilder(): Builder { @@ -75,17 +75,17 @@ constructor( /** The builder of the [AudioSpec]. */ @RestrictTo(Scope.LIBRARY) public class Builder { - private var bitrate: Int = BITRATE_AUTO - private var sourceFormat: Int = SOURCE_FORMAT_AUTO - private var source: Int = SOURCE_AUTO - private var sampleRate: Int = SAMPLE_RATE_AUTO - private var channelCount: Int = CHANNEL_COUNT_AUTO - private var mimeType: String = MIME_TYPE_AUTO + private var bitrate: Int = BITRATE_UNSPECIFIED + private var sourceFormat: Int = SOURCE_FORMAT_UNSPECIFIED + private var source: Int = SOURCE_UNSPECIFIED + private var sampleRate: Int = SAMPLE_RATE_UNSPECIFIED + private var channelCount: Int = CHANNEL_COUNT_UNSPECIFIED + private var mimeType: String = MIME_TYPE_UNSPECIFIED /** * Sets the desired bitrate to be used by the encoder. * - * If not set, defaults to [BITRATE_AUTO]. + * If not set, defaults to [BITRATE_UNSPECIFIED]. */ public fun setBitrate(bitrate: Int): Builder { this.bitrate = bitrate @@ -95,10 +95,10 @@ constructor( /** * Sets the audio source format. * - * Available values for source format are [SOURCE_FORMAT_AUTO] and + * Available values for source format are [SOURCE_FORMAT_UNSPECIFIED] and * [SOURCE_FORMAT_PCM_16BIT]. * - * If not set, defaults to [SOURCE_FORMAT_AUTO]. + * If not set, defaults to [SOURCE_FORMAT_UNSPECIFIED]. */ public fun setSourceFormat(@SourceFormat audioFormat: Int): Builder { this.sourceFormat = audioFormat @@ -108,23 +108,23 @@ constructor( /** * Sets the audio source. * - * Available values for source are [SOURCE_AUTO] and [SOURCE_CAMCORDER]. + * Available values for source are [SOURCE_UNSPECIFIED] and [SOURCE_CAMCORDER]. * - * If not set, defaults to [SOURCE_AUTO]. + * If not set, defaults to [SOURCE_UNSPECIFIED]. */ public fun setSource(@Source source: Int): Builder = apply { this.source = source } /** * Sets the desired sample rate to be used by the encoder. * - * If not set, defaults to [SAMPLE_RATE_AUTO]. + * If not set, defaults to [SAMPLE_RATE_UNSPECIFIED]. */ public fun setSampleRate(sampleRate: Int): Builder = apply { this.sampleRate = sampleRate } /** * Sets the desired number of audio channels. * - * If not set, defaults to [CHANNEL_COUNT_AUTO]. Other common channel counts include + * If not set, defaults to [CHANNEL_COUNT_UNSPECIFIED]. Other common channel counts include * [CHANNEL_COUNT_MONO] or [CHANNEL_COUNT_STEREO]. * * Setting to [CHANNEL_COUNT_NONE] is equivalent to requesting that no audio should be @@ -137,7 +137,7 @@ constructor( /** * Sets the desired MIME type to be used by the encoder. * - * If not set, defaults to [MIME_TYPE_AUTO]. + * If not set, defaults to [MIME_TYPE_UNSPECIFIED]. */ public fun setMimeType(mimeType: String): Builder = apply { this.mimeType = mimeType } @@ -149,20 +149,26 @@ constructor( @RestrictTo(Scope.LIBRARY) @Retention(AnnotationRetention.SOURCE) - @IntDef(SOURCE_FORMAT_AUTO, SOURCE_FORMAT_PCM_16BIT) + @IntDef(SOURCE_FORMAT_UNSPECIFIED, SOURCE_FORMAT_PCM_16BIT) public annotation class SourceFormat @RestrictTo(Scope.LIBRARY) @Retention(AnnotationRetention.SOURCE) @IntDef( open = true, - value = [CHANNEL_COUNT_AUTO, CHANNEL_COUNT_NONE, CHANNEL_COUNT_MONO, CHANNEL_COUNT_STEREO], + value = + [ + CHANNEL_COUNT_UNSPECIFIED, + CHANNEL_COUNT_NONE, + CHANNEL_COUNT_MONO, + CHANNEL_COUNT_STEREO, + ], ) public annotation class ChannelCount @RestrictTo(Scope.LIBRARY) @IntDef( - SOURCE_AUTO, + SOURCE_UNSPECIFIED, SOURCE_CAMCORDER, SOURCE_DEFAULT, SOURCE_MIC, @@ -176,7 +182,7 @@ constructor( public companion object { /** The audio source format representing no preference for audio source format. */ - public const val SOURCE_FORMAT_AUTO: Int = -1 + public const val SOURCE_FORMAT_UNSPECIFIED: Int = -1 /** * The PCM 16 bit per sample audio source format. Guaranteed to be supported by all devices. @@ -184,7 +190,7 @@ constructor( public const val SOURCE_FORMAT_PCM_16BIT: Int = AudioFormat.ENCODING_PCM_16BIT /** Allows the audio source to choose the appropriate number of channels. */ - public const val CHANNEL_COUNT_AUTO: Int = -1 + public const val CHANNEL_COUNT_UNSPECIFIED: Int = -1 /** A channel count which is equivalent to no audio. */ public const val CHANNEL_COUNT_NONE: Int = 0 @@ -196,7 +202,7 @@ constructor( public const val CHANNEL_COUNT_STEREO: Int = 2 /** The audio source representing no preference for audio source. */ - public const val SOURCE_AUTO: Int = -1 + public const val SOURCE_UNSPECIFIED: Int = -1 /** * Microphone audio source tuned for video recording, with the same orientation as the @@ -259,7 +265,7 @@ constructor( * Using this value with [AudioSpec.Builder.setBitrate] informs the device it should choose * any appropriate bitrate given the device and codec constraints. */ - public const val BITRATE_AUTO: Int = 0 + public const val BITRATE_UNSPECIFIED: Int = 0 /** * No preference for sample rate. @@ -267,19 +273,13 @@ constructor( * Using this value with [AudioSpec.Builder.setSampleRate] informs the device it should * choose any appropriate sample rate given the device and codec constraints. */ - public const val SAMPLE_RATE_AUTO: Int = 0 + public const val SAMPLE_RATE_UNSPECIFIED: Int = 0 /** No preference for MIME type. */ - public const val MIME_TYPE_AUTO: String = "audio/*" + public const val MIME_TYPE_UNSPECIFIED: String = "audio/*" - /** - * An audio specification that corresponds to no audio. - * - * This is equivalent to creating an [AudioSpec] with channel count set to - * [CHANNEL_COUNT_NONE]. - */ - @JvmField - public val NO_AUDIO: AudioSpec = Builder().setChannelCount(CHANNEL_COUNT_NONE).build() + /** An [AudioSpec] representing the default audio configuration. */ + public val DEFAULT: AudioSpec = builder().build() /** Returns a build for this config. */ @RestrictTo(Scope.LIBRARY) diff --git a/camera/camera-video/src/main/java/androidx/camera/video/MediaSpec.kt b/camera/camera-video/src/main/java/androidx/camera/video/MediaSpec.kt index 186b17dc00ef0..dd42132883dff 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/MediaSpec.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/MediaSpec.kt @@ -33,9 +33,9 @@ import java.util.Objects public class MediaSpec @JvmOverloads public constructor( - public val videoSpec: VideoSpec = VIDEO_SPEC_AUTO, - public val audioSpec: AudioSpec = AUDIO_SPEC_AUTO, - @get:OutputFormat public val outputFormat: Int, + public val videoSpec: VideoSpec = VideoSpec.DEFAULT, + public val audioSpec: AudioSpec = AudioSpec.DEFAULT, + @get:OutputFormat public val outputFormat: Int = OUTPUT_FORMAT_UNSPECIFIED, ) { /** Returns a [Builder] instance with the same property values as this instance. */ @@ -69,9 +69,9 @@ public constructor( /** The builder for [MediaSpec]. */ @RestrictTo(Scope.LIBRARY) public class Builder { - private var audioSpec: AudioSpec = AUDIO_SPEC_AUTO - private var videoSpec: VideoSpec = VIDEO_SPEC_AUTO - @OutputFormat private var outputFormat: Int = OUTPUT_FORMAT_AUTO + private var audioSpec: AudioSpec = AudioSpec.DEFAULT + private var videoSpec: VideoSpec = VideoSpec.DEFAULT + @OutputFormat private var outputFormat: Int = OUTPUT_FORMAT_UNSPECIFIED /** Sets the audio-related configuration. */ public fun setAudioSpec(audioSpec: AudioSpec): Builder = apply { @@ -106,17 +106,12 @@ public constructor( } } - @IntDef(OUTPUT_FORMAT_AUTO, OUTPUT_FORMAT_MPEG_4, OUTPUT_FORMAT_WEBM) + @IntDef(OUTPUT_FORMAT_UNSPECIFIED, OUTPUT_FORMAT_MPEG_4, OUTPUT_FORMAT_WEBM) @Retention(AnnotationRetention.SOURCE) @RestrictTo(Scope.LIBRARY) public annotation class OutputFormat public companion object { - - @JvmField public val VIDEO_SPEC_AUTO: VideoSpec = VideoSpec.builder().build() - - @JvmField public val AUDIO_SPEC_AUTO: AudioSpec = AudioSpec.builder().build() - private const val AUDIO_ENCODER_MIME_MPEG4_DEFAULT = MediaFormat.MIMETYPE_AUDIO_AAC private const val AUDIO_ENCODER_MIME_WEBM_DEFAULT = MediaFormat.MIMETYPE_AUDIO_VORBIS private const val VIDEO_ENCODER_MIME_MPEG4_DEFAULT = MediaFormat.MIMETYPE_VIDEO_AVC @@ -124,7 +119,7 @@ public constructor( private const val AAC_DEFAULT_PROFILE = MediaCodecInfo.CodecProfileLevel.AACObjectLC /** The output format representing no preference. */ - public const val OUTPUT_FORMAT_AUTO: Int = -1 + public const val OUTPUT_FORMAT_UNSPECIFIED: Int = -1 /** MPEG4 media file format. */ public const val OUTPUT_FORMAT_MPEG_4: Int = 0 /** VP8, VP9 media file format */ diff --git a/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java b/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java index 4653f0b27e045..8b21a23ef00bd 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java +++ b/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java @@ -83,6 +83,15 @@ public final class QualitySelector { private static final String TAG = "QualitySelector"; + /** + * A QualitySelector that contains no preferred qualities and no fallback strategy. + * When used, the resolution engine will have to rely entirely on system defaults + * or other specification components (like AspectRatio). + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + public static final @NonNull QualitySelector NONE = + new QualitySelector(Collections.emptyList(), FallbackStrategy.NONE); + /** * Gets all supported qualities on the device. * @@ -173,9 +182,6 @@ public static boolean isQualitySupported(@NonNull CameraInfo cameraInfo, QualitySelector(@NonNull List preferredQualityList, @NonNull FallbackStrategy fallbackStrategy) { - Preconditions.checkArgument( - !preferredQualityList.isEmpty() || fallbackStrategy != FallbackStrategy.NONE, - "No preferred quality and fallback strategy."); mPreferredQualityList = Collections.unmodifiableList(new ArrayList<>(preferredQualityList)); mFallbackStrategy = fallbackStrategy; } 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 39fe3c27db514..7244036e96b16 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 @@ -42,6 +42,7 @@ import static androidx.core.util.Preconditions.checkNotNull; import static java.lang.annotation.RetentionPolicy.SOURCE; +import static java.util.Arrays.asList; import android.Manifest; import android.annotation.SuppressLint; @@ -336,7 +337,11 @@ enum AudioState { * * @see QualitySelector */ - public static final QualitySelector DEFAULT_QUALITY_SELECTOR = VideoSpec.QUALITY_SELECTOR_AUTO; + public static final QualitySelector DEFAULT_QUALITY_SELECTOR = + QualitySelector.fromOrderedList( + asList(Quality.FHD, Quality.HD, Quality.SD), + FallbackStrategy.higherQualityOrLowerThan(Quality.FHD) + ); private static final VideoSpec VIDEO_SPEC_DEFAULT = VideoSpec.builder() @@ -345,7 +350,7 @@ enum AudioState { .build(); private static final MediaSpec MEDIA_SPEC_DEFAULT = MediaSpec.builder() - .setOutputFormat(MediaSpec.OUTPUT_FORMAT_AUTO) + .setOutputFormat(MediaSpec.OUTPUT_FORMAT_UNSPECIFIED) .setVideoSpec(VIDEO_SPEC_DEFAULT) .build(); @SuppressWarnings({"deprecation", "RedundantSuppression"}) @@ -735,8 +740,8 @@ public int getVideoCapabilitiesSource() { * Gets the audio source of this Recorder. * * @return the value provided to {@link Builder#setAudioSource(int)} on the builder used to - * create this recorder, or the default value of {@link AudioSpec#SOURCE_AUTO} if no source was - * set. + * create this recorder, or the default value of {@link AudioSpec#SOURCE_UNSPECIFIED} if no + * source was set. */ @RestrictTo(RestrictTo.Scope.LIBRARY) @AudioSpec.Source @@ -1700,7 +1705,7 @@ void setupAndStartMuxer(@NonNull RecordingRecord recordingToStart) { try { MediaSpec mediaSpec = getObservableData(mMediaSpec); int muxerOutputFormat = - mediaSpec.getOutputFormat() == MediaSpec.OUTPUT_FORMAT_AUTO + mediaSpec.getOutputFormat() == MediaSpec.OUTPUT_FORMAT_UNSPECIFIED ? supportedMuxerFormatOrDefaultFrom(mResolvedEncoderProfiles, MediaSpec.outputFormatToMuxerFormat( MEDIA_SPEC_DEFAULT.getOutputFormat())) @@ -3631,7 +3636,7 @@ public static final class Builder { * options. */ public Builder() { - mMediaSpecBuilder = MediaSpec.builder(); + mMediaSpecBuilder = MEDIA_SPEC_DEFAULT.toBuilder(); } /** @@ -3791,9 +3796,9 @@ public Builder() { * enabled on a per-recording basis with {@link PendingRecording#withAudioEnabled()} * before starting the recording. * - * @param source The audio source to use. One of {@link AudioSpec#SOURCE_AUTO} or + * @param source The audio source to use. One of {@link AudioSpec#SOURCE_UNSPECIFIED} or * {@link AudioSpec#SOURCE_CAMCORDER}. Default is - * {@link AudioSpec#SOURCE_AUTO}. + * {@link AudioSpec#SOURCE_UNSPECIFIED}. */ @RestrictTo(RestrictTo.Scope.LIBRARY) public @NonNull Builder setAudioSource(@AudioSpec.Source int source) { diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoSpec.kt b/camera/camera-video/src/main/java/androidx/camera/video/VideoSpec.kt index 31f84a35a5062..82b298e59d208 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/VideoSpec.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoSpec.kt @@ -25,11 +25,11 @@ import java.util.Objects public class VideoSpec @JvmOverloads public constructor( - public val qualitySelector: QualitySelector = QUALITY_SELECTOR_AUTO, - public val encodeFrameRate: Int = ENCODE_FRAME_RATE_AUTO, - public val bitrate: Int = BITRATE_AUTO, + public val qualitySelector: QualitySelector = QUALITY_SELECTOR_UNSPECIFIED, + public val encodeFrameRate: Int = ENCODE_FRAME_RATE_UNSPECIFIED, + public val bitrate: Int = BITRATE_UNSPECIFIED, @get:AspectRatio.Ratio public val aspectRatio: Int = AspectRatio.RATIO_DEFAULT, - public val mimeType: String = MIME_TYPE_AUTO, + public val mimeType: String = MIME_TYPE_UNSPECIFIED, ) { /** Returns a [Builder] instance with the same property values as this instance. */ @@ -69,16 +69,16 @@ public constructor( /** The builder of the [VideoSpec]. */ @RestrictTo(Scope.LIBRARY) public class Builder { - private var qualitySelector: QualitySelector = QUALITY_SELECTOR_AUTO - private var encodeFrameRate: Int = ENCODE_FRAME_RATE_AUTO - private var bitrate: Int = BITRATE_AUTO + private var qualitySelector: QualitySelector = QUALITY_SELECTOR_UNSPECIFIED + private var encodeFrameRate: Int = ENCODE_FRAME_RATE_UNSPECIFIED + private var bitrate: Int = BITRATE_UNSPECIFIED private var aspectRatio: Int = AspectRatio.RATIO_DEFAULT - private var mimeType: String = MIME_TYPE_AUTO + private var mimeType: String = MIME_TYPE_UNSPECIFIED /** * Sets the [QualitySelector]. * - * If not set, defaults to [QUALITY_SELECTOR_AUTO]. + * If not set, defaults to [QUALITY_SELECTOR_UNSPECIFIED]. */ public fun setQualitySelector(qualitySelector: QualitySelector): Builder = apply { this.qualitySelector = qualitySelector @@ -87,7 +87,7 @@ public constructor( /** * Sets the encode frame rate. * - * If not set, defaults to [ENCODE_FRAME_RATE_AUTO]. + * If not set, defaults to [ENCODE_FRAME_RATE_UNSPECIFIED]. */ public fun setEncodeFrameRate(frameRate: Int): Builder = apply { this.encodeFrameRate = frameRate @@ -96,7 +96,7 @@ public constructor( /** * Sets the bitrate. * - * If not set, defaults to [BITRATE_AUTO]. + * If not set, defaults to [BITRATE_UNSPECIFIED]. */ public fun setBitrate(bitrate: Int): Builder = apply { this.bitrate = bitrate } @@ -112,7 +112,7 @@ public constructor( /** * Sets the MIME type. * - * If not set, defaults to [MIME_TYPE_AUTO]. + * If not set, defaults to [MIME_TYPE_UNSPECIFIED]. */ public fun setMimeType(mimeType: String): Builder = apply { this.mimeType = mimeType } @@ -124,21 +124,19 @@ public constructor( public companion object { /** Frame rate representing no preference for encode frame rate. */ - public const val ENCODE_FRAME_RATE_AUTO: Int = 0 + public const val ENCODE_FRAME_RATE_UNSPECIFIED: Int = 0 /** No preference for bitrate. */ - public const val BITRATE_AUTO: Int = 0 + public const val BITRATE_UNSPECIFIED: Int = 0 /** No preference for MIME type. */ - public const val MIME_TYPE_AUTO: String = "video/*" + public const val MIME_TYPE_UNSPECIFIED: String = "video/*" /** Quality selector representing no preference for quality. */ - @JvmField - public val QUALITY_SELECTOR_AUTO: QualitySelector = - QualitySelector.fromOrderedList( - listOf(Quality.FHD, Quality.HD, Quality.SD), - FallbackStrategy.higherQualityOrLowerThan(Quality.FHD), - ) + public val QUALITY_SELECTOR_UNSPECIFIED: QualitySelector = QualitySelector.NONE + + /** A [VideoSpec] representing the default video configuration. */ + public val DEFAULT: VideoSpec = builder().build() /** Returns a build for this config. */ @RestrictTo(Scope.LIBRARY) @JvmStatic public fun builder(): Builder = Builder() diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/MimeMatchedEncoderProfilesProvider.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/MimeMatchedEncoderProfilesProvider.kt index 8dbb2f1f8ceca..28c23c2946203 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/MimeMatchedEncoderProfilesProvider.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/MimeMatchedEncoderProfilesProvider.kt @@ -24,8 +24,8 @@ import androidx.camera.video.VideoSpec /** An [EncoderProfilesProvider] that filters profiles by a specific MIME type. */ internal class MimeMatchedEncoderProfilesProvider( private val baseProvider: EncoderProfilesProvider, - private val videoMime: String = VideoSpec.MIME_TYPE_AUTO, - private val audioMime: String = AudioSpec.MIME_TYPE_AUTO, + private val videoMime: String = VideoSpec.MIME_TYPE_UNSPECIFIED, + private val audioMime: String = AudioSpec.MIME_TYPE_UNSPECIFIED, ) : EncoderProfilesProvider { private val profilesCache = mutableMapOf() @@ -41,19 +41,22 @@ internal class MimeMatchedEncoderProfilesProvider( } private fun filterProfiles(profiles: EncoderProfilesProxy): EncoderProfilesProxy? { - // If both are AUTO, return original to avoid unnecessary object creation - if (videoMime == VideoSpec.MIME_TYPE_AUTO && audioMime == AudioSpec.MIME_TYPE_AUTO) { + // If both are UNSPECIFIED, return original to avoid unnecessary object creation + if ( + videoMime == VideoSpec.MIME_TYPE_UNSPECIFIED && + audioMime == AudioSpec.MIME_TYPE_UNSPECIFIED + ) { return profiles } val matchedVideo = profiles.videoProfiles.filter { - videoMime == VideoSpec.MIME_TYPE_AUTO || it.mediaType == videoMime + videoMime == VideoSpec.MIME_TYPE_UNSPECIFIED || it.mediaType == videoMime } val matchedAudio = profiles.audioProfiles.filter { - audioMime == AudioSpec.MIME_TYPE_AUTO || it.mediaType == audioMime + audioMime == AudioSpec.MIME_TYPE_UNSPECIFIED || it.mediaType == audioMime } // For optimization, if the filtered video and audio profiles remain unchanged, the diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioConfigUtil.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioConfigUtil.kt index e426af76e00ca..8f55763292165 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioConfigUtil.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioConfigUtil.kt @@ -72,13 +72,13 @@ public object AudioConfigUtil { "used. May rely on fallback defaults to derive settings [chosen mime " + "type: $resolvedAudioMime(profile: $resolvedAudioProfile)]", ) - } else if (mediaSpec.outputFormat == MediaSpec.OUTPUT_FORMAT_AUTO) { + } else if (mediaSpec.outputFormat == MediaSpec.OUTPUT_FORMAT_UNSPECIFIED) { compatibleAudioProfile = audioProfile resolvedAudioMime = encoderProfileAudioMime resolvedAudioProfile = encoderProfileAudioProfile Logger.d( TAG, - "MediaSpec contains OUTPUT_FORMAT_AUTO. Using EncoderProfiles " + + "MediaSpec contains OUTPUT_FORMAT_UNSPECIFIED. Using EncoderProfiles " + "to derive AUDIO settings [mime type: $resolvedAudioMime(profile: " + "$resolvedAudioProfile)]", ) @@ -182,7 +182,7 @@ public object AudioConfigUtil { public fun resolveAudioSource(audioSpec: AudioSpec): Int { var resolvedAudioSource = audioSpec.source - if (resolvedAudioSource == AudioSpec.SOURCE_AUTO) { + if (resolvedAudioSource == AudioSpec.SOURCE_UNSPECIFIED) { resolvedAudioSource = AUDIO_SOURCE_DEFAULT Logger.d(TAG, "Using default AUDIO source: $resolvedAudioSource") } else { @@ -193,7 +193,7 @@ public object AudioConfigUtil { public fun resolveAudioSourceFormat(audioSpec: AudioSpec): Int { var resolvedAudioSourceFormat = audioSpec.sourceFormat - if (resolvedAudioSourceFormat == AudioSpec.SOURCE_FORMAT_AUTO) { + if (resolvedAudioSourceFormat == AudioSpec.SOURCE_FORMAT_UNSPECIFIED) { // TODO: This should come from a priority list and may need to be combined with // AudioSource.isSettingsSupported. resolvedAudioSourceFormat = AUDIO_SOURCE_FORMAT_DEFAULT diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioEncoderConfigAudioProfileResolver.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioEncoderConfigAudioProfileResolver.kt index 712b024af53e2..a0a1bb30511ee 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioEncoderConfigAudioProfileResolver.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioEncoderConfigAudioProfileResolver.kt @@ -55,7 +55,7 @@ public constructor( override fun get(): AudioEncoderConfig { val audioSpecBitrate = audioSpec.bitrate val resolvedBitrate: Int = - if (audioSpecBitrate != AudioSpec.BITRATE_AUTO) { + if (audioSpecBitrate != AudioSpec.BITRATE_UNSPECIFIED) { audioSpecBitrate } else { Logger.d(TAG, "Using resolved AUDIO bitrate from AudioProfile") diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioEncoderConfigDefaultResolver.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioEncoderConfigDefaultResolver.kt index f409fa0625a45..693496c194a6a 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioEncoderConfigDefaultResolver.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioEncoderConfigDefaultResolver.kt @@ -59,7 +59,7 @@ public constructor( override fun get(): AudioEncoderConfig { val audioSpecBitrate = audioSpec.bitrate val resolvedBitrate: Int = - if (audioSpecBitrate != AudioSpec.BITRATE_AUTO) { + if (audioSpecBitrate != AudioSpec.BITRATE_UNSPECIFIED) { audioSpecBitrate } else { Logger.d(TAG, "Using fallback AUDIO bitrate") diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioSettingsAudioProfileResolver.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioSettingsAudioProfileResolver.kt index c49337f5e6a19..3b8e44407b894 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioSettingsAudioProfileResolver.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioSettingsAudioProfileResolver.kt @@ -55,7 +55,7 @@ public constructor( val audioSpecChannelCount = audioSpec.channelCount val resolvedChannelCount: Int val audioProfileChannelCount = audioProfile.getChannels() - if (audioSpecChannelCount == AudioSpec.CHANNEL_COUNT_AUTO) { + if (audioSpecChannelCount == AudioSpec.CHANNEL_COUNT_UNSPECIFIED) { resolvedChannelCount = audioProfileChannelCount Logger.d(TAG, "Resolved AUDIO channel count from AudioProfile: $resolvedChannelCount") } else { @@ -72,7 +72,7 @@ public constructor( val audioSpecSampleRate = audioSpec.sampleRate val audioProfileSampleRate = audioProfile.getSampleRate() val targetSampleRate: Int = - if (audioSpecSampleRate != AudioSpec.SAMPLE_RATE_AUTO) { + if (audioSpecSampleRate != AudioSpec.SAMPLE_RATE_UNSPECIFIED) { audioSpecSampleRate } else { audioProfileSampleRate diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioSettingsDefaultResolver.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioSettingsDefaultResolver.kt index eaa94d6ba6752..0b536ae8617cb 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioSettingsDefaultResolver.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioSettingsDefaultResolver.kt @@ -50,7 +50,7 @@ public constructor(private val audioSpec: AudioSpec, private val captureToEncode // Resolve channel count val audioSpecChannelCount = audioSpec.channelCount val resolvedChannelCount: Int - if (audioSpecChannelCount == AudioSpec.CHANNEL_COUNT_AUTO) { + if (audioSpecChannelCount == AudioSpec.CHANNEL_COUNT_UNSPECIFIED) { resolvedChannelCount = AudioConfigUtil.AUDIO_CHANNEL_COUNT_DEFAULT Logger.d(TAG, "Using fallback AUDIO channel count: $resolvedChannelCount") } else { @@ -61,7 +61,7 @@ public constructor(private val audioSpec: AudioSpec, private val captureToEncode // Resolve sample rate val audioSpecSampleRate = audioSpec.sampleRate val targetSampleRate: Int = - if (audioSpecSampleRate != AudioSpec.SAMPLE_RATE_AUTO) { + if (audioSpecSampleRate != AudioSpec.SAMPLE_RATE_UNSPECIFIED) { audioSpecSampleRate } else { AudioConfigUtil.AUDIO_SAMPLE_RATE_DEFAULT diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/FormatComboRegistry.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/FormatComboRegistry.kt index 40ef44bc7d03f..8746e9f62cdaf 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/FormatComboRegistry.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/FormatComboRegistry.kt @@ -18,7 +18,7 @@ package androidx.camera.video.internal.config import androidx.camera.video.AudioSpec import androidx.camera.video.MediaSpec -import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_AUTO +import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_UNSPECIFIED import androidx.camera.video.VideoSpec /** @@ -45,9 +45,9 @@ private constructor(private val formatComboMapping: Map surfaceRequest = request }, ) val videoCapture = createVideoCapture(videoOutput) @@ -493,10 +490,7 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( - mediaSpec = - MediaSpec.builder() - .configureVideo { it.setQualitySelector(QualitySelector.from(quality)) } - .build() + mediaSpec = createMediaSpec(qualitySelector = QualitySelector.from(quality)) ) val videoCapture = createVideoCapture(videoOutput) @@ -521,20 +515,17 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( mediaSpec = - MediaSpec.builder() - .configureVideo { - it.setQualitySelector( - QualitySelector.fromOrderedList( - listOf( - UHD, // 2160P - SD, // 480P - HD, // 720P - FHD, // 1080P - ) + createMediaSpec( + qualitySelector = + QualitySelector.fromOrderedList( + listOf( + UHD, // 2160P + SD, // 480P + HD, // 720P + FHD, // 1080P ) ) - } - .build(), + ), videoCapabilities = FULL_QUALITY_VIDEO_CAPABILITIES, ) val videoCapture = createVideoCapture(videoOutput) @@ -562,14 +553,10 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( mediaSpec = - MediaSpec.builder() - .configureVideo { - it.setQualitySelector( - QualitySelector.fromOrderedList(listOf(UHD, FHD, HD, SD)) - ) - it.setAspectRatio(RATIO_4_3) - } - .build(), + createMediaSpec( + qualitySelector = QualitySelector.fromOrderedList(listOf(UHD, FHD, HD, SD)), + aspectRatio = RATIO_4_3, + ), videoCapabilities = FULL_QUALITY_VIDEO_CAPABILITIES, ) val videoCapture = createVideoCapture(videoOutput) @@ -602,14 +589,10 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( mediaSpec = - MediaSpec.builder() - .configureVideo { - it.setQualitySelector( - QualitySelector.fromOrderedList(listOf(UHD, FHD, HD, SD)) - ) - it.setAspectRatio(RATIO_16_9) - } - .build(), + createMediaSpec( + qualitySelector = QualitySelector.fromOrderedList(listOf(UHD, FHD, HD, SD)), + aspectRatio = RATIO_16_9, + ), videoCapabilities = FULL_QUALITY_VIDEO_CAPABILITIES, ) val videoCapture = createVideoCapture(videoOutput) @@ -642,14 +625,10 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( mediaSpec = - MediaSpec.builder() - .configureVideo { - it.setQualitySelector( - QualitySelector.fromOrderedList(listOf(UHD, FHD, HD, SD)) - ) - it.setAspectRatio(RATIO_4_3) - } - .build(), + createMediaSpec( + qualitySelector = QualitySelector.fromOrderedList(listOf(UHD, FHD, HD, SD)), + aspectRatio = RATIO_4_3, + ), videoCapabilities = HIGH_SPEED_FULL_QUALITY_VIDEO_CAPABILITIES, ) val videoCapture = createVideoCapture(videoOutput, sessionType = SESSION_TYPE_HIGH_SPEED) @@ -682,14 +661,10 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( mediaSpec = - MediaSpec.builder() - .configureVideo { - it.setQualitySelector( - QualitySelector.fromOrderedList(listOf(UHD, FHD, HD, SD)) - ) - it.setAspectRatio(RATIO_16_9) - } - .build(), + createMediaSpec( + qualitySelector = QualitySelector.fromOrderedList(listOf(UHD, FHD, HD, SD)), + aspectRatio = RATIO_16_9, + ), videoCapabilities = HIGH_SPEED_FULL_QUALITY_VIDEO_CAPABILITIES, ) val videoCapture = createVideoCapture(videoOutput, sessionType = SESSION_TYPE_HIGH_SPEED) @@ -744,12 +719,10 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( mediaSpec = - MediaSpec.builder() - .configureVideo { - it.setQualitySelector(QualitySelector.from(HD)) - it.setAspectRatio(RATIO_4_3) - } - .build(), + createMediaSpec( + qualitySelector = QualitySelector.from(HD), + aspectRatio = RATIO_4_3, + ), videoCapabilities = createFakeVideoCapabilities(mapOf(DynamicRange.SDR to profileMap)), ) @@ -837,12 +810,10 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( mediaSpec = - MediaSpec.builder() - .configureVideo { - it.setQualitySelector(QualitySelector.from(HD)) - it.setAspectRatio(RATIO_4_3) - } - .build(), + createMediaSpec( + qualitySelector = QualitySelector.from(HD), + aspectRatio = RATIO_4_3, + ), videoCapabilities = createFakeVideoCapabilities( mapOf( @@ -933,10 +904,7 @@ class VideoCaptureTest { // Camera 0 support 2160P(UHD) and 720P(HD) val videoOutput = createVideoOutput( - mediaSpec = - MediaSpec.builder() - .configureVideo { it.setQualitySelector(QualitySelector.from(FHD)) } - .build() + mediaSpec = createMediaSpec(qualitySelector = QualitySelector.from(FHD)) ) val videoCapture = createVideoCapture(videoOutput) @@ -955,10 +923,7 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( - mediaSpec = - MediaSpec.builder() - .configureVideo { it.setQualitySelector(QualitySelector.from(UHD)) } - .build() + mediaSpec = createMediaSpec(qualitySelector = QualitySelector.from(UHD)) ) val videoCapture = createVideoCapture(videoOutput) @@ -1759,9 +1724,7 @@ class VideoCaptureTest { @Test fun canSetVideoStabilization() { val videoCapture = - VideoCapture.Builder(Recorder.Builder().build()) - .setVideoStabilizationEnabled(true) - .build() + VideoCapture.Builder(createVideoOutput()).setVideoStabilizationEnabled(true).build() assertThat(videoCapture.isVideoStabilizationEnabled).isTrue() } @@ -2028,10 +1991,7 @@ class VideoCaptureTest { createVideoCapture( videoOutput = createVideoOutput( - mediaSpec = - MediaSpec.builder() - .configureVideo { it.setQualitySelector(QualitySelector.from(FHD)) } - .build() + mediaSpec = createMediaSpec(qualitySelector = QualitySelector.from(FHD)) ), customOrderedResolutions = customOrderedResolutions, ) @@ -2137,10 +2097,7 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( videoCapabilities = videoCapabilities, - mediaSpec = - MediaSpec.builder() - .configureVideo { it.setQualitySelector(qualitySelector) } - .build(), + mediaSpec = createMediaSpec(qualitySelector = qualitySelector), ) val videoCapture = createVideoCapture(videoOutput = videoOutput) @@ -2301,7 +2258,7 @@ class VideoCaptureTest { private fun createVideoOutput( streamInfo: StreamInfo = createStreamInfo(), - mediaSpec: MediaSpec? = MediaSpec.builder().build(), + mediaSpec: MediaSpec? = createMediaSpec(), videoCapabilities: VideoCapabilities = CAMERA_0_VIDEO_CAPABILITIES, surfaceRequestListener: (SurfaceRequest, Timebase) -> Unit = { surfaceRequest, _ -> surfaceRequest.willNotProvideSurface() @@ -2312,6 +2269,18 @@ class VideoCaptureTest { surfaceRequestListener.invoke(surfaceRequest, timebase) } + private fun createMediaSpec( + qualitySelector: QualitySelector = DEFAULT_QUALITY_SELECTOR, + @AspectRatio.Ratio aspectRatio: Int? = null, + ): MediaSpec { + return MediaSpec.builder() + .configureVideo { config -> + config.setQualitySelector(qualitySelector) + aspectRatio?.let { config.setAspectRatio(it) } + } + .build() + } + private class TestVideoOutput( streamInfo: StreamInfo, mediaSpec: MediaSpec?, @@ -2353,9 +2322,9 @@ class VideoCaptureTest { override fun isQualitySelectorDefault(): Boolean { val currentSelector = mediaSpec.fetchData().get()?.videoSpec?.qualitySelector - // For tests, both null and the Recorder-default are considered as default quality + // For tests, both null and the default are considered as default quality // selector. - return currentSelector == null || currentSelector == Recorder.DEFAULT_QUALITY_SELECTOR + return currentSelector == null || currentSelector == DEFAULT_QUALITY_SELECTOR } } @@ -2620,6 +2589,12 @@ class VideoCaptureTest { private val CAMERA_0_VIDEO_CAPABILITIES = createFakeVideoCapabilities(mapOf(DynamicRange.SDR to CAMERA_0_PROFILES)) + private val DEFAULT_QUALITY_SELECTOR = + QualitySelector.fromOrderedList( + listOf(FHD, HD, SD), + FallbackStrategy.higherQualityOrLowerThan(FHD), + ) + /** Create a fake VideoCapabilities. */ private fun createFakeVideoCapabilities( profilesMap: Map>, diff --git a/camera/camera-video/src/test/java/androidx/camera/video/VideoSpecTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/VideoSpecTest.kt index 3fb7b400dffba..e70b6f4f24072 100644 --- a/camera/camera-video/src/test/java/androidx/camera/video/VideoSpecTest.kt +++ b/camera/camera-video/src/test/java/androidx/camera/video/VideoSpecTest.kt @@ -33,11 +33,11 @@ class VideoSpecTest { fun newBuilder_containsCorrectDefaults() { val videoSpec = VideoSpec.builder().build() - assertThat(videoSpec.qualitySelector).isEqualTo(VideoSpec.QUALITY_SELECTOR_AUTO) - assertThat(videoSpec.bitrate).isEqualTo(VideoSpec.BITRATE_AUTO) - assertThat(videoSpec.encodeFrameRate).isEqualTo(VideoSpec.ENCODE_FRAME_RATE_AUTO) + assertThat(videoSpec.qualitySelector).isEqualTo(VideoSpec.QUALITY_SELECTOR_UNSPECIFIED) + assertThat(videoSpec.bitrate).isEqualTo(VideoSpec.BITRATE_UNSPECIFIED) + assertThat(videoSpec.encodeFrameRate).isEqualTo(VideoSpec.ENCODE_FRAME_RATE_UNSPECIFIED) assertThat(videoSpec.aspectRatio).isEqualTo(RATIO_DEFAULT) - assertThat(videoSpec.mimeType).isEqualTo(VideoSpec.MIME_TYPE_AUTO) + assertThat(videoSpec.mimeType).isEqualTo(VideoSpec.MIME_TYPE_UNSPECIFIED) } @Test diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/config/FormatComboRegistryTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/FormatComboRegistryTest.kt index ae5117cc3e9b3..56cfcc44daff5 100644 --- a/camera/camera-video/src/test/java/androidx/camera/video/internal/config/FormatComboRegistryTest.kt +++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/FormatComboRegistryTest.kt @@ -21,8 +21,8 @@ import android.media.MediaFormat.MIMETYPE_AUDIO_OPUS import android.media.MediaFormat.MIMETYPE_VIDEO_AVC import android.media.MediaFormat.MIMETYPE_VIDEO_VP9 import androidx.camera.video.AudioSpec -import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_AUTO import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_MPEG_4 +import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_UNSPECIFIED import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_WEBM import androidx.camera.video.VideoSpec import com.google.common.truth.Truth.assertThat @@ -63,12 +63,12 @@ class FormatComboRegistryTest { } @Test - fun getCombos_withOutputFormatAuto_searchesAcrossAllContainers() { + fun getCombos_withOutputFormatUnspecified_searchesAcrossAllContainers() { val results = sampleRegistry.getCombos( - OUTPUT_FORMAT_AUTO, - VideoSpec.MIME_TYPE_AUTO, - AudioSpec.MIME_TYPE_AUTO, + OUTPUT_FORMAT_UNSPECIFIED, + VideoSpec.MIME_TYPE_UNSPECIFIED, + AudioSpec.MIME_TYPE_UNSPECIFIED, ) // MPEG_4: (AVC+AAC), (AVC+null), (null+AAC) -> 3 @@ -84,9 +84,13 @@ class FormatComboRegistryTest { } @Test - fun getCombos_supportsMimeAuto() { + fun getCombos_supportsMimeUnspecified() { val results = - sampleRegistry.getCombos(OUTPUT_FORMAT_MPEG_4, VIDEO_AVC, AudioSpec.MIME_TYPE_AUTO) + sampleRegistry.getCombos( + OUTPUT_FORMAT_MPEG_4, + VIDEO_AVC, + AudioSpec.MIME_TYPE_UNSPECIFIED, + ) // Should find (AVC+AAC) and (AVC+null) assertThat(results).hasSize(2) diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/config/VideoConfigUtilTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/VideoConfigUtilTest.kt index 543764e9753c5..c8ae13bca9e87 100644 --- a/camera/camera-video/src/test/java/androidx/camera/video/internal/config/VideoConfigUtilTest.kt +++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/VideoConfigUtilTest.kt @@ -58,7 +58,7 @@ class VideoConfigUtilTest { fun videoMimeInfo_resolvesFromDynamicRange_withCompatibleProfile() { val videoMimeInfo = VideoConfigUtil.resolveVideoMimeInfo( - createMediaSpec(outputFormat = MediaSpec.OUTPUT_FORMAT_AUTO), + createMediaSpec(outputFormat = MediaSpec.OUTPUT_FORMAT_UNSPECIFIED), DynamicRange.HLG_10_BIT, createFakeEncoderProfiles( listOf( @@ -140,7 +140,7 @@ class VideoConfigUtilTest { } @Test - fun resolveFrameRates_expectedCaptureFrameRateUnspecified_videoSpecAuto() { + fun resolveFrameRates_expectedCaptureFrameRateUnspecified_videoSpecUnspecified() { val videoSpec = VideoSpec.builder().build() val expectedCaptureFrameRateRange = FRAME_RATE_RANGE_UNSPECIFIED @@ -151,7 +151,7 @@ class VideoConfigUtilTest { } @Test - fun resolveFrameRates_expectedCaptureFrameRateSpecified_videoSpecAuto() { + fun resolveFrameRates_expectedCaptureFrameRateSpecified_videoSpecUnspecified() { val videoSpec = VideoSpec.builder().build() val expectedCaptureFrameRateRange = Range(24, 60) @@ -192,7 +192,7 @@ class VideoConfigUtilTest { videoProfileProxies, ) - fun createMediaSpec(outputFormat: Int = MediaSpec.OUTPUT_FORMAT_AUTO) = + fun createMediaSpec(outputFormat: Int = MediaSpec.OUTPUT_FORMAT_UNSPECIFIED) = MediaSpec.builder().apply { setOutputFormat(outputFormat) }.build() private val DEFAULT_VIDEO_RESOLUTION = Size(1920, 1080) From cb3188186bca0557ce44238dad76857f3fb8cfa0 Mon Sep 17 00:00:00 2001 From: leo huang Date: Thu, 18 Dec 2025 10:42:07 +0800 Subject: [PATCH 6/7] Centralize media configuration resolution with MediaConfigUtil Introduce MediaConfigUtil to centralize the logic for resolving media settings (container format, MIME types, and EncoderProfiles), including the auto-selection via FormatComboRegistry queries if certain container format or MIME types are unset. Currently, `VideoConfigUtil.resolveVideoMimeInfo()` and `AudioConfigUtil.resolveAudioMimeInfo()` resolve settings based only on output format and EncoderProfiles. However, supporting custom MIME types requires evaluating the entire combination of MediaSpec(container, MIME types), DynamicRange and EncoderProfiles. `MediaConfigUtil.resolveMediaInfo()` will later replace the `VideoConfigUtil.resolveVideoMimeInfo()` and `AudioConfigUtil.resolveAudioMimeInfo()` to handle these combinations holistically. Key changes: - Added `MediaConfigUtil.resolveMediaInfo()` which attempts to resolve configuration using `EncoderProfiles` first, and falls back to `FormatComboRegistry` if necessary. - Introduced `MediaInfo` class to aggregate the resolved configuration (Container, Video, Audio). - Added `ContainerInfo` to hold container format details. - Added helper methods in `VideoConfigUtil` and `AudioConfigUtil` to resolve compatible profiles based on MIME types and dynamic range. Bug: 473996668 Test: ./gradlew camera:camera-video:testRelease Change-Id: I3ca7ec6c01e63aed7a4f144f42ce3a04ee664f83 --- .../testing/impl/EncoderProfilesUtil.kt | 17 +- .../video/internal/config/AudioConfigUtil.kt | 39 +++ .../video/internal/config/ContainerInfo.kt | 44 +++ .../video/internal/config/MediaConfigUtil.kt | 253 ++++++++++++++++ .../camera/video/internal/config/MediaInfo.kt | 41 +++ .../video/internal/config/VideoConfigUtil.kt | 31 ++ .../internal/config/AudioConfigUtilTest.kt | 79 +++++ .../internal/config/MediaConfigUtilTest.kt | 282 ++++++++++++++++++ .../internal/config/VideoConfigUtilTest.kt | 194 +++++++++--- 9 files changed, 934 insertions(+), 46 deletions(-) create mode 100644 camera/camera-video/src/main/java/androidx/camera/video/internal/config/ContainerInfo.kt create mode 100644 camera/camera-video/src/main/java/androidx/camera/video/internal/config/MediaConfigUtil.kt create mode 100644 camera/camera-video/src/main/java/androidx/camera/video/internal/config/MediaInfo.kt create mode 100644 camera/camera-video/src/test/java/androidx/camera/video/internal/config/MediaConfigUtilTest.kt diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/EncoderProfilesUtil.kt b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/EncoderProfilesUtil.kt index 05cfc956b598c..0cd8f2932d9d3 100644 --- a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/EncoderProfilesUtil.kt +++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/EncoderProfilesUtil.kt @@ -251,15 +251,20 @@ public object EncoderProfilesUtil { /** A utility method to create an AudioProfileProxy with some default values. */ public fun createFakeAudioProfileProxy( - audioMediaType: String = DEFAULT_AUDIO_MEDIA_TYPE + audioCodec: Int = DEFAULT_AUDIO_CODEC, + audioMediaType: String = DEFAULT_AUDIO_MEDIA_TYPE, + bitrate: Int = DEFAULT_AUDIO_BITRATE, + sampleRate: Int = DEFAULT_AUDIO_SAMPLE_RATE, + channelCount: Int = DEFAULT_AUDIO_CHANNELS, + profile: Int = DEFAULT_AUDIO_PROFILE, ): AudioProfileProxy { return AudioProfileProxy.create( - DEFAULT_AUDIO_CODEC, + audioCodec, audioMediaType, - DEFAULT_AUDIO_BITRATE, - DEFAULT_AUDIO_SAMPLE_RATE, - DEFAULT_AUDIO_CHANNELS, - DEFAULT_AUDIO_PROFILE, + bitrate, + sampleRate, + channelCount, + profile, ) } } diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioConfigUtil.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioConfigUtil.kt index 8f55763292165..ea22b7836bd5f 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioConfigUtil.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/AudioConfigUtil.kt @@ -15,6 +15,8 @@ */ package androidx.camera.video.internal.config +import android.media.MediaCodecInfo +import android.media.MediaFormat.MIMETYPE_AUDIO_AAC import android.util.Rational import androidx.camera.core.Logger import androidx.camera.core.impl.EncoderProfilesProxy.AudioProfileProxy @@ -25,6 +27,7 @@ import androidx.camera.video.internal.VideoValidatedEncoderProfilesProxy import androidx.camera.video.internal.audio.AudioSettings import androidx.camera.video.internal.audio.AudioSource import androidx.camera.video.internal.encoder.AudioEncoderConfig +import androidx.camera.video.internal.encoder.EncoderConfig import kotlin.math.abs import kotlin.math.sign @@ -44,6 +47,42 @@ public object AudioConfigUtil { // Defaults to Camcorder as this should be the source closest to the camera public const val AUDIO_SOURCE_DEFAULT: Int = AudioSpec.SOURCE_CAMCORDER + private const val AAC_DEFAULT_PROFILE = MediaCodecInfo.CodecProfileLevel.AACObjectLC + + /** + * Resolves a compatible [AudioProfileProxy] from a list based on the provided MIME type. + * + * This method attempts to find the first profile in the provided list that matches the + * requested [audioMime] and its corresponding codec profile. If the [audioMime] is set to + * [AudioSpec.MIME_TYPE_UNSPECIFIED], it will return the first available profile in the list. + * + * @param audioMime The desired audio MIME type. + * @param audioProfiles A list of available [AudioProfileProxy]s. + * @return The first matching [AudioProfileProxy], or `null` if no compatible profile is found. + */ + public fun resolveCompatibleAudioProfile( + audioMime: String, + audioProfiles: List, + ): AudioProfileProxy? { + val audioCodecProfile = audioMimeToAudioProfile(audioMime) + return audioProfiles.firstOrNull { + audioMime == AudioSpec.MIME_TYPE_UNSPECIFIED || + (it.mediaType == audioMime && it.profile == audioCodecProfile) + } + } + + /** + * Maps an audio MIME type to its corresponding standard [MediaCodecInfo.CodecProfileLevel]. + * + * @param audioMime The audio MIME type. + * @return The codec profile. + */ + public fun audioMimeToAudioProfile(audioMime: String): Int = + when (audioMime) { + MIMETYPE_AUDIO_AAC -> AAC_DEFAULT_PROFILE + else -> EncoderConfig.CODEC_PROFILE_NONE + } + /** * Resolves the audio mime information into a [AudioMimeInfo]. * diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/ContainerInfo.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/ContainerInfo.kt new file mode 100644 index 0000000000000..3552c7978df6f --- /dev/null +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/ContainerInfo.kt @@ -0,0 +1,44 @@ +/* + * 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.internal.config + +import androidx.camera.core.impl.EncoderProfilesProxy +import androidx.camera.video.MediaSpec + +/** + * Data class containing information about the media container format. + * + * This includes the resolved output format (e.g., MPEG-4, WebM) and the [EncoderProfilesProxy] that + * provided the device capabilities for this specific container. + */ +public data class ContainerInfo( + /** + * Returns the output format of the container. + * + * @return The format as defined in [MediaSpec.OutputFormat], such as + * [MediaSpec.OUTPUT_FORMAT_MPEG_4]. + */ + @property:MediaSpec.OutputFormat val outputFormat: Int, + + /** + * Returns the compatible [EncoderProfilesProxy] used to resolve the settings for this + * container. + * + * If no profiles are available or compatible with the requested configuration, returns `null`. + */ + val compatibleEncoderProfiles: EncoderProfilesProxy?, +) diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/MediaConfigUtil.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/MediaConfigUtil.kt new file mode 100644 index 0000000000000..6b74f3f2f6a97 --- /dev/null +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/MediaConfigUtil.kt @@ -0,0 +1,253 @@ +/* + * 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.internal.config + +import android.media.MediaRecorder.OutputFormat.MPEG_4 +import android.media.MediaRecorder.OutputFormat.THREE_GPP +import android.media.MediaRecorder.OutputFormat.WEBM +import androidx.annotation.VisibleForTesting +import androidx.camera.core.DynamicRange +import androidx.camera.core.Logger +import androidx.camera.core.impl.EncoderProfilesProxy +import androidx.camera.core.impl.EncoderProfilesProxy.AudioProfileProxy +import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy +import androidx.camera.video.AudioSpec +import androidx.camera.video.MediaSpec +import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_MPEG_4 +import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_UNSPECIFIED +import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_WEBM +import androidx.camera.video.MediaSpec.OutputFormat +import androidx.camera.video.internal.utils.CodecUtil + +public object MediaConfigUtil { + private const val TAG = "MediaConfigUtil" + + private var supportedVideoEncoderMimesOverride: List? = null + private var supportedAudioEncoderMimesOverride: List? = null + + @VisibleForTesting + public fun setSupportedEncoderMimeTypes(videoMimes: List?, audioMimes: List?) { + supportedVideoEncoderMimesOverride = videoMimes + supportedAudioEncoderMimesOverride = audioMimes + } + + private fun getSupportedVideoEncoderMimes() = + supportedVideoEncoderMimesOverride ?: CodecUtil.getVideoEncoderMimeTypes() + + private fun getSupportedAudioEncoderMimes() = + supportedAudioEncoderMimesOverride ?: CodecUtil.getAudioEncoderMimeTypes() + + @JvmStatic + public fun resolveMediaInfo( + mediaSpec: MediaSpec, + dynamicRange: DynamicRange, + encoderProfiles: EncoderProfilesProxy?, + ): MediaInfo? { + Logger.d( + TAG, + "Resolving MediaInfo for MediaSpec: $mediaSpec, " + + "DynamicRange: $dynamicRange, EncoderProfiles: $encoderProfiles", + ) + val outputFormat = mediaSpec.outputFormat + val videoMime = mediaSpec.videoSpec.mimeType + val audioMime = mediaSpec.audioSpec.mimeType + + val compatibleProfiles = + resolveCompatibleProfiles( + encoderProfiles = encoderProfiles, + dynamicRange = dynamicRange, + outputFormat = outputFormat, + videoMime = videoMime, + audioMime = audioMime, + ) + .also { Logger.d(TAG, "Resolved CompatibleProfiles: $it") } + + // Step 1: Trust EncoderProfiles if it fully matches requirements + // If all formats are UNSPECIFIED and encoderProfiles exists, it is definitely fully + // compatible. + if (compatibleProfiles.isFullyCompatible) { + return compatibleProfiles.toMediaInfo().also { + Logger.d(TAG, "Resolved MediaInfo by fully CompatibleProfiles: $it") + } + } + + // Step 2: Fall back to the FormatComboRegistry mapping + val supportedVideoMimes = getSupportedVideoEncoderMimes() + val supportedAudioMimes = getSupportedAudioEncoderMimes() + val formatCombo = + resolveFormatCombo( + dynamicRange = dynamicRange, + outputFormat = outputFormat, + videoMime = videoMime, + audioMime = audioMime, + supportedVideoEncoderMimes = supportedVideoMimes, + supportedAudioEncoderMimes = supportedAudioMimes, + ) + .also { Logger.d(TAG, "Resolved FormatCombo: $it") } ?: return null + + // Step 3: Find compatible profiles based on resolved formatCombo + val formatComboCompatibleProfiles = + resolveCompatibleProfiles( + encoderProfiles = encoderProfiles, + dynamicRange = dynamicRange, + outputFormat = formatCombo.container, + videoMime = formatCombo.videoMime!!, + audioMime = formatCombo.audioMime ?: AudioSpec.MIME_TYPE_UNSPECIFIED, + ) + .also { Logger.d(TAG, "Resolved FormatCombo CompatibleProfiles: $it") } + + return MediaInfo( + containerInfo = + ContainerInfo( + outputFormat = formatCombo.container, + compatibleEncoderProfiles = formatComboCompatibleProfiles.encoderProfiles, + ), + videoMimeInfo = + VideoMimeInfo( + mimeType = formatCombo.videoMime, + compatibleVideoProfile = formatComboCompatibleProfiles.videoProfile, + ), + audioMimeInfo = + formatCombo.audioMime?.let { + AudioMimeInfo( + mimeType = it, + profile = AudioConfigUtil.audioMimeToAudioProfile(it), + compatibleAudioProfile = formatComboCompatibleProfiles.audioProfile, + ) + }, + ) + .also { Logger.d(TAG, "Resolved MediaInfo by FormatCombo: $it") } + } + + private fun resolveFormatCombo( + @OutputFormat outputFormat: Int, + videoMime: String, + audioMime: String, + dynamicRange: DynamicRange, + supportedVideoEncoderMimes: List, + supportedAudioEncoderMimes: List, + ): FormatCombo? { + val registry = DynamicRangeFormatComboRegistry.getRegistry(dynamicRange) ?: return null + + return registry + .getCombos(outputFormat = outputFormat, videoMime = videoMime, audioMime = audioMime) + .filter { combo -> + // Remove Audio-Only combos since currently CameraX doesn't support Audio-Only + // recording + combo.videoMime != null + } + .firstOrNull { combo -> + // Note: The registry appends the 'Video-Only' variant (i.e. audioMime == null) + // after the mixed (Video + Audio) combos, which ensures that the mixed combo is + // prioritized. + supportedVideoEncoderMimes.contains(combo.videoMime) && + (supportedAudioEncoderMimes.contains(combo.audioMime) || + combo.audioMime == null) + } + } + + private fun resolveCompatibleProfiles( + encoderProfiles: EncoderProfilesProxy?, + dynamicRange: DynamicRange, + @OutputFormat outputFormat: Int, + videoMime: String, + audioMime: String, + ): CompatibleProfiles { + encoderProfiles ?: return CompatibleProfiles.EMPTY + + val compatibleEncoderProfiles = + resolveCompatibleEncoderProfiles(outputFormat = outputFormat, encoderProfiles) + + val compatibleVideoProfile = + VideoConfigUtil.resolveCompatibleVideoProfile( + videoMime = videoMime, + dynamicRange = dynamicRange, + videoProfiles = encoderProfiles.videoProfiles, + ) + + val compatibleAudioProfile = + AudioConfigUtil.resolveCompatibleAudioProfile( + audioMime = audioMime, + audioProfiles = encoderProfiles.audioProfiles, + ) + + return CompatibleProfiles( + encoderProfiles = compatibleEncoderProfiles, + videoProfile = compatibleVideoProfile, + audioProfile = compatibleAudioProfile, + ) + } + + private fun resolveCompatibleEncoderProfiles( + @OutputFormat outputFormat: Int, + encoderProfiles: EncoderProfilesProxy?, + ): EncoderProfilesProxy? { + encoderProfiles ?: return null + return encoderProfiles.takeIf { + outputFormat == OUTPUT_FORMAT_UNSPECIFIED || + outputFormat == mediaRecorderFormatToOutputFormat(it.recommendedFileFormat) + } + } + + @OutputFormat + private fun mediaRecorderFormatToOutputFormat(mediaRecorderFormat: Int): Int { + return when (mediaRecorderFormat) { + WEBM -> OUTPUT_FORMAT_WEBM + MPEG_4, + THREE_GPP -> OUTPUT_FORMAT_MPEG_4 + else -> OUTPUT_FORMAT_UNSPECIFIED + } + } + + private data class CompatibleProfiles( + val encoderProfiles: EncoderProfilesProxy? = null, + val videoProfile: VideoProfileProxy? = null, + val audioProfile: AudioProfileProxy? = null, + ) { + companion object { + val EMPTY = CompatibleProfiles() + } + + val isFullyCompatible: Boolean + get() = encoderProfiles != null && videoProfile != null && audioProfile != null + + fun toMediaInfo(): MediaInfo { + check(isFullyCompatible) + return MediaInfo( + containerInfo = + ContainerInfo( + outputFormat = + mediaRecorderFormatToOutputFormat( + encoderProfiles!!.recommendedFileFormat + ), + compatibleEncoderProfiles = encoderProfiles, + ), + videoMimeInfo = + VideoMimeInfo( + mimeType = videoProfile!!.mediaType, + compatibleVideoProfile = videoProfile, + ), + audioMimeInfo = + AudioMimeInfo( + mimeType = audioProfile!!.mediaType, + profile = audioProfile.profile, + compatibleAudioProfile = audioProfile, + ), + ) + } + } +} diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/MediaInfo.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/MediaInfo.kt new file mode 100644 index 0000000000000..5b1883842a6f2 --- /dev/null +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/MediaInfo.kt @@ -0,0 +1,41 @@ +/* + * 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.internal.config + +/** + * A high-level aggregate class containing the resolved configuration for a recording session. + * + * MediaInfo serves as the final configuration blueprint, combining the container format with the + * resolved video settings and optional audio settings. + */ +public data class MediaInfo( + /** Returns the resolved container-level information, including the output format. */ + public val containerInfo: ContainerInfo, + + /** + * Returns the resolved video configuration. + * + * This is a mandatory component of the MediaInfo. + */ + public val videoMimeInfo: VideoMimeInfo, + + /** + * Returns the resolved audio configuration, or `null` if audio is not enabled or no compatible + * audio profile could be resolved. + */ + public val audioMimeInfo: AudioMimeInfo?, +) diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoConfigUtil.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoConfigUtil.kt index 395a2e1b81b77..2e7aa9ed0f0c3 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoConfigUtil.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoConfigUtil.kt @@ -115,6 +115,37 @@ public object VideoConfigUtil { // --------------------------------------------------------------------------------------// } + /** + * Resolves a compatible [VideoProfileProxy] from a list based on the provided MIME type and + * [DynamicRange]. + * + * This method attempts to find the first profile in the provided list that matches the + * requested [videoMime] and the constraints (HDR format and bit depth) of the [dynamicRange]. + * If the [videoMime] is set to [VideoSpec.MIME_TYPE_AUTO], it will return the first profile + * that satisfies the [dynamicRange] requirements. + * + * @param videoMime The desired video MIME type. + * @param dynamicRange The fully specified [DynamicRange] required for the profile. + * @param videoProfiles A list of available [VideoProfileProxy]s. + * @return The first matching [VideoProfileProxy], or `null` if no compatible profile is found. + */ + public fun resolveCompatibleVideoProfile( + videoMime: String, + dynamicRange: DynamicRange, + videoProfiles: List, + ): VideoProfileProxy? { + val hdrFormats = DynamicRangeUtil.dynamicRangeToVideoProfileHdrFormats(dynamicRange) + val bitDepths = DynamicRangeUtil.dynamicRangeToVideoProfileBitDepth(dynamicRange) + + return videoProfiles.firstOrNull { + // is HDR compatible + hdrFormats.contains(it.hdrFormat) && + bitDepths.contains(it.bitDepth) && + // is MIME type compatible + (videoMime == VideoSpec.MIME_TYPE_UNSPECIFIED || it.mediaType == videoMime) + } + } + /** * Resolves the video mime information into a [VideoMimeInfo]. * diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/config/AudioConfigUtilTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/AudioConfigUtilTest.kt index 77b7e6d88c73c..59d5d790506d4 100644 --- a/camera/camera-video/src/test/java/androidx/camera/video/internal/config/AudioConfigUtilTest.kt +++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/AudioConfigUtilTest.kt @@ -16,9 +16,15 @@ package androidx.camera.video.internal.config +import android.media.MediaCodecInfo.CodecProfileLevel.AACObjectLC +import android.media.MediaFormat.MIMETYPE_AUDIO_AAC +import android.media.MediaFormat.MIMETYPE_AUDIO_VORBIS import android.util.Rational +import androidx.camera.testing.impl.EncoderProfilesUtil.createFakeAudioProfileProxy import androidx.camera.video.AudioSpec.Companion.CHANNEL_COUNT_MONO +import androidx.camera.video.AudioSpec.Companion.MIME_TYPE_UNSPECIFIED import androidx.camera.video.AudioSpec.Companion.SOURCE_FORMAT_PCM_16BIT +import androidx.camera.video.internal.encoder.EncoderConfig.CODEC_PROFILE_NONE import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith @@ -64,4 +70,77 @@ class AudioConfigUtilTest { assertThat(result.captureRate).isEqualTo(48000) assertThat(result.encodeRate).isEqualTo(24000) } + + @Test + fun resolveCompatibleAudioProfile_matchesSpecificMimeAndProfile_returnsProfile() { + // Arrange: Prepare profiles including one matching AAC + val audioMime = MIMETYPE_AUDIO_AAC + val matchingProfile = + createFakeAudioProfileProxy(audioMediaType = audioMime, profile = AACObjectLC) + val profiles = + listOf( + createFakeAudioProfileProxy(audioMediaType = MIMETYPE_AUDIO_VORBIS), + matchingProfile, + ) + + // Act + val result = AudioConfigUtil.resolveCompatibleAudioProfile(audioMime, profiles) + + // Assert + assertThat(result).isEqualTo(matchingProfile) + } + + @Test + fun resolveCompatibleAudioProfile_matchesMimeButMismatchesProfile_returnsNull() { + // Arrange: Create a profile that has the right MIME but the WRONG profile integer + val audioMime = MIMETYPE_AUDIO_AAC + val mismatchingProfile = + createFakeAudioProfileProxy(audioMediaType = audioMime, profile = CODEC_PROFILE_NONE) + val profiles = listOf(mismatchingProfile) + + // Act + val result = AudioConfigUtil.resolveCompatibleAudioProfile(audioMime, profiles) + + // Assert: Even though MIME matches, the profile check should fail it + assertThat(result).isNull() + } + + @Test + fun resolveCompatibleAudioProfile_noMatchReturnsNull() { + // Arrange: Request a MIME type not present in the list + val audioMime = MIMETYPE_AUDIO_VORBIS + val profiles = + listOf( + createFakeAudioProfileProxy( + audioMediaType = MIMETYPE_AUDIO_AAC, + profile = AACObjectLC, + ) + ) + + // Act + val result = AudioConfigUtil.resolveCompatibleAudioProfile(audioMime, profiles) + + // Assert + assertThat(result).isNull() + } + + @Test + fun resolveCompatibleAudioProfile_unspecifiedMimeReturnsFirstProfile() { + // Arrange: Provide a list of profiles + val audioMime = MIME_TYPE_UNSPECIFIED + val profiles = + listOf( + createFakeAudioProfileProxy(audioMediaType = MIMETYPE_AUDIO_VORBIS), + createFakeAudioProfileProxy( + audioMediaType = MIMETYPE_AUDIO_AAC, + profile = AACObjectLC, + ), + ) + + // Act + val result = AudioConfigUtil.resolveCompatibleAudioProfile(audioMime, profiles) + + // Assert: It should return the first available profile + assertThat(result).isEqualTo(profiles.first()) + } } diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/config/MediaConfigUtilTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/MediaConfigUtilTest.kt new file mode 100644 index 0000000000000..8948db7fe4ea5 --- /dev/null +++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/MediaConfigUtilTest.kt @@ -0,0 +1,282 @@ +/* + * Copyright 2026 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.internal.config + +import android.media.EncoderProfiles.VideoProfile.HDR_HLG +import android.media.EncoderProfiles.VideoProfile.HDR_NONE +import android.media.MediaFormat.MIMETYPE_AUDIO_AAC +import android.media.MediaFormat.MIMETYPE_AUDIO_AMR_NB +import android.media.MediaFormat.MIMETYPE_AUDIO_VORBIS +import android.media.MediaFormat.MIMETYPE_VIDEO_AVC +import android.media.MediaFormat.MIMETYPE_VIDEO_HEVC +import android.media.MediaFormat.MIMETYPE_VIDEO_MPEG4 +import android.media.MediaFormat.MIMETYPE_VIDEO_VP8 +import android.media.MediaRecorder.VideoEncoder.H264 +import android.media.MediaRecorder.VideoEncoder.HEVC +import androidx.camera.core.DynamicRange.HLG_10_BIT +import androidx.camera.core.DynamicRange.SDR +import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy +import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy.BIT_DEPTH_10 +import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy.BIT_DEPTH_8 +import androidx.camera.testing.impl.EncoderProfilesUtil.DEFAULT_DURATION +import androidx.camera.testing.impl.EncoderProfilesUtil.DEFAULT_OUTPUT_FORMAT +import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_1080P +import androidx.camera.testing.impl.EncoderProfilesUtil.createFakeAudioProfileProxy +import androidx.camera.testing.impl.EncoderProfilesUtil.createFakeVideoProfileProxy +import androidx.camera.video.AudioSpec +import androidx.camera.video.MediaSpec +import androidx.camera.video.MediaSpec.Companion.OUTPUT_FORMAT_UNSPECIFIED +import androidx.camera.video.VideoSpec +import androidx.camera.video.internal.VideoValidatedEncoderProfilesProxy +import com.google.common.truth.Truth.assertThat +import org.junit.After +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 MediaConfigUtilTest { + + companion object { + private const val VIDEO_AVC = MIMETYPE_VIDEO_AVC + private const val VIDEO_MPEG4 = MIMETYPE_VIDEO_MPEG4 + private const val VIDEO_HEVC = MIMETYPE_VIDEO_HEVC + private const val VIDEO_VP8 = MIMETYPE_VIDEO_VP8 + private const val AUDIO_AAC = MIMETYPE_AUDIO_AAC + private const val AUDIO_AMR_NB = MIMETYPE_AUDIO_AMR_NB + private const val AUDIO_VORBIS = MIMETYPE_AUDIO_VORBIS + + private val VIDEO_PROFILE_SDR_AVC = + createFakeVideoProfileProxy( + RESOLUTION_1080P, + videoCodec = H264, + videoMediaType = VIDEO_AVC, + videoHdrFormat = HDR_NONE, + videoBitDepth = BIT_DEPTH_8, + ) + + private val VIDEO_PROFILE_HLG_HEVC = + createFakeVideoProfileProxy( + RESOLUTION_1080P, + videoCodec = HEVC, + videoMediaType = VIDEO_HEVC, + videoHdrFormat = HDR_HLG, + videoBitDepth = BIT_DEPTH_10, + ) + } + + @After + fun tearDown() { + // Reset overrides to ensure a clean slate + MediaConfigUtil.setSupportedEncoderMimeTypes(videoMimes = null, audioMimes = null) + } + + @Test + fun resolveMediaInfo_fullyCompatibleEncoderProfiles_returnsImmediately() { + // Arrange + val mediaSpec = createMediaSpec() + val encoderProfiles = createFakeEncoderProfiles(listOf(VIDEO_PROFILE_SDR_AVC)) + + // Act + val mediaInfo = MediaConfigUtil.resolveMediaInfo(mediaSpec, SDR, encoderProfiles) + + // Assert: It should resolve using the provided profiles without needing fallback + assertThat(mediaInfo).isNotNull() + assertThat(mediaInfo!!.videoMimeInfo.mimeType).isEqualTo(VIDEO_AVC) + assertThat(mediaInfo.videoMimeInfo.compatibleVideoProfile).isEqualTo(VIDEO_PROFILE_SDR_AVC) + } + + @Test + fun resolveMediaInfo_nullProfiles_fallsBackToRegistry() { + // Arrange + MediaConfigUtil.setSupportedEncoderMimeTypes( + videoMimes = listOf(VIDEO_AVC), + audioMimes = listOf(AUDIO_AAC), + ) + val mediaSpec = createMediaSpec() + + // Act + val mediaInfo = MediaConfigUtil.resolveMediaInfo(mediaSpec, SDR, null) + + // Assert: Resolved via Registry. Compatible profiles will be null. + assertThat(mediaInfo).isNotNull() + assertThat(mediaInfo!!.videoMimeInfo.mimeType).isEqualTo(VIDEO_AVC) + assertThat(mediaInfo.audioMimeInfo!!.mimeType).isEqualTo(AUDIO_AAC) + assertThat(mediaInfo.videoMimeInfo.compatibleVideoProfile).isNull() + assertThat(mediaInfo.audioMimeInfo.compatibleAudioProfile).isNull() + } + + @Test + @Config(minSdk = 24) // Required for HEVC + fun resolveMediaInfo_incompatibleProfiles_fallsBackToRegistry() { + // Arrange: Device supports HEVC and AVC + MediaConfigUtil.setSupportedEncoderMimeTypes( + videoMimes = listOf(VIDEO_HEVC, VIDEO_AVC), + audioMimes = listOf(AUDIO_AAC), + ) + + // EncoderProfile is HLG/HEVC, but we request SDR + val hlgProfiles = createFakeEncoderProfiles(listOf(VIDEO_PROFILE_HLG_HEVC)) + val mediaSpec = createMediaSpec() + + // Act + val mediaInfo = MediaConfigUtil.resolveMediaInfo(mediaSpec, SDR, hlgProfiles) + + // Assert: Since HLG HEVC profile isn't SDR compatible, it falls back to Registry (SDR -> + // AVC) + assertThat(mediaInfo).isNotNull() + assertThat(mediaInfo!!.videoMimeInfo.mimeType).isEqualTo(VIDEO_AVC) + assertThat(mediaInfo.videoMimeInfo.compatibleVideoProfile).isNull() + } + + @Test + @Config(minSdk = 24) // Required for HEVC + fun resolveMediaInfo_hdrRequest_resolvesCorrectHdrFallback() { + // Arrange: Device supports HEVC + MediaConfigUtil.setSupportedEncoderMimeTypes( + videoMimes = listOf(VIDEO_HEVC), + audioMimes = listOf(AUDIO_AAC), + ) + val mediaSpec = createMediaSpec() + + // Act: Request HLG with no profiles + val mediaInfo = MediaConfigUtil.resolveMediaInfo(mediaSpec, HLG_10_BIT, null) + + // Assert: Should resolve to HEVC as it's the standard for HLG + assertThat(mediaInfo).isNotNull() + assertThat(mediaInfo!!.videoMimeInfo.mimeType).isEqualTo(VIDEO_HEVC) + } + + @Test + fun resolveMediaInfo_respectsExplicitMimeTypeInSpec() { + // Arrange + val explicitMime = VIDEO_MPEG4 + MediaConfigUtil.setSupportedEncoderMimeTypes( + videoMimes = listOf(explicitMime), + audioMimes = listOf(AUDIO_AAC), + ) + val mediaSpec = createMediaSpec(videoMime = explicitMime) + + // Act + val mediaInfo = MediaConfigUtil.resolveMediaInfo(mediaSpec, SDR, null) + + // Assert + assertThat(mediaInfo).isNotNull() + assertThat(mediaInfo!!.videoMimeInfo.mimeType).isEqualTo(explicitMime) + } + + @Test + fun resolveMediaInfo_returnsNull_whenDeviceDoesNotSupport() { + // Arrange: Spec wants HEVC, but device only has AVC + MediaConfigUtil.setSupportedEncoderMimeTypes( + videoMimes = listOf(VIDEO_AVC), + audioMimes = listOf(AUDIO_AAC), + ) + val mediaSpec = createMediaSpec(videoMime = VIDEO_MPEG4) + + // Act + val mediaInfo = MediaConfigUtil.resolveMediaInfo(mediaSpec, SDR, null) + + // Assert + assertThat(mediaInfo).isNull() + } + + @Test + fun resolveMediaInfo_partialProfileMatch_resolvesCompatibleProfileForFallback() { + // Arrange: Device supports AVC, AAC and AMR_NB + MediaConfigUtil.setSupportedEncoderMimeTypes( + videoMimes = listOf(VIDEO_AVC), + audioMimes = listOf(AUDIO_AAC, AUDIO_AMR_NB), + ) + + // Spec is AUDIO_AMR_NB, but Profiles is AUDIO_AAC + val profiles = createFakeEncoderProfiles(listOf(VIDEO_PROFILE_SDR_AVC)) + val mediaSpec = createMediaSpec(audioMime = AUDIO_AMR_NB) + + // Act + val mediaInfo = MediaConfigUtil.resolveMediaInfo(mediaSpec, SDR, profiles) + + // Assert: Not "fully compatible" because audio mime doesn't match, but it should find and + // attach the AVC profile to the fallback. + assertThat(mediaInfo).isNotNull() + assertThat(mediaInfo!!.videoMimeInfo.mimeType).isEqualTo(VIDEO_AVC) + assertThat(mediaInfo.audioMimeInfo!!.mimeType).isEqualTo(AUDIO_AMR_NB) + assertThat(mediaInfo.videoMimeInfo.compatibleVideoProfile).isEqualTo(VIDEO_PROFILE_SDR_AVC) + assertThat(mediaInfo.audioMimeInfo.compatibleAudioProfile).isNull() + } + + @Test + fun resolveMediaInfo_videoOnlyFallback_returnsNullAudioInfo() { + // Arrange: Device supports VP8, but VORBIS/OPUS are not supported + MediaConfigUtil.setSupportedEncoderMimeTypes( + videoMimes = listOf(VIDEO_VP8), + audioMimes = listOf(AUDIO_AAC), + ) + val mediaSpec = createMediaSpec(videoMime = VIDEO_VP8) + + // Act + val mediaInfo = MediaConfigUtil.resolveMediaInfo(mediaSpec, SDR, null) + + // Assert: Should resolve to VP8 with no audio + assertThat(mediaInfo).isNotNull() + assertThat(mediaInfo!!.videoMimeInfo.mimeType).isEqualTo(VIDEO_VP8) + assertThat(mediaInfo.audioMimeInfo).isNull() + } + + @Test + fun resolveMediaInfo_containerMismatch_ignoresProfiles() { + // Arrange + MediaConfigUtil.setSupportedEncoderMimeTypes( + videoMimes = listOf(VIDEO_AVC, VIDEO_VP8), + audioMimes = listOf(AUDIO_AAC, AUDIO_VORBIS), + ) + // Profiles are for MP4 (default), but user requests WebM + val mp4Profiles = createFakeEncoderProfiles(listOf(VIDEO_PROFILE_SDR_AVC)) + val webmSpec = createMediaSpec(outputFormat = MediaSpec.OUTPUT_FORMAT_WEBM) + + // Act + val mediaInfo = MediaConfigUtil.resolveMediaInfo(webmSpec, SDR, mp4Profiles) + + // Assert: Should ignore the MP4 profiles and fallback to SDR WebM registry + assertThat(mediaInfo).isNotNull() + assertThat(mediaInfo!!.containerInfo.outputFormat).isEqualTo(MediaSpec.OUTPUT_FORMAT_WEBM) + assertThat(mediaInfo.containerInfo.compatibleEncoderProfiles).isNull() + } + + private fun createFakeEncoderProfiles(videoProfiles: List) = + VideoValidatedEncoderProfilesProxy.create( + DEFAULT_DURATION, + DEFAULT_OUTPUT_FORMAT, + listOf(createFakeAudioProfileProxy()), + videoProfiles, + ) + + private fun createMediaSpec( + outputFormat: Int = OUTPUT_FORMAT_UNSPECIFIED, + videoMime: String = VideoSpec.MIME_TYPE_UNSPECIFIED, + audioMime: String = AudioSpec.MIME_TYPE_UNSPECIFIED, + ) = + MediaSpec.builder() + .setOutputFormat(outputFormat) + .configureVideo { it.setMimeType(videoMime) } + .configureAudio { it.setMimeType(audioMime) } + .build() +} diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/config/VideoConfigUtilTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/VideoConfigUtilTest.kt index c8ae13bca9e87..e69d31b06564f 100644 --- a/camera/camera-video/src/test/java/androidx/camera/video/internal/config/VideoConfigUtilTest.kt +++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/config/VideoConfigUtilTest.kt @@ -16,17 +16,32 @@ package androidx.camera.video.internal.config -import android.media.EncoderProfiles -import android.media.MediaFormat -import android.media.MediaRecorder +import android.media.EncoderProfiles.VideoProfile.HDR_DOLBY_VISION +import android.media.EncoderProfiles.VideoProfile.HDR_HDR10 +import android.media.EncoderProfiles.VideoProfile.HDR_HDR10PLUS +import android.media.EncoderProfiles.VideoProfile.HDR_HLG +import android.media.MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION +import android.media.MediaFormat.MIMETYPE_VIDEO_HEVC +import android.media.MediaFormat.MIMETYPE_VIDEO_VP9 +import android.media.MediaRecorder.VideoEncoder.DOLBY_VISION +import android.media.MediaRecorder.VideoEncoder.HEVC +import android.media.MediaRecorder.VideoEncoder.VP9 import android.util.Range import android.util.Size -import androidx.camera.core.DynamicRange +import androidx.camera.core.DynamicRange.DOLBY_VISION_10_BIT +import androidx.camera.core.DynamicRange.DOLBY_VISION_8_BIT +import androidx.camera.core.DynamicRange.HDR10_10_BIT +import androidx.camera.core.DynamicRange.HDR10_PLUS_10_BIT +import androidx.camera.core.DynamicRange.HLG_10_BIT +import androidx.camera.core.DynamicRange.SDR import androidx.camera.core.SurfaceRequest.FRAME_RATE_RANGE_UNSPECIFIED import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy +import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy.BIT_DEPTH_10 +import androidx.camera.core.impl.EncoderProfilesProxy.VideoProfileProxy.BIT_DEPTH_8 import androidx.camera.testing.impl.EncoderProfilesUtil import androidx.camera.video.MediaSpec import androidx.camera.video.VideoSpec +import androidx.camera.video.VideoSpec.Companion.MIME_TYPE_UNSPECIFIED import androidx.camera.video.internal.VideoValidatedEncoderProfilesProxy import androidx.camera.video.internal.config.VideoConfigUtil.VIDEO_FRAME_RATE_FIXED_DEFAULT import com.google.common.truth.Truth.assertThat @@ -46,12 +61,12 @@ class VideoConfigUtilTest { val videoMimeInfo = VideoConfigUtil.resolveVideoMimeInfo( createMediaSpec(), - DynamicRange.HLG_10_BIT, + HLG_10_BIT, createFakeEncoderProfiles(listOf(VIDEO_PROFILE_DEFAULT)), ) assertThat(videoMimeInfo.compatibleVideoProfile).isNull() - assertThat(videoMimeInfo.mimeType).isEqualTo(MediaFormat.MIMETYPE_VIDEO_HEVC) + assertThat(videoMimeInfo.mimeType).isEqualTo(MIMETYPE_VIDEO_HEVC) } @Test @@ -59,7 +74,7 @@ class VideoConfigUtilTest { val videoMimeInfo = VideoConfigUtil.resolveVideoMimeInfo( createMediaSpec(outputFormat = MediaSpec.OUTPUT_FORMAT_UNSPECIFIED), - DynamicRange.HLG_10_BIT, + HLG_10_BIT, createFakeEncoderProfiles( listOf( VIDEO_PROFILE_DEFAULT, @@ -79,7 +94,7 @@ class VideoConfigUtilTest { val videoMimeInfo = VideoConfigUtil.resolveVideoMimeInfo( createMediaSpec(outputFormat = MediaSpec.OUTPUT_FORMAT_MPEG_4), - DynamicRange.HLG_10_BIT, + HLG_10_BIT, createFakeEncoderProfiles( listOf( VIDEO_PROFILE_DEFAULT, @@ -96,7 +111,7 @@ class VideoConfigUtilTest { val videoMimeInfo = VideoConfigUtil.resolveVideoMimeInfo( createMediaSpec(), - DynamicRange.DOLBY_VISION_10_BIT, + DOLBY_VISION_10_BIT, createFakeEncoderProfiles( listOf( VIDEO_PROFILE_DEFAULT, @@ -107,19 +122,19 @@ class VideoConfigUtilTest { ) assertThat(videoMimeInfo.compatibleVideoProfile).isNull() - assertThat(videoMimeInfo.mimeType).isEqualTo(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION) + assertThat(videoMimeInfo.mimeType).isEqualTo(MIMETYPE_VIDEO_DOLBY_VISION) } @Test fun videoMimeInfo_resolvesFromMatchingMime() { val expectedProfileMap = mapOf( - DynamicRange.SDR to VIDEO_PROFILE_DEFAULT, - DynamicRange.HLG_10_BIT to VIDEO_PROFILE_HEVC_HLG10, - DynamicRange.HDR10_10_BIT to VIDEO_PROFILE_HEVC_HDR10, - DynamicRange.HDR10_PLUS_10_BIT to VIDEO_PROFILE_HEVC_HDR10_PLUS, - DynamicRange.DOLBY_VISION_10_BIT to VIDEO_PROFILE_DOLBY_VISION_10_BIT, - DynamicRange.DOLBY_VISION_8_BIT to VIDEO_PROFILE_DOLBY_VISION_8_BIT, + SDR to VIDEO_PROFILE_DEFAULT, + HLG_10_BIT to VIDEO_PROFILE_HEVC_HLG10, + HDR10_10_BIT to VIDEO_PROFILE_HEVC_HDR10, + HDR10_PLUS_10_BIT to VIDEO_PROFILE_HEVC_HDR10_PLUS, + DOLBY_VISION_10_BIT to VIDEO_PROFILE_DOLBY_VISION_10_BIT, + DOLBY_VISION_8_BIT to VIDEO_PROFILE_DOLBY_VISION_8_BIT, ) val encoderProfiles = createFakeEncoderProfiles(expectedProfileMap.values.toList()) @@ -183,6 +198,105 @@ class VideoConfigUtilTest { assertThat(result.encodeRate).isEqualTo(30) } + @Test + fun resolveCompatibleVideoProfile_unspecifiedMime_returnsFirstMatchingDynamicRange() { + // Arrange: UNSPECIFIED MIME should ignore the media type and find the first compatible + // profile + val videoMime = MIME_TYPE_UNSPECIFIED + val dynamicRange = HLG_10_BIT + val profiles = + listOf( + VIDEO_PROFILE_DEFAULT, // SDR, 8-bit (Incompatible) + VIDEO_PROFILE_VP9_HLG10, // HLG, 10-bit (Compatible) + VIDEO_PROFILE_HEVC_HLG10, // HLG, 10-bit (Compatible) + ) + + // Act + val result = + VideoConfigUtil.resolveCompatibleVideoProfile(videoMime, dynamicRange, profiles) + + // Assert: Should return the first compatible one (VP9) + assertThat(result).isEqualTo(VIDEO_PROFILE_VP9_HLG10) + } + + @Test + fun resolveCompatibleVideoProfile_specificMime_matchesBothMimeAndDynamicRange() { + // Arrange + val expectedProfileMap = + mapOf( + SDR to VIDEO_PROFILE_DEFAULT, + HLG_10_BIT to VIDEO_PROFILE_HEVC_HLG10, + HDR10_10_BIT to VIDEO_PROFILE_HEVC_HDR10, + HDR10_PLUS_10_BIT to VIDEO_PROFILE_HEVC_HDR10_PLUS, + DOLBY_VISION_10_BIT to VIDEO_PROFILE_DOLBY_VISION_10_BIT, + DOLBY_VISION_8_BIT to VIDEO_PROFILE_DOLBY_VISION_8_BIT, + ) + val encoderProfiles = createFakeEncoderProfiles(expectedProfileMap.values.toList()) + + for ((dynamicRange, expectedVideoProfile) in expectedProfileMap) { + + // Act + val result = + VideoConfigUtil.resolveCompatibleVideoProfile( + expectedVideoProfile.mediaType, + dynamicRange, + encoderProfiles.videoProfiles, + ) + + // Assert + assertThat(result).isEqualTo(expectedVideoProfile) + } + } + + @Test + fun resolveCompatibleVideoProfile_mismatchingDynamicRange_returnsNull() { + // Arrange: Requesting HLG 10-bit but only SDR or Dolby 8-bit are available + val videoMime = MIME_TYPE_UNSPECIFIED + val dynamicRange = HLG_10_BIT + val profiles = + listOf( + VIDEO_PROFILE_DEFAULT, // SDR + VIDEO_PROFILE_DOLBY_VISION_8_BIT, // Incompatible HDR + ) + + // Act + val result = + VideoConfigUtil.resolveCompatibleVideoProfile(videoMime, dynamicRange, profiles) + + // Assert + assertThat(result).isNull() + } + + @Test + fun resolveCompatibleVideoProfile_mismatchingMime_returnsNull() { + // Arrange: Requesting VP9 but only DOLBY_VISION is available + val videoMime = MIMETYPE_VIDEO_VP9 + val dynamicRange = DOLBY_VISION_10_BIT + val profiles = listOf(VIDEO_PROFILE_DOLBY_VISION_10_BIT) + + // Act + val result = + VideoConfigUtil.resolveCompatibleVideoProfile(videoMime, dynamicRange, profiles) + + // Assert + assertThat(result).isNull() + } + + @Test + fun resolveCompatibleVideoProfile_mismatchingDynamicRangeBitDepth_returnsNull() { + // Arrange: Requesting DOLBY_VISION 10-bit but only 8-bit is available + val videoMime = MIMETYPE_VIDEO_DOLBY_VISION + val dynamicRange = DOLBY_VISION_10_BIT + val profiles = listOf(VIDEO_PROFILE_DOLBY_VISION_8_BIT) + + // Act + val result = + VideoConfigUtil.resolveCompatibleVideoProfile(videoMime, dynamicRange, profiles) + + // Assert + assertThat(result).isNull() + } + companion object { fun createFakeEncoderProfiles(videoProfileProxies: List) = VideoValidatedEncoderProfilesProxy.create( @@ -203,55 +317,55 @@ class VideoConfigUtilTest { val VIDEO_PROFILE_HEVC_HLG10 = EncoderProfilesUtil.createFakeVideoProfileProxy( DEFAULT_VIDEO_RESOLUTION, - videoCodec = MediaRecorder.VideoEncoder.HEVC, - videoMediaType = MediaFormat.MIMETYPE_VIDEO_HEVC, - videoHdrFormat = EncoderProfiles.VideoProfile.HDR_HLG, - videoBitDepth = VideoProfileProxy.BIT_DEPTH_10, + videoCodec = HEVC, + videoMediaType = MIMETYPE_VIDEO_HEVC, + videoHdrFormat = HDR_HLG, + videoBitDepth = BIT_DEPTH_10, ) val VIDEO_PROFILE_HEVC_HDR10 = EncoderProfilesUtil.createFakeVideoProfileProxy( DEFAULT_VIDEO_RESOLUTION, - videoCodec = MediaRecorder.VideoEncoder.HEVC, - videoMediaType = MediaFormat.MIMETYPE_VIDEO_HEVC, - videoHdrFormat = EncoderProfiles.VideoProfile.HDR_HDR10, - videoBitDepth = VideoProfileProxy.BIT_DEPTH_10, + videoCodec = HEVC, + videoMediaType = MIMETYPE_VIDEO_HEVC, + videoHdrFormat = HDR_HDR10, + videoBitDepth = BIT_DEPTH_10, ) val VIDEO_PROFILE_HEVC_HDR10_PLUS = EncoderProfilesUtil.createFakeVideoProfileProxy( DEFAULT_VIDEO_RESOLUTION, - videoCodec = MediaRecorder.VideoEncoder.HEVC, - videoMediaType = MediaFormat.MIMETYPE_VIDEO_HEVC, - videoHdrFormat = EncoderProfiles.VideoProfile.HDR_HDR10PLUS, - videoBitDepth = VideoProfileProxy.BIT_DEPTH_10, + videoCodec = HEVC, + videoMediaType = MIMETYPE_VIDEO_HEVC, + videoHdrFormat = HDR_HDR10PLUS, + videoBitDepth = BIT_DEPTH_10, ) val VIDEO_PROFILE_DOLBY_VISION_10_BIT = EncoderProfilesUtil.createFakeVideoProfileProxy( DEFAULT_VIDEO_RESOLUTION, - videoCodec = MediaRecorder.VideoEncoder.DOLBY_VISION, - videoMediaType = MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, - videoHdrFormat = EncoderProfiles.VideoProfile.HDR_DOLBY_VISION, - videoBitDepth = VideoProfileProxy.BIT_DEPTH_10, + videoCodec = DOLBY_VISION, + videoMediaType = MIMETYPE_VIDEO_DOLBY_VISION, + videoHdrFormat = HDR_DOLBY_VISION, + videoBitDepth = BIT_DEPTH_10, ) val VIDEO_PROFILE_DOLBY_VISION_8_BIT = EncoderProfilesUtil.createFakeVideoProfileProxy( DEFAULT_VIDEO_RESOLUTION, - videoCodec = MediaRecorder.VideoEncoder.DOLBY_VISION, - videoMediaType = MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, - videoHdrFormat = EncoderProfiles.VideoProfile.HDR_DOLBY_VISION, - videoBitDepth = VideoProfileProxy.BIT_DEPTH_8, + videoCodec = DOLBY_VISION, + videoMediaType = MIMETYPE_VIDEO_DOLBY_VISION, + videoHdrFormat = HDR_DOLBY_VISION, + videoBitDepth = BIT_DEPTH_8, ) val VIDEO_PROFILE_VP9_HLG10 = EncoderProfilesUtil.createFakeVideoProfileProxy( DEFAULT_VIDEO_RESOLUTION, - videoCodec = MediaRecorder.VideoEncoder.VP9, - videoMediaType = MediaFormat.MIMETYPE_VIDEO_VP9, - videoHdrFormat = EncoderProfiles.VideoProfile.HDR_HLG, - videoBitDepth = VideoProfileProxy.BIT_DEPTH_10, + videoCodec = VP9, + videoMediaType = MIMETYPE_VIDEO_VP9, + videoHdrFormat = HDR_HLG, + videoBitDepth = BIT_DEPTH_10, ) } } From e92082d1090cb2374b8d0f30b2e74412a7fcef3a Mon Sep 17 00:00:00 2001 From: WenHung_Teng Date: Thu, 8 Jan 2026 13:45:48 +0800 Subject: [PATCH 7/7] Refactor: Defer UseCaseCameraRequestControl initialization This CL refactors UseCaseCameraRequestControl to support lazy initialization, moving its heavy construction off the main thread during camera startup. DeferredUseCaseCameraRequestControl: A proxy implementation that intercepts control requests and dispatches them to the background sequential thread before initializing the real implementation. Updated UseCaseCameraRequestControlImpl: Modified internal dispatching logic (runOnSequential) to detect if it is already running on the sequential thread. If so, it executes immediately (using CoroutineStart.UNDISPATCHED) to prevent double-posting tasks. Binds the new Wrapper to the UseCaseCameraRequestControl interface, allowing consumers (like FlashControl, ZoomControl) to remain unchanged while benefiting from lazy loading. Bug: 448593362 Test: ./gradlew camera:camera-camera2-pipe-integration:testDebugUnitTest Test: Manual verification of startup logs to confirm deferred initialization. Change-Id: Iad3437208e1f296f25be054e2fe3655ca816bd3b --- .../DeferredUseCaseCameraRequestControl.kt | 222 +++++++++++++++ .../impl/UseCaseCameraRequestControl.kt | 48 +++- .../pipe/integration/impl/UseCaseThreads.kt | 36 ++- ...DeferredUseCaseCameraRequestControlTest.kt | 263 ++++++++++++++++++ .../DeferredUseCaseCameraRequestControl.kt | 222 +++++++++++++++ .../impl/UseCaseCameraRequestControl.kt | 48 +++- .../camera/camera2/impl/UseCaseThreads.kt | 36 ++- ...DeferredUseCaseCameraRequestControlTest.kt | 263 ++++++++++++++++++ 8 files changed, 1096 insertions(+), 42 deletions(-) create mode 100644 camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/DeferredUseCaseCameraRequestControl.kt create mode 100644 camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/DeferredUseCaseCameraRequestControlTest.kt create mode 100644 camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/DeferredUseCaseCameraRequestControl.kt create mode 100644 camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/DeferredUseCaseCameraRequestControlTest.kt diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/DeferredUseCaseCameraRequestControl.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/DeferredUseCaseCameraRequestControl.kt new file mode 100644 index 0000000000000..029fc628d7124 --- /dev/null +++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/DeferredUseCaseCameraRequestControl.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2026 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.camera2.pipe.integration.impl + +import android.hardware.camera2.CaptureRequest +import android.hardware.camera2.params.MeteringRectangle +import androidx.camera.camera2.pipe.AeMode +import androidx.camera.camera2.pipe.Lock3ABehavior +import androidx.camera.camera2.pipe.Result3A +import androidx.camera.camera2.pipe.integration.config.UseCaseCameraScope +import androidx.camera.core.ImageCapture +import androidx.camera.core.UseCase +import androidx.camera.core.impl.CaptureConfig +import androidx.camera.core.impl.Config +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Provider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext + +/** + * A proxy implementation of [UseCaseCameraRequestControlImpl] that allows for lazy initialization. + * + * This class ensures that the creation of the underlying [UseCaseCameraRequestControlImpl] (via + * [Provider.get]) happens on the background sequential thread, preventing main-thread blocking + * during startup. It also ensures strict ordering of requests by dispatching all calls to the + * sequential scope. + */ +@UseCaseCameraScope +public class DeferredUseCaseCameraRequestControl +@Inject +constructor( + private val implProvider: Provider, + private val threads: UseCaseThreads, +) : UseCaseCameraRequestControl { + + @Volatile private var impl: UseCaseCameraRequestControlImpl? = null + + private val isClosed = AtomicBoolean(false) + + /** + * Internal helper to initialize the implementation if needed. Must be called within the + * sequential scope or a lock. + */ + private fun getOrCreateImpl(): UseCaseCameraRequestControlImpl { + if (isClosed.get()) { + throw CancellationException("UseCaseCameraRequestControl is closed") + } + + impl?.let { + return it + } + val instance = implProvider.get() + if (isClosed.get()) { + // Re-check closed state in case close() was called during get() + instance.close() + throw CancellationException("UseCaseCameraRequestControl closed during initialization") + } + impl = instance + return instance + } + + /** Standard utility for methods returning Deferred. */ + private inline fun runOnSequential( + crossinline action: UseCaseCameraRequestControl.() -> Deferred + ): Deferred { + // Fast path: if initialized, run immediately + impl?.let { + return it.action() + } + + // Slow path: initialize on background thread + return threads.sequentialScope.async { getOrCreateImpl().action().await() } + } + + /** Utility for methods returning List> (e.g. issueSingleCaptureAsync). */ + private inline fun runOnSequentialList( + size: Int, + crossinline action: UseCaseCameraRequestControl.() -> List>, + ): List> { + // Fast path + impl?.let { + return it.action() + } + + // Slow path: Create a job that returns the list of deferreds + val submissionJob = threads.sequentialScope.async { getOrCreateImpl().action() } + + return List(size) { index -> + threads.sequentialScope.async { + val realDeferreds = submissionJob.await() + if (index < realDeferreds.size) { + realDeferreds[index].await() + } else { + // This is safe because this method is currently only used with T=Void?, + // where null is the correct return value. + @Suppress("UNCHECKED_CAST") + null as T + } + } + } + } + + /** Utility for suspend functions (e.g. awaitSurfaceSetup). */ + private suspend inline fun runOnSequentialSuspend( + crossinline action: suspend UseCaseCameraRequestControl.() -> T + ): T { + // Fast path + impl?.let { + return it.action() + } + + // Slow path + return withContext(threads.sequentialExecutor.asCoroutineDispatcher()) { + getOrCreateImpl().action() + } + } + + override fun setParametersAsync( + values: Map, Any>, + type: UseCaseCameraRequestControl.Type, + optionPriority: Config.OptionPriority, + ): Deferred = runOnSequential { setParametersAsync(values, type, optionPriority) } + + override fun submitParameters( + values: Map, Any>, + type: UseCaseCameraRequestControl.Type, + optionPriority: Config.OptionPriority, + ): Deferred = runOnSequential { submitParameters(values, type, optionPriority) } + + override fun removeParametersAsync( + keys: List>, + type: UseCaseCameraRequestControl.Type, + ): Deferred = runOnSequential { removeParametersAsync(keys, type) } + + override fun updateRepeatingRequestAsync( + isPrimary: Boolean, + runningUseCases: Collection, + ): Deferred = runOnSequential { updateRepeatingRequestAsync(isPrimary, runningUseCases) } + + override fun updateCamera2ConfigAsync(config: Config, tags: Map): Deferred = + runOnSequential { + updateCamera2ConfigAsync(config, tags) + } + + override fun setTorchOnAsync(): Deferred = runOnSequential { setTorchOnAsync() } + + override fun setTorchOffAsync(aeMode: AeMode): Deferred = runOnSequential { + setTorchOffAsync(aeMode) + } + + override fun startFocusAndMeteringAsync( + aeRegions: List?, + afRegions: List?, + awbRegions: List?, + aeLockBehavior: Lock3ABehavior?, + afLockBehavior: Lock3ABehavior?, + awbLockBehavior: Lock3ABehavior?, + afTriggerStartAeMode: AeMode?, + timeLimitNs: Long, + ): Deferred = runOnSequential { + startFocusAndMeteringAsync( + aeRegions, + afRegions, + awbRegions, + aeLockBehavior, + afLockBehavior, + awbLockBehavior, + afTriggerStartAeMode, + timeLimitNs, + ) + } + + override fun cancelFocusAndMeteringAsync(): Deferred = runOnSequential { + cancelFocusAndMeteringAsync() + } + + override fun update3aRegions( + aeRegions: List?, + afRegions: List?, + awbRegions: List?, + ): Deferred = runOnSequential { update3aRegions(aeRegions, afRegions, awbRegions) } + + override fun issueSingleCaptureAsync( + captureSequence: List, + @ImageCapture.CaptureMode captureMode: Int, + @ImageCapture.FlashType flashType: Int, + @ImageCapture.FlashMode flashMode: Int, + ): List> = + runOnSequentialList(captureSequence.size) { + issueSingleCaptureAsync(captureSequence, captureMode, flashType, flashMode) + } + + override suspend fun awaitSurfaceSetup(): Boolean = runOnSequentialSuspend { + awaitSurfaceSetup() + } + + override fun close() { + if (isClosed.getAndSet(true)) { + return // Already closed + } + // Fire and forget close on the sequential thread + threads.confineLaunch { impl?.close() } + } +} diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControl.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControl.kt index b884cfb69881f..9ab0c0fbbe5d3 100644 --- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControl.kt +++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraRequestControl.kt @@ -283,6 +283,10 @@ constructor( private val cameraXConfig: CameraXConfig? = null, ) : UseCaseCameraRequestControl { + init { + Camera2Logger.debug { "Configured $this" } + } + @Volatile private var closed = false private val capturePipeline by lazy { capturePipelineProvider.get() } @@ -344,7 +348,7 @@ constructor( optionPriority: Config.OptionPriority, ): Deferred { return runIfNotClosed { - threads.confineDeferredSuspend { setParametersInternal(type, values, optionPriority) } + runOnSequential { setParametersInternal(type, values, optionPriority) } } ?: canceledResult } @@ -381,7 +385,7 @@ constructor( type: UseCaseCameraRequestControl.Type, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#removeParametersAsync: [$type] keys = $keys" } @@ -396,7 +400,7 @@ constructor( runningUseCases: Collection, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl: Building SessionConfig..." } val sessionConfigAdapter = SessionConfigAdapter(runningUseCases, isPrimary) @@ -427,7 +431,7 @@ constructor( override fun updateCamera2ConfigAsync(config: Config, tags: Map): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#updateCamera2ConfigAsync" } infoBundleMap[UseCaseCameraRequestControl.Type.CAMERA2_CAMERA_CONTROL] = InfoBundle( @@ -440,7 +444,7 @@ constructor( override fun setTorchOnAsync(): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#setTorchOnAsync" } useGraphSessionOrFailed { it.setTorchOn() } } @@ -448,7 +452,7 @@ constructor( override fun setTorchOffAsync(aeMode: AeMode): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#setTorchOffAsync" } useGraphSessionOrFailed { it.setTorchOff(aeMode = aeMode) } } @@ -465,7 +469,7 @@ constructor( timeLimitNs: Long, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#startFocusAndMeteringAsync" } useGraphSessionOrFailed { it.lock3A( @@ -485,7 +489,7 @@ constructor( override fun cancelFocusAndMeteringAsync(): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#cancelFocusAndMeteringAsync" } @@ -509,7 +513,7 @@ constructor( @ImageCapture.FlashMode flashMode: Int, ): List> = runIfNotClosed { - threads.confineDeferredListSuspend(captureSequence.size) { + runOnSequentialList(captureSequence.size) { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#issueSingleCaptureAsync" } if (captureSequence.hasInvalidSurface()) { @@ -545,7 +549,7 @@ constructor( awbRegions: List?, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#update3aRegions" } useGraphSessionOrFailed { it.update3A( @@ -655,12 +659,32 @@ constructor( submitFailedResult } + private fun runOnSequential(block: suspend () -> Deferred): Deferred { + val start = threads.determineStartStrategy() + return threads.confineDeferredSuspend(start = start, block = block) + } + + private fun runOnSequentialList( + size: Int, + block: suspend () -> List>, + ): List> { + val start = threads.determineStartStrategy() + return threads.confineDeferredListSuspend(size = size, start = start, block = block) + } + + /** + * Checks if the current thread is the sequential thread. Returns UNDISPATCHED if true (to + * execute immediately), DEFAULT otherwise. + */ + internal fun UseCaseThreads.determineStartStrategy(): CoroutineStart = + if (isOnSequentialThread()) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT + @Module public abstract class Bindings { @UseCaseCameraScope @Binds - public abstract fun provideRequestControls( - requestControl: UseCaseCameraRequestControlImpl + public abstract fun bindRequestControl( + requestControl: DeferredUseCaseCameraRequestControl ): UseCaseCameraRequestControl } diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseThreads.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseThreads.kt index 1ce43de730393..9574b1ea41fef 100644 --- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseThreads.kt +++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseThreads.kt @@ -25,6 +25,7 @@ import java.util.concurrent.Executor import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -47,6 +48,14 @@ public class UseCaseThreads( */ private val isSequentialThread = ThreadLocal() + /** + * Returns true if the current thread is the one associated with the [sequentialScope]. + * + * This is useful for determining dispatching strategies (e.g., UNDISPATCHED) without the + * performance overhead of exception handling. + */ + public fun isOnSequentialThread(): Boolean = isSequentialThread.get() == true + /** * Executor that enforces sequential execution and sets the [isSequentialThread] flag during * task execution. @@ -75,7 +84,7 @@ public class UseCaseThreads( * @throws IllegalStateException if called from a different thread context. */ public fun checkOnSequentialThread() { - check(isSequentialThread.get() == true) { + check(isOnSequentialThread()) { "Thread check failed: This method must be called from the UseCaseThreads sequential " + "scope. Current thread: ${Thread.currentThread().name}" } @@ -128,36 +137,45 @@ public class UseCaseThreads( * Cancelling the `Deferred` returned from this function does not cancel the `Deferred` returned * by `block`. * + * @param block The suspend lambda that returns a [Deferred]. + * @param start The start option for the coroutine. Defaults to [CoroutineStart.DEFAULT]. + * **Note:** Do not use [CoroutineStart.LAZY] as the internal [Job] is not returned, meaning + * the coroutine cannot be started manually. * @return A [Deferred] which is completed as per the [Deferred] returned from [block] via * [propagateTo]. */ public inline fun confineDeferredSuspend( - crossinline block: suspend () -> Deferred + crossinline block: suspend () -> Deferred, + start: CoroutineStart = CoroutineStart.DEFAULT, ): Deferred { val signal = CompletableDeferred() - sequentialScope.launch { block().propagateTo(signal) } + sequentialScope.launch(start = start) { block().propagateTo(signal) } return signal } /** - * Confines a [Deferred] list returning suspendable `block` parameter to + * Confines a [Deferred] list returning suspendable [block] parameter to * [UseCaseThreads.sequentialScope] for the purpose of thread confinement and returns a new - * `Deferred` list with one-to-one mapping. + * [Deferred] list with one-to-one mapping. * - * Cancelling a `Deferred` returned from this function does not cancel the corresponding - * `Deferred` returned by `block`. + * Cancelling a [Deferred] returned from this function does not cancel the corresponding + * [Deferred] returned by [block]. * * @param size Size of the list returned from [block], the list returned via this function will - * also have th same size. + * also have the same size. + * @param start Coroutine starting strategy (default: [CoroutineStart.DEFAULT]). Use + * [CoroutineStart.UNDISPATCHED] to start execution immediately on the current thread if the + * caller is already on the sequential thread. * @return A list of [Deferred] where each element is completed as per the corresponding * [Deferred] in the list returned from [block]. */ public inline fun confineDeferredListSuspend( size: Int, + start: CoroutineStart = CoroutineStart.DEFAULT, crossinline block: suspend () -> List>, ): List> { val deferredList = List(size) { CompletableDeferred() } - sequentialScope.launch { + sequentialScope.launch(start = start) { block().forEachIndexed { index, deferred -> deferred.propagateTo(deferredList[index]) } } return deferredList diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/DeferredUseCaseCameraRequestControlTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/DeferredUseCaseCameraRequestControlTest.kt new file mode 100644 index 0000000000000..c6ec0cbcec453 --- /dev/null +++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/DeferredUseCaseCameraRequestControlTest.kt @@ -0,0 +1,263 @@ +/* + * Copyright 2026 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.camera2.pipe.integration.impl + +import android.hardware.camera2.CaptureRequest +import androidx.camera.camera2.pipe.Result3A +import androidx.camera.camera2.pipe.integration.adapter.RobolectricCameraPipeTestRunner +import androidx.camera.core.impl.CaptureConfig +import androidx.camera.core.impl.Config +import androidx.camera.core.impl.utils.executor.CameraXExecutors +import androidx.camera.testing.impl.fakes.FakeUseCase +import com.google.common.truth.Truth.assertThat +import javax.inject.Provider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.anyMap +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.robolectric.annotation.Config as RoboConfig +import org.robolectric.annotation.internal.DoNotInstrument + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricCameraPipeTestRunner::class) +@DoNotInstrument +@RoboConfig(sdk = [RoboConfig.ALL_SDKS]) +class DeferredUseCaseCameraRequestControlImplTest { + + @Mock private lateinit var mockImplProvider: Provider + + @Mock private lateinit var mockImpl: UseCaseCameraRequestControlImpl + + private lateinit var useCaseThreads: UseCaseThreads + private lateinit var deferredControl: DeferredUseCaseCameraRequestControl + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + val sequentialExecutor = + CameraXExecutors.newSequentialExecutor(CameraXExecutors.directExecutor()) + + val sequentialDispatcher = sequentialExecutor.asCoroutineDispatcher() + + useCaseThreads = UseCaseThreads(testScope, sequentialExecutor, sequentialDispatcher) + + `when`(mockImplProvider.get()).thenReturn(mockImpl) + + deferredControl = DeferredUseCaseCameraRequestControl(mockImplProvider, useCaseThreads) + } + + @Test + fun initializationFailure_propagatesException() = + testScope.runTest { + `when`(mockImplProvider.get()).thenThrow(RuntimeException("Init failed")) + val deferred = deferredControl.setTorchOnAsync() + advanceUntilIdle() + assertThat(deferred.isCompleted).isTrue() + assertThat(deferred.getCompletionExceptionOrNull()).isNotNull() + } + + @Test + fun constructor_doesNotInitializeImpl() { + // Assert: Simply creating the instance should not trigger the Provider + verify(mockImplProvider, never()).get() + } + + @Test + fun setParametersAsync_initializesImplAndDelegates() = + testScope.runTest { + // Arrange + val values = + mapOf, Any>( + CaptureRequest.CONTROL_AE_MODE to CaptureRequest.CONTROL_AE_MODE_ON + ) + val deferredResult = CompletableDeferred(Unit) + + `when`(mockImpl.setParametersAsync(anyMap(), any(), any())).thenReturn(deferredResult) + + // Act + deferredControl.setParametersAsync( + values, + UseCaseCameraRequestControl.Type.DEFAULT, + Config.OptionPriority.OPTIONAL, + ) + + advanceUntilIdle() // Ensure the sequential coroutine runs + + // Assert + verify(mockImplProvider, times(1)).get() + verify(mockImpl) + .setParametersAsync( + values, + UseCaseCameraRequestControl.Type.DEFAULT, + Config.OptionPriority.OPTIONAL, + ) + } + + @Test + fun subsequentCalls_doNotReinitializeImpl() = + testScope.runTest { + // Arrange + `when`(mockImpl.setTorchOnAsync()) + .thenReturn(CompletableDeferred(Result3A(Result3A.Status.OK))) + + // Act + deferredControl.setTorchOnAsync() + advanceUntilIdle() + + deferredControl.setTorchOnAsync() + advanceUntilIdle() + + // Assert + verify(mockImplProvider, times(1)).get() + verify(mockImpl, times(2)).setTorchOnAsync() + } + + @Test + fun updateRepeatingRequestAsync_delegatesWithCorrectArguments() = + testScope.runTest { + // Arrange + val fakeUseCase = FakeUseCase() + val runningUseCases = listOf(fakeUseCase) + + `when`(mockImpl.updateRepeatingRequestAsync(anyBoolean(), anyList())) + .thenReturn(CompletableDeferred(Unit)) + + // Act + deferredControl.updateRepeatingRequestAsync( + isPrimary = true, + runningUseCases = runningUseCases, + ) + advanceUntilIdle() + + // Assert + verify(mockImpl).updateRepeatingRequestAsync(true, runningUseCases) + } + + @Test + fun issueSingleCaptureAsync_delegatesAndReturnsMappedDeferreds() = + testScope.runTest { + // Arrange + val captureConfig = CaptureConfig.Builder().build() + val sequence = listOf(captureConfig, captureConfig) + + val mockDeferred1 = CompletableDeferred().apply { complete(null) } + val mockDeferred2 = CompletableDeferred().apply { complete(null) } + val implDeferreds = listOf(mockDeferred1, mockDeferred2) + + `when`(mockImpl.issueSingleCaptureAsync(anyList(), anyInt(), anyInt(), anyInt())) + .thenReturn(implDeferreds) + + // Act + val resultDeferreds = deferredControl.issueSingleCaptureAsync(sequence, 0, 0, 0) + advanceUntilIdle() + + // Assert + verify(mockImplProvider).get() + verify(mockImpl).issueSingleCaptureAsync(sequence, 0, 0, 0) + + assertThat(resultDeferreds).hasSize(2) + assertThat(resultDeferreds[0].isCompleted).isTrue() + } + + @Test + fun close_callsCloseOnImpl_onlyIfInitialized() = + testScope.runTest { + // Arrange: Initialize it first + `when`(mockImpl.setTorchOnAsync()) + .thenReturn(CompletableDeferred(Result3A(Result3A.Status.OK))) + deferredControl.setTorchOnAsync() + advanceUntilIdle() + + // Act + deferredControl.close() + advanceUntilIdle() + + // Assert + verify(mockImpl).close() + } + + @Test + fun close_doesNotInitializeImpl_ifNotCalledBefore() = + testScope.runTest { + // Act + deferredControl.close() + advanceUntilIdle() + + // Assert + verify(mockImplProvider, never()).get() + } + + @Test + fun awaitSurfaceSetup_initializesAndDelegates() = + testScope.runTest { + // Arrange + `when`(mockImpl.awaitSurfaceSetup()).thenReturn(true) + + // Act + val result = deferredControl.awaitSurfaceSetup() + + // Assert + verify(mockImplProvider).get() + verify(mockImpl).awaitSurfaceSetup() + assertThat(result).isTrue() + } + + @Test + fun submitParameters_runsOnSequentialThread() = + testScope.runTest { + val values = mapOf, Any>() + + `when`(mockImpl.submitParameters(anyMap(), any(), any())) + .thenReturn(CompletableDeferred(Unit)) + + deferredControl.submitParameters( + values, + UseCaseCameraRequestControl.Type.DEFAULT, + Config.OptionPriority.OPTIONAL, + ) + advanceUntilIdle() + + verify(mockImplProvider).get() + verify(mockImpl) + .submitParameters( + values, + UseCaseCameraRequestControl.Type.DEFAULT, + Config.OptionPriority.OPTIONAL, + ) + } +} diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/DeferredUseCaseCameraRequestControl.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/DeferredUseCaseCameraRequestControl.kt new file mode 100644 index 0000000000000..4e7eddbc6c0a5 --- /dev/null +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/DeferredUseCaseCameraRequestControl.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2026 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.camera2.impl + +import android.hardware.camera2.CaptureRequest +import android.hardware.camera2.params.MeteringRectangle +import androidx.camera.camera2.config.UseCaseCameraScope +import androidx.camera.camera2.pipe.AeMode +import androidx.camera.camera2.pipe.Lock3ABehavior +import androidx.camera.camera2.pipe.Result3A +import androidx.camera.core.ImageCapture +import androidx.camera.core.UseCase +import androidx.camera.core.impl.CaptureConfig +import androidx.camera.core.impl.Config +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Provider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext + +/** + * A proxy implementation of [UseCaseCameraRequestControlImpl] that allows for lazy initialization. + * + * This class ensures that the creation of the underlying [UseCaseCameraRequestControlImpl] (via + * [Provider.get]) happens on the background sequential thread, preventing main-thread blocking + * during startup. It also ensures strict ordering of requests by dispatching all calls to the + * sequential scope. + */ +@UseCaseCameraScope +public class DeferredUseCaseCameraRequestControl +@Inject +constructor( + private val implProvider: Provider, + private val threads: UseCaseThreads, +) : UseCaseCameraRequestControl { + + @Volatile private var impl: UseCaseCameraRequestControlImpl? = null + + private val isClosed = AtomicBoolean(false) + + /** + * Internal helper to initialize the implementation if needed. Must be called within the + * sequential scope or a lock. + */ + private fun getOrCreateImpl(): UseCaseCameraRequestControlImpl { + if (isClosed.get()) { + throw CancellationException("UseCaseCameraRequestControl is closed") + } + + impl?.let { + return it + } + val instance = implProvider.get() + if (isClosed.get()) { + // Re-check closed state in case close() was called during get() + instance.close() + throw CancellationException("UseCaseCameraRequestControl closed during initialization") + } + impl = instance + return instance + } + + /** Standard utility for methods returning Deferred. */ + private inline fun runOnSequential( + crossinline action: UseCaseCameraRequestControl.() -> Deferred + ): Deferred { + // Fast path: if initialized, run immediately + impl?.let { + return it.action() + } + + // Slow path: initialize on background thread + return threads.sequentialScope.async { getOrCreateImpl().action().await() } + } + + /** Utility for methods returning List> (e.g. issueSingleCaptureAsync). */ + private inline fun runOnSequentialList( + size: Int, + crossinline action: UseCaseCameraRequestControl.() -> List>, + ): List> { + // Fast path + impl?.let { + return it.action() + } + + // Slow path: Create a job that returns the list of deferreds + val submissionJob = threads.sequentialScope.async { getOrCreateImpl().action() } + + return List(size) { index -> + threads.sequentialScope.async { + val realDeferreds = submissionJob.await() + if (index < realDeferreds.size) { + realDeferreds[index].await() + } else { + // This is safe because this method is currently only used with T=Void?, + // where null is the correct return value. + @Suppress("UNCHECKED_CAST") + null as T + } + } + } + } + + /** Utility for suspend functions (e.g. awaitSurfaceSetup). */ + private suspend inline fun runOnSequentialSuspend( + crossinline action: suspend UseCaseCameraRequestControl.() -> T + ): T { + // Fast path + impl?.let { + return it.action() + } + + // Slow path + return withContext(threads.sequentialExecutor.asCoroutineDispatcher()) { + getOrCreateImpl().action() + } + } + + override fun setParametersAsync( + values: Map, Any>, + type: UseCaseCameraRequestControl.Type, + optionPriority: Config.OptionPriority, + ): Deferred = runOnSequential { setParametersAsync(values, type, optionPriority) } + + override fun submitParameters( + values: Map, Any>, + type: UseCaseCameraRequestControl.Type, + optionPriority: Config.OptionPriority, + ): Deferred = runOnSequential { submitParameters(values, type, optionPriority) } + + override fun removeParametersAsync( + keys: List>, + type: UseCaseCameraRequestControl.Type, + ): Deferred = runOnSequential { removeParametersAsync(keys, type) } + + override fun updateRepeatingRequestAsync( + isPrimary: Boolean, + runningUseCases: Collection, + ): Deferred = runOnSequential { updateRepeatingRequestAsync(isPrimary, runningUseCases) } + + override fun updateCamera2ConfigAsync(config: Config, tags: Map): Deferred = + runOnSequential { + updateCamera2ConfigAsync(config, tags) + } + + override fun setTorchOnAsync(): Deferred = runOnSequential { setTorchOnAsync() } + + override fun setTorchOffAsync(aeMode: AeMode): Deferred = runOnSequential { + setTorchOffAsync(aeMode) + } + + override fun startFocusAndMeteringAsync( + aeRegions: List?, + afRegions: List?, + awbRegions: List?, + aeLockBehavior: Lock3ABehavior?, + afLockBehavior: Lock3ABehavior?, + awbLockBehavior: Lock3ABehavior?, + afTriggerStartAeMode: AeMode?, + timeLimitNs: Long, + ): Deferred = runOnSequential { + startFocusAndMeteringAsync( + aeRegions, + afRegions, + awbRegions, + aeLockBehavior, + afLockBehavior, + awbLockBehavior, + afTriggerStartAeMode, + timeLimitNs, + ) + } + + override fun cancelFocusAndMeteringAsync(): Deferred = runOnSequential { + cancelFocusAndMeteringAsync() + } + + override fun update3aRegions( + aeRegions: List?, + afRegions: List?, + awbRegions: List?, + ): Deferred = runOnSequential { update3aRegions(aeRegions, afRegions, awbRegions) } + + override fun issueSingleCaptureAsync( + captureSequence: List, + @ImageCapture.CaptureMode captureMode: Int, + @ImageCapture.FlashType flashType: Int, + @ImageCapture.FlashMode flashMode: Int, + ): List> = + runOnSequentialList(captureSequence.size) { + issueSingleCaptureAsync(captureSequence, captureMode, flashType, flashMode) + } + + override suspend fun awaitSurfaceSetup(): Boolean = runOnSequentialSuspend { + awaitSurfaceSetup() + } + + override fun close() { + if (isClosed.getAndSet(true)) { + return // Already closed + } + // Fire and forget close on the sequential thread + threads.confineLaunch { impl?.close() } + } +} diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCameraRequestControl.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCameraRequestControl.kt index ce84f914c8970..5e4d4c9f60ab3 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCameraRequestControl.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseCameraRequestControl.kt @@ -283,6 +283,10 @@ constructor( private val cameraXConfig: CameraXConfig? = null, ) : UseCaseCameraRequestControl { + init { + Camera2Logger.debug { "Configured $this" } + } + @Volatile private var closed = false private val capturePipeline by lazy { capturePipelineProvider.get() } @@ -344,7 +348,7 @@ constructor( optionPriority: Config.OptionPriority, ): Deferred { return runIfNotClosed { - threads.confineDeferredSuspend { setParametersInternal(type, values, optionPriority) } + runOnSequential { setParametersInternal(type, values, optionPriority) } } ?: canceledResult } @@ -381,7 +385,7 @@ constructor( type: UseCaseCameraRequestControl.Type, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#removeParametersAsync: [$type] keys = $keys" } @@ -396,7 +400,7 @@ constructor( runningUseCases: Collection, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl: Building SessionConfig..." } val sessionConfigAdapter = SessionConfigAdapter(runningUseCases, isPrimary) @@ -427,7 +431,7 @@ constructor( override fun updateCamera2ConfigAsync(config: Config, tags: Map): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#updateCamera2ConfigAsync" } infoBundleMap[UseCaseCameraRequestControl.Type.CAMERA2_CAMERA_CONTROL] = InfoBundle( @@ -440,7 +444,7 @@ constructor( override fun setTorchOnAsync(): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#setTorchOnAsync" } useGraphSessionOrFailed { it.setTorchOn() } } @@ -448,7 +452,7 @@ constructor( override fun setTorchOffAsync(aeMode: AeMode): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#setTorchOffAsync" } useGraphSessionOrFailed { it.setTorchOff(aeMode = aeMode) } } @@ -465,7 +469,7 @@ constructor( timeLimitNs: Long, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#startFocusAndMeteringAsync" } useGraphSessionOrFailed { it.lock3A( @@ -485,7 +489,7 @@ constructor( override fun cancelFocusAndMeteringAsync(): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#cancelFocusAndMeteringAsync" } @@ -509,7 +513,7 @@ constructor( @ImageCapture.FlashMode flashMode: Int, ): List> = runIfNotClosed { - threads.confineDeferredListSuspend(captureSequence.size) { + runOnSequentialList(captureSequence.size) { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#issueSingleCaptureAsync" } if (captureSequence.hasInvalidSurface()) { @@ -545,7 +549,7 @@ constructor( awbRegions: List?, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#update3aRegions" } useGraphSessionOrFailed { it.update3A( @@ -655,12 +659,32 @@ constructor( submitFailedResult } + private fun runOnSequential(block: suspend () -> Deferred): Deferred { + val start = threads.determineStartStrategy() + return threads.confineDeferredSuspend(start = start, block = block) + } + + private fun runOnSequentialList( + size: Int, + block: suspend () -> List>, + ): List> { + val start = threads.determineStartStrategy() + return threads.confineDeferredListSuspend(size = size, start = start, block = block) + } + + /** + * Checks if the current thread is the sequential thread. Returns UNDISPATCHED if true (to + * execute immediately), DEFAULT otherwise. + */ + internal fun UseCaseThreads.determineStartStrategy(): CoroutineStart = + if (isOnSequentialThread()) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT + @Module public abstract class Bindings { @UseCaseCameraScope @Binds - public abstract fun provideRequestControls( - requestControl: UseCaseCameraRequestControlImpl + public abstract fun bindRequestControl( + requestControl: DeferredUseCaseCameraRequestControl ): UseCaseCameraRequestControl } diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseThreads.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseThreads.kt index d5c0b12ab1070..a51eaa7d6546f 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseThreads.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/UseCaseThreads.kt @@ -25,6 +25,7 @@ import java.util.concurrent.Executor import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -47,6 +48,14 @@ public class UseCaseThreads( */ private val isSequentialThread = ThreadLocal() + /** + * Returns true if the current thread is the one associated with the [sequentialScope]. + * + * This is useful for determining dispatching strategies (e.g., UNDISPATCHED) without the + * performance overhead of exception handling. + */ + public fun isOnSequentialThread(): Boolean = isSequentialThread.get() == true + /** * Executor that enforces sequential execution and sets the [isSequentialThread] flag during * task execution. @@ -75,7 +84,7 @@ public class UseCaseThreads( * @throws IllegalStateException if called from a different thread context. */ public fun checkOnSequentialThread() { - check(isSequentialThread.get() == true) { + check(isOnSequentialThread()) { "Thread check failed: This method must be called from the UseCaseThreads sequential " + "scope. Current thread: ${Thread.currentThread().name}" } @@ -128,36 +137,45 @@ public class UseCaseThreads( * Cancelling the `Deferred` returned from this function does not cancel the `Deferred` returned * by `block`. * + * @param block The suspend lambda that returns a [Deferred]. + * @param start The start option for the coroutine. Defaults to [CoroutineStart.DEFAULT]. + * **Note:** Do not use [CoroutineStart.LAZY] as the internal [Job] is not returned, meaning + * the coroutine cannot be started manually. * @return A [Deferred] which is completed as per the [Deferred] returned from [block] via * [propagateTo]. */ public inline fun confineDeferredSuspend( - crossinline block: suspend () -> Deferred + crossinline block: suspend () -> Deferred, + start: CoroutineStart = CoroutineStart.DEFAULT, ): Deferred { val signal = CompletableDeferred() - sequentialScope.launch { block().propagateTo(signal) } + sequentialScope.launch(start = start) { block().propagateTo(signal) } return signal } /** - * Confines a [Deferred] list returning suspendable `block` parameter to + * Confines a [Deferred] list returning suspendable [block] parameter to * [UseCaseThreads.sequentialScope] for the purpose of thread confinement and returns a new - * `Deferred` list with one-to-one mapping. + * [Deferred] list with one-to-one mapping. * - * Cancelling a `Deferred` returned from this function does not cancel the corresponding - * `Deferred` returned by `block`. + * Cancelling a [Deferred] returned from this function does not cancel the corresponding + * [Deferred] returned by [block]. * * @param size Size of the list returned from [block], the list returned via this function will - * also have th same size. + * also have the same size. + * @param start Coroutine starting strategy (default: [CoroutineStart.DEFAULT]). Use + * [CoroutineStart.UNDISPATCHED] to start execution immediately on the current thread if the + * caller is already on the sequential thread. * @return A list of [Deferred] where each element is completed as per the corresponding * [Deferred] in the list returned from [block]. */ public inline fun confineDeferredListSuspend( size: Int, + start: CoroutineStart = CoroutineStart.DEFAULT, crossinline block: suspend () -> List>, ): List> { val deferredList = List(size) { CompletableDeferred() } - sequentialScope.launch { + sequentialScope.launch(start = start) { block().forEachIndexed { index, deferred -> deferred.propagateTo(deferredList[index]) } } return deferredList diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/DeferredUseCaseCameraRequestControlTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/DeferredUseCaseCameraRequestControlTest.kt new file mode 100644 index 0000000000000..ad759452515fe --- /dev/null +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/DeferredUseCaseCameraRequestControlTest.kt @@ -0,0 +1,263 @@ +/* + * Copyright 2026 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.camera2.impl + +import android.hardware.camera2.CaptureRequest +import androidx.camera.camera2.adapter.RobolectricCameraPipeTestRunner +import androidx.camera.camera2.pipe.Result3A +import androidx.camera.core.impl.CaptureConfig +import androidx.camera.core.impl.Config +import androidx.camera.core.impl.utils.executor.CameraXExecutors +import androidx.camera.testing.impl.fakes.FakeUseCase +import com.google.common.truth.Truth.assertThat +import javax.inject.Provider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.anyMap +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.robolectric.annotation.Config as RoboConfig +import org.robolectric.annotation.internal.DoNotInstrument + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricCameraPipeTestRunner::class) +@DoNotInstrument +@RoboConfig(sdk = [RoboConfig.ALL_SDKS]) +class DeferredUseCaseCameraRequestControlImplTest { + + @Mock private lateinit var mockImplProvider: Provider + + @Mock private lateinit var mockImpl: UseCaseCameraRequestControlImpl + + private lateinit var useCaseThreads: UseCaseThreads + private lateinit var deferredControl: DeferredUseCaseCameraRequestControl + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + val sequentialExecutor = + CameraXExecutors.newSequentialExecutor(CameraXExecutors.directExecutor()) + + val sequentialDispatcher = sequentialExecutor.asCoroutineDispatcher() + + useCaseThreads = UseCaseThreads(testScope, sequentialExecutor, sequentialDispatcher) + + `when`(mockImplProvider.get()).thenReturn(mockImpl) + + deferredControl = DeferredUseCaseCameraRequestControl(mockImplProvider, useCaseThreads) + } + + @Test + fun initializationFailure_propagatesException() = + testScope.runTest { + `when`(mockImplProvider.get()).thenThrow(RuntimeException("Init failed")) + val deferred = deferredControl.setTorchOnAsync() + advanceUntilIdle() + assertThat(deferred.isCompleted).isTrue() + assertThat(deferred.getCompletionExceptionOrNull()).isNotNull() + } + + @Test + fun constructor_doesNotInitializeImpl() { + // Assert: Simply creating the instance should not trigger the Provider + verify(mockImplProvider, never()).get() + } + + @Test + fun setParametersAsync_initializesImplAndDelegates() = + testScope.runTest { + // Arrange + val values = + mapOf, Any>( + CaptureRequest.CONTROL_AE_MODE to CaptureRequest.CONTROL_AE_MODE_ON + ) + val deferredResult = CompletableDeferred(Unit) + + `when`(mockImpl.setParametersAsync(anyMap(), any(), any())).thenReturn(deferredResult) + + // Act + deferredControl.setParametersAsync( + values, + UseCaseCameraRequestControl.Type.DEFAULT, + Config.OptionPriority.OPTIONAL, + ) + + advanceUntilIdle() // Ensure the sequential coroutine runs + + // Assert + verify(mockImplProvider, times(1)).get() + verify(mockImpl) + .setParametersAsync( + values, + UseCaseCameraRequestControl.Type.DEFAULT, + Config.OptionPriority.OPTIONAL, + ) + } + + @Test + fun subsequentCalls_doNotReinitializeImpl() = + testScope.runTest { + // Arrange + `when`(mockImpl.setTorchOnAsync()) + .thenReturn(CompletableDeferred(Result3A(Result3A.Status.OK))) + + // Act + deferredControl.setTorchOnAsync() + advanceUntilIdle() + + deferredControl.setTorchOnAsync() + advanceUntilIdle() + + // Assert + verify(mockImplProvider, times(1)).get() + verify(mockImpl, times(2)).setTorchOnAsync() + } + + @Test + fun updateRepeatingRequestAsync_delegatesWithCorrectArguments() = + testScope.runTest { + // Arrange + val fakeUseCase = FakeUseCase() + val runningUseCases = listOf(fakeUseCase) + + `when`(mockImpl.updateRepeatingRequestAsync(anyBoolean(), anyList())) + .thenReturn(CompletableDeferred(Unit)) + + // Act + deferredControl.updateRepeatingRequestAsync( + isPrimary = true, + runningUseCases = runningUseCases, + ) + advanceUntilIdle() + + // Assert + verify(mockImpl).updateRepeatingRequestAsync(true, runningUseCases) + } + + @Test + fun issueSingleCaptureAsync_delegatesAndReturnsMappedDeferreds() = + testScope.runTest { + // Arrange + val captureConfig = CaptureConfig.Builder().build() + val sequence = listOf(captureConfig, captureConfig) + + val mockDeferred1 = CompletableDeferred().apply { complete(null) } + val mockDeferred2 = CompletableDeferred().apply { complete(null) } + val implDeferreds = listOf(mockDeferred1, mockDeferred2) + + `when`(mockImpl.issueSingleCaptureAsync(anyList(), anyInt(), anyInt(), anyInt())) + .thenReturn(implDeferreds) + + // Act + val resultDeferreds = deferredControl.issueSingleCaptureAsync(sequence, 0, 0, 0) + advanceUntilIdle() + + // Assert + verify(mockImplProvider).get() + verify(mockImpl).issueSingleCaptureAsync(sequence, 0, 0, 0) + + assertThat(resultDeferreds).hasSize(2) + assertThat(resultDeferreds[0].isCompleted).isTrue() + } + + @Test + fun close_callsCloseOnImpl_onlyIfInitialized() = + testScope.runTest { + // Arrange: Initialize it first + `when`(mockImpl.setTorchOnAsync()) + .thenReturn(CompletableDeferred(Result3A(Result3A.Status.OK))) + deferredControl.setTorchOnAsync() + advanceUntilIdle() + + // Act + deferredControl.close() + advanceUntilIdle() + + // Assert + verify(mockImpl).close() + } + + @Test + fun close_doesNotInitializeImpl_ifNotCalledBefore() = + testScope.runTest { + // Act + deferredControl.close() + advanceUntilIdle() + + // Assert + verify(mockImplProvider, never()).get() + } + + @Test + fun awaitSurfaceSetup_initializesAndDelegates() = + testScope.runTest { + // Arrange + `when`(mockImpl.awaitSurfaceSetup()).thenReturn(true) + + // Act + val result = deferredControl.awaitSurfaceSetup() + + // Assert + verify(mockImplProvider).get() + verify(mockImpl).awaitSurfaceSetup() + assertThat(result).isTrue() + } + + @Test + fun submitParameters_runsOnSequentialThread() = + testScope.runTest { + val values = mapOf, Any>() + + `when`(mockImpl.submitParameters(anyMap(), any(), any())) + .thenReturn(CompletableDeferred(Unit)) + + deferredControl.submitParameters( + values, + UseCaseCameraRequestControl.Type.DEFAULT, + Config.OptionPriority.OPTIONAL, + ) + advanceUntilIdle() + + verify(mockImplProvider).get() + verify(mockImpl) + .submitParameters( + values, + UseCaseCameraRequestControl.Type.DEFAULT, + Config.OptionPriority.OPTIONAL, + ) + } +}