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/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/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..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 @@ -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,24 @@ 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 { + init { + Camera2Logger.debug { "Configured $this" } + } + @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(), @@ -339,7 +348,7 @@ constructor( optionPriority: Config.OptionPriority, ): Deferred { return runIfNotClosed { - threads.confineDeferredSuspend { setParametersInternal(type, values, optionPriority) } + runOnSequential { setParametersInternal(type, values, optionPriority) } } ?: canceledResult } @@ -376,7 +385,7 @@ constructor( type: UseCaseCameraRequestControl.Type, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#removeParametersAsync: [$type] keys = $keys" } @@ -391,7 +400,7 @@ constructor( runningUseCases: Collection, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl: Building SessionConfig..." } val sessionConfigAdapter = SessionConfigAdapter(runningUseCases, isPrimary) @@ -422,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( @@ -435,7 +444,7 @@ constructor( override fun setTorchOnAsync(): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#setTorchOnAsync" } useGraphSessionOrFailed { it.setTorchOn() } } @@ -443,7 +452,7 @@ constructor( override fun setTorchOffAsync(aeMode: AeMode): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#setTorchOffAsync" } useGraphSessionOrFailed { it.setTorchOff(aeMode = aeMode) } } @@ -460,7 +469,7 @@ constructor( timeLimitNs: Long, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#startFocusAndMeteringAsync" } useGraphSessionOrFailed { it.lock3A( @@ -480,7 +489,7 @@ constructor( override fun cancelFocusAndMeteringAsync(): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#cancelFocusAndMeteringAsync" } @@ -504,7 +513,7 @@ constructor( @ImageCapture.FlashMode flashMode: Int, ): List> = runIfNotClosed { - threads.confineDeferredListSuspend(captureSequence.size) { + runOnSequentialList(captureSequence.size) { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#issueSingleCaptureAsync" } if (captureSequence.hasInvalidSurface()) { @@ -540,7 +549,7 @@ constructor( awbRegions: List?, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#update3aRegions" } useGraphSessionOrFailed { it.update3A( @@ -625,7 +634,7 @@ constructor( DEFAULT_REQUEST_TEMPLATE } - state.updateAsync( + useCaseCameraState.updateAsync( parameters = options.build().toParameters(), appendParameters = false, internalParameters = mapOf(CAMERAX_TAG_BUNDLE to toTagBundle()), @@ -650,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/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/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/CapturePipelineTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/CapturePipelineTest.kt index 9c1662610fb53..ad26a450cabeb 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 @@ -1134,7 +1134,7 @@ class CapturePipelineTest { val capturePipelineTorchCorrection = CapturePipelineTorchCorrection( cameraProperties = FakeCameraProperties(), - capturePipelineImpl = capturePipeline, + capturePipelineImplProvider = { capturePipeline }, threads = fakeUseCaseThreads, torchControl = torchControl, ) @@ -1311,7 +1311,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/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-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/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/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..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 @@ -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,24 @@ 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 { + init { + Camera2Logger.debug { "Configured $this" } + } + @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(), @@ -339,7 +348,7 @@ constructor( optionPriority: Config.OptionPriority, ): Deferred { return runIfNotClosed { - threads.confineDeferredSuspend { setParametersInternal(type, values, optionPriority) } + runOnSequential { setParametersInternal(type, values, optionPriority) } } ?: canceledResult } @@ -376,7 +385,7 @@ constructor( type: UseCaseCameraRequestControl.Type, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#removeParametersAsync: [$type] keys = $keys" } @@ -391,7 +400,7 @@ constructor( runningUseCases: Collection, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl: Building SessionConfig..." } val sessionConfigAdapter = SessionConfigAdapter(runningUseCases, isPrimary) @@ -422,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( @@ -435,7 +444,7 @@ constructor( override fun setTorchOnAsync(): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#setTorchOnAsync" } useGraphSessionOrFailed { it.setTorchOn() } } @@ -443,7 +452,7 @@ constructor( override fun setTorchOffAsync(aeMode: AeMode): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#setTorchOffAsync" } useGraphSessionOrFailed { it.setTorchOff(aeMode = aeMode) } } @@ -460,7 +469,7 @@ constructor( timeLimitNs: Long, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#startFocusAndMeteringAsync" } useGraphSessionOrFailed { it.lock3A( @@ -480,7 +489,7 @@ constructor( override fun cancelFocusAndMeteringAsync(): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#cancelFocusAndMeteringAsync" } @@ -504,7 +513,7 @@ constructor( @ImageCapture.FlashMode flashMode: Int, ): List> = runIfNotClosed { - threads.confineDeferredListSuspend(captureSequence.size) { + runOnSequentialList(captureSequence.size) { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#issueSingleCaptureAsync" } if (captureSequence.hasInvalidSurface()) { @@ -540,7 +549,7 @@ constructor( awbRegions: List?, ): Deferred = runIfNotClosed { - threads.confineDeferredSuspend { + runOnSequential { Camera2Logger.debug { "UseCaseCameraRequestControlImpl#update3aRegions" } useGraphSessionOrFailed { it.update3A( @@ -625,7 +634,7 @@ constructor( DEFAULT_REQUEST_TEMPLATE } - state.updateAsync( + useCaseCameraState.updateAsync( parameters = options.build().toParameters(), appendParameters = false, internalParameters = mapOf(CAMERAX_TAG_BUNDLE to toTagBundle()), @@ -650,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/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/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/CapturePipelineTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/CapturePipelineTest.kt index 83c25f0bc7081..d8087e438f972 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 @@ -1135,7 +1135,7 @@ class CapturePipelineTest { val capturePipelineTorchCorrection = CapturePipelineTorchCorrection( cameraProperties = FakeCameraProperties(), - capturePipelineImpl = capturePipeline, + capturePipelineImplProvider = { capturePipeline }, threads = fakeUseCaseThreads, torchControl = torchControl, ) @@ -1312,7 +1312,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/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, + ) + } +} 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, 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/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..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]. * @@ -72,13 +111,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 +221,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 +232,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/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/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? = 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 8f53887fc728a..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]. * @@ -153,7 +184,7 @@ public object VideoConfigUtil { } // Dynamic range is compatible. Use EncoderProfiles settings if the media spec's - // output format is set to auto or happens to match the EncoderProfiles' output + // output format is UNSPECIFIED or happens to match the EncoderProfiles' output // format. val videoProfileMime = videoProfile.mediaType if (mediaSpecVideoMime == videoProfileMime) { @@ -163,10 +194,10 @@ public object VideoConfigUtil { "EncoderProfiles to derive VIDEO settings [mime type: " + "$resolvedVideoMime]", ) - } else if (mediaSpec.outputFormat == MediaSpec.OUTPUT_FORMAT_AUTO) { + } else if (mediaSpec.outputFormat == MediaSpec.OUTPUT_FORMAT_UNSPECIFIED) { Logger.d( TAG, - "MediaSpec contains OUTPUT_FORMAT_AUTO. Using CamcorderProfile " + + "MediaSpec contains OUTPUT_FORMAT_UNSPECIFIED. Using CamcorderProfile " + "to derive VIDEO settings [mime type: $resolvedVideoMime, " + "dynamic range: $dynamicRange]", ) @@ -179,9 +210,9 @@ public object VideoConfigUtil { } } if (compatibleVideoProfile == null) { - if (mediaSpec.outputFormat == MediaSpec.OUTPUT_FORMAT_AUTO) { - // If output format is AUTO, use the dynamic range to get the mime. Otherwise we - // fall back to the default mime type from MediaSpec + if (mediaSpec.outputFormat == MediaSpec.OUTPUT_FORMAT_UNSPECIFIED) { + // If output format is UNSPECIFIED, use the dynamic range to get the mime. + // Otherwise, we fall back to the default mime type from MediaSpec resolvedVideoMime = getDynamicRangeDefaultMime(dynamicRange) } if (encoderProfiles == null) { @@ -397,7 +428,7 @@ public object VideoConfigUtil { expectedCaptureFrameRateRange.upper } val encodeFrameRate: Int = - if (videoSpec.encodeFrameRate != VideoSpec.ENCODE_FRAME_RATE_AUTO) { + if (videoSpec.encodeFrameRate != VideoSpec.ENCODE_FRAME_RATE_UNSPECIFIED) { videoSpec.encodeFrameRate } else { captureFrameRate diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoEncoderConfigDefaultResolver.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoEncoderConfigDefaultResolver.kt index b0a80dc1932cf..9bd6077a6b9a9 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoEncoderConfigDefaultResolver.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoEncoderConfigDefaultResolver.kt @@ -81,7 +81,7 @@ public constructor( val videoSpecBitrate = videoSpec.bitrate val resolvedBitrate: Int = - if (videoSpecBitrate != VideoSpec.BITRATE_AUTO) { + if (videoSpecBitrate != VideoSpec.BITRATE_UNSPECIFIED) { videoSpecBitrate } else { Logger.d(TAG, "Using fallback VIDEO bitrate") diff --git a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoEncoderConfigVideoProfileResolver.kt b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoEncoderConfigVideoProfileResolver.kt index ce37eea846898..a8604555e7267 100644 --- a/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoEncoderConfigVideoProfileResolver.kt +++ b/camera/camera-video/src/main/java/androidx/camera/video/internal/config/VideoEncoderConfigVideoProfileResolver.kt @@ -76,7 +76,7 @@ public constructor( val videoSpecBitrate = videoSpec.bitrate val resolvedBitrate: Int = - if (videoSpecBitrate != VideoSpec.BITRATE_AUTO) { + if (videoSpecBitrate != VideoSpec.BITRATE_UNSPECIFIED) { videoSpecBitrate } else { Logger.d(TAG, "Using resolved VIDEO bitrate from EncoderProfiles") diff --git a/camera/camera-video/src/test/java/androidx/camera/video/AudioSpecTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/AudioSpecTest.kt index ebae87f64d37b..5415ef053bea6 100644 --- a/camera/camera-video/src/test/java/androidx/camera/video/AudioSpecTest.kt +++ b/camera/camera-video/src/test/java/androidx/camera/video/AudioSpecTest.kt @@ -31,12 +31,12 @@ class AudioSpecTest { fun newBuilder_containsCorrectDefaults() { val audioSpec = AudioSpec.builder().build() - assertThat(audioSpec.source).isEqualTo(AudioSpec.SOURCE_AUTO) - assertThat(audioSpec.sourceFormat).isEqualTo(AudioSpec.SOURCE_FORMAT_AUTO) - assertThat(audioSpec.bitrate).isEqualTo(AudioSpec.BITRATE_AUTO) - assertThat(audioSpec.channelCount).isEqualTo(AudioSpec.CHANNEL_COUNT_AUTO) - assertThat(audioSpec.sampleRate).isEqualTo(AudioSpec.SAMPLE_RATE_AUTO) - assertThat(audioSpec.mimeType).isEqualTo(AudioSpec.MIME_TYPE_AUTO) + assertThat(audioSpec.source).isEqualTo(AudioSpec.SOURCE_UNSPECIFIED) + assertThat(audioSpec.sourceFormat).isEqualTo(AudioSpec.SOURCE_FORMAT_UNSPECIFIED) + assertThat(audioSpec.bitrate).isEqualTo(AudioSpec.BITRATE_UNSPECIFIED) + assertThat(audioSpec.channelCount).isEqualTo(AudioSpec.CHANNEL_COUNT_UNSPECIFIED) + assertThat(audioSpec.sampleRate).isEqualTo(AudioSpec.SAMPLE_RATE_UNSPECIFIED) + assertThat(audioSpec.mimeType).isEqualTo(AudioSpec.MIME_TYPE_UNSPECIFIED) } @Test diff --git a/camera/camera-video/src/test/java/androidx/camera/video/QualitySelectorTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/QualitySelectorTest.kt index 340b94138d2cd..efb5e6defe1e2 100644 --- a/camera/camera-video/src/test/java/androidx/camera/video/QualitySelectorTest.kt +++ b/camera/camera-video/src/test/java/androidx/camera/video/QualitySelectorTest.kt @@ -126,6 +126,19 @@ class QualitySelectorTest { } } + @Test + fun getPrioritizedQualities_withNoneSelector_returnsEmpty() { + // Arrange. + val qualitySelector = QualitySelector.NONE + + // Act. + val supportedQualities = videoCapabilities.getSupportedQualities(SDR) + val selectedQualities = qualitySelector.getPrioritizedQualities(supportedQualities) + + // Assert. + assertThat(selectedQualities).isEmpty() + } + @Test fun getPrioritizedQualities_selectSingleQuality() { // Arrange. diff --git a/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt index 172e6c3762cfe..57cc0666ecbf3 100644 --- a/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt +++ b/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt @@ -43,6 +43,7 @@ import android.os.Looper import android.util.Range import android.util.Size import android.view.Surface +import androidx.camera.core.AspectRatio import androidx.camera.core.AspectRatio.RATIO_16_9 import androidx.camera.core.AspectRatio.RATIO_4_3 import androidx.camera.core.CameraEffect @@ -376,11 +377,7 @@ class VideoCaptureTest { val videoOutput = createVideoOutput( mediaSpec = - MediaSpec.builder() - .configureVideo { - it.setQualitySelector(QualitySelector.from(quality)) - } - .build(), + createMediaSpec(qualitySelector = QualitySelector.from(quality)), surfaceRequestListener = { request, _ -> 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/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/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/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 543764e9753c5..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,20 +61,20 @@ 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 fun videoMimeInfo_resolvesFromDynamicRange_withCompatibleProfile() { val videoMimeInfo = VideoConfigUtil.resolveVideoMimeInfo( - createMediaSpec(outputFormat = MediaSpec.OUTPUT_FORMAT_AUTO), - DynamicRange.HLG_10_BIT, + createMediaSpec(outputFormat = MediaSpec.OUTPUT_FORMAT_UNSPECIFIED), + 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()) @@ -140,7 +155,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 +166,7 @@ class VideoConfigUtilTest { } @Test - fun resolveFrameRates_expectedCaptureFrameRateSpecified_videoSpecAuto() { + fun resolveFrameRates_expectedCaptureFrameRateSpecified_videoSpecUnspecified() { val videoSpec = VideoSpec.builder().build() val expectedCaptureFrameRateRange = Range(24, 60) @@ -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( @@ -192,7 +306,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) @@ -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, ) } } 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/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/androidTest/assets/acro_js.pdf b/pdf/pdf-document-service/src/androidTest/assets/acro_js.pdf new file mode 100644 index 0000000000000..ff79dd0b82f1a Binary files /dev/null and b/pdf/pdf-document-service/src/androidTest/assets/acro_js.pdf differ diff --git a/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt b/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt index 1b45948e1aaa8..a9124a140878a 100644 --- a/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt +++ b/pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfDocumentTest.kt @@ -26,6 +26,7 @@ import android.os.Build import android.os.ParcelFileDescriptor import android.util.Size import androidx.annotation.RequiresExtension +import androidx.pdf.annotation.models.ImagePdfObject import androidx.pdf.annotation.processor.BatchPdfAnnotationsProcessor import androidx.pdf.annotation.processor.BatchPdfAnnotationsProcessor.Companion.parcelSizeInBytes import androidx.pdf.content.PdfPageTextContent @@ -46,6 +47,7 @@ import java.io.File import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertNotNull import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import org.junit.Test @@ -278,6 +280,92 @@ class SandboxedPdfDocumentTest { } } + @RequiresExtension(extension = Build.VERSION_CODES.S, version = 19) + @Test + fun getPageTopObject_validImageObject_fetchLargeImage() = runTest { + if (!isRequiredSdkExtensionAvailable(19)) return@runTest + + withDocument(PDF_DOCUMENT_WITH_TEXT_AND_IMAGE) { document -> + 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/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/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt b/pdf/pdf-document-service/src/main/kotlin/androidx/pdf/service/PdfDocumentRemoteImpl.kt index 6d7c7d1cef3bb..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 @@ -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,9 +43,11 @@ 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 +import androidx.pdf.utils.toPdfObject @RestrictTo(RestrictTo.Scope.LIBRARY) internal class PdfDocumentRemoteImpl( @@ -153,6 +156,21 @@ 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, + types: IntArray, + ): PdfObject? { + return rendererAdapter.withPage(pageNum) { page -> + val topObjectResult = page.getTopPageObjectAtPosition(point, types) + topObjectResult?.let { + val convertedObject: PdfObject? = topObjectResult.second.toPdfObject() + return@withPage convertedObject + } + } + } + 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/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/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/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-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-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) + } +} 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 205aa143e6b0f..820b757b90453 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 5e8dd9c8d4d55..43b002d355b83 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/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 -> {} } } } 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 172ee6b65bc90..76dec1149f21e 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,