Merge "[Mult-Cam] Add CameraPipe support to test app" into androidx-main
diff --git a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/testing/TestUseCaseCamera.kt b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/testing/TestUseCaseCamera.kt
index 43f9c2c..145da62 100644
--- a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/testing/TestUseCaseCamera.kt
+++ b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/testing/TestUseCaseCamera.kt
@@ -30,6 +30,7 @@
import androidx.camera.camera2.pipe.RequestTemplate
import androidx.camera.camera2.pipe.integration.adapter.CameraStateAdapter
import androidx.camera.camera2.pipe.integration.adapter.SessionConfigAdapter
+import androidx.camera.camera2.pipe.integration.adapter.ZslControlNoOpImpl
import androidx.camera.camera2.pipe.integration.compat.StreamConfigurationMapCompat
import androidx.camera.camera2.pipe.integration.compat.quirk.CameraQuirks
import androidx.camera.camera2.pipe.integration.compat.workaround.NoOpInactiveSurfaceCloser
@@ -95,8 +96,8 @@
val callbackMap = CameraCallbackMap()
val requestListener = ComboRequestListener()
val cameraGraphConfig = createCameraGraphConfig(
- sessionConfigAdapter, streamConfigMap,
- callbackMap, requestListener, cameraConfig, cameraQuirks, null
+ sessionConfigAdapter, streamConfigMap, callbackMap, requestListener, cameraConfig,
+ cameraQuirks, null, ZslControlNoOpImpl()
)
val cameraGraph = cameraPipe.create(cameraGraphConfig)
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraControlAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraControlAdapter.kt
index b5ee865..f7a6c0c 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraControlAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraControlAdapter.kt
@@ -39,6 +39,8 @@
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.FocusMeteringResult
import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCapture.FLASH_MODE_AUTO
+import androidx.camera.core.ImageCapture.FLASH_MODE_ON
import androidx.camera.core.impl.CameraControlInternal
import androidx.camera.core.impl.CaptureConfig
import androidx.camera.core.impl.Config
@@ -71,6 +73,7 @@
private val torchControl: TorchControl,
private val threads: UseCaseThreads,
private val zoomControl: ZoomControl,
+ private val zslControl: ZslControl,
val camera2cameraControl: Camera2CameraControl,
) : CameraControlInternal {
override fun getSensorRect(): Rect {
@@ -127,6 +130,10 @@
override fun setFlashMode(@ImageCapture.FlashMode flashMode: Int) {
flashControl.setFlashAsync(flashMode)
+ zslControl.setZslDisabledByFlashMode(
+ flashMode == FLASH_MODE_ON ||
+ flashMode == FLASH_MODE_AUTO
+ )
}
override fun setScreenFlash(screenFlash: ImageCapture.ScreenFlash?) {
@@ -139,16 +146,15 @@
)
override fun setZslDisabledByUserCaseConfig(disabled: Boolean) {
- // Override if Zero-Shutter Lag needs to be disabled by user case config.
+ zslControl.setZslDisabledByUserCaseConfig(disabled)
}
override fun isZslDisabledByByUserCaseConfig(): Boolean {
- // Override if Zero-Shutter Lag needs to be disabled by user case config.
- return false
+ return zslControl.isZslDisabledByUserCaseConfig()
}
override fun addZslConfig(sessionConfigBuilder: SessionConfig.Builder) {
- // Override if Zero-Shutter Lag needs to add config to session config.
+ zslControl.addZslConfig(sessionConfigBuilder)
}
override fun submitStillCaptureRequests(
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapter.kt
index 0b8364c..dfcf6b0 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraInfoAdapter.kt
@@ -24,17 +24,21 @@
import android.hardware.camera2.CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_PREVIEW_STABILIZATION
import android.hardware.camera2.CameraMetadata
import android.hardware.camera2.params.DynamicRangeProfiles
+import android.os.Build
import android.util.Range
import android.util.Size
import android.view.Surface
import androidx.annotation.RequiresApi
import androidx.camera.camera2.pipe.CameraId
import androidx.camera.camera2.pipe.CameraMetadata.Companion.supportsLogicalMultiCamera
+import androidx.camera.camera2.pipe.CameraMetadata.Companion.supportsPrivateReprocessing
import androidx.camera.camera2.pipe.CameraPipe
import androidx.camera.camera2.pipe.core.Log
import androidx.camera.camera2.pipe.integration.compat.DynamicRangeProfilesCompat
import androidx.camera.camera2.pipe.integration.compat.StreamConfigurationMapCompat
import androidx.camera.camera2.pipe.integration.compat.quirk.CameraQuirks
+import androidx.camera.camera2.pipe.integration.compat.quirk.DeviceQuirks
+import androidx.camera.camera2.pipe.integration.compat.quirk.ZslDisablerQuirk
import androidx.camera.camera2.pipe.integration.compat.workaround.isFlashAvailable
import androidx.camera.camera2.pipe.integration.config.CameraConfig
import androidx.camera.camera2.pipe.integration.config.CameraScope
@@ -232,13 +236,12 @@
?: emptySet()
override fun isZslSupported(): Boolean {
- Log.warn { "TODO: isZslSupported are not yet supported." }
- return false
+ return Build.VERSION.SDK_INT >= 23 && isPrivateReprocessingSupported &&
+ DeviceQuirks[ZslDisablerQuirk::class.java] == null
}
override fun isPrivateReprocessingSupported(): Boolean {
- Log.warn { "TODO: isPrivateReprocessingSupported are not yet supported." }
- return false
+ return cameraProperties.metadata.supportsPrivateReprocessing
}
override fun getSupportedDynamicRanges(): Set<DynamicRange> {
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraUseCaseAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraUseCaseAdapter.kt
index a83161c..7bb9bc1 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraUseCaseAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraUseCaseAdapter.kt
@@ -30,6 +30,8 @@
import androidx.camera.camera2.pipe.integration.impl.SESSION_PHYSICAL_CAMERA_ID_OPTION
import androidx.camera.camera2.pipe.integration.impl.STREAM_USE_CASE_OPTION
import androidx.camera.camera2.pipe.integration.interop.ExperimentalCamera2Interop
+import androidx.camera.core.ExperimentalZeroShutterLag
+import androidx.camera.core.ImageCapture
import androidx.camera.core.impl.CameraCaptureCallback
import androidx.camera.core.impl.CaptureConfig
import androidx.camera.core.impl.Config
@@ -70,10 +72,11 @@
* Returns the configuration for the given capture type, or `null` if the
* configuration cannot be produced.
*/
+ @ExperimentalZeroShutterLag
override fun getConfig(
captureType: CaptureType,
captureMode: Int
- ): Config? {
+ ): Config {
debug { "Creating config for $captureType" }
val mutableConfig = MutableOptionsBundle.create()
@@ -102,7 +105,11 @@
val captureBuilder = CaptureConfig.Builder()
when (captureType) {
CaptureType.IMAGE_CAPTURE ->
- captureBuilder.templateType = CameraDevice.TEMPLATE_STILL_CAPTURE
+ captureBuilder.templateType =
+ if (captureMode == ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG)
+ CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG
+ else
+ CameraDevice.TEMPLATE_STILL_CAPTURE
CaptureType.PREVIEW,
CaptureType.IMAGE_ANALYSIS,
@@ -165,7 +172,7 @@
implOptions = defaultCaptureConfig.implementationOptions
// Also copy these info to the CaptureConfig
- builder.setUseRepeatingSurface(defaultCaptureConfig.isUseRepeatingSurface)
+ builder.isUseRepeatingSurface = defaultCaptureConfig.isUseRepeatingSurface
builder.addAllTags(defaultCaptureConfig.tagBundle)
defaultCaptureConfig.surfaces.forEach { builder.addSurface(it) }
}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapter.kt
index dd3bf72..5fb4eea 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapter.kt
@@ -19,7 +19,10 @@
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CaptureRequest
+import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.FrameInfo
+import androidx.camera.camera2.pipe.InputRequest
import androidx.camera.camera2.pipe.Request
import androidx.camera.camera2.pipe.RequestTemplate
import androidx.camera.camera2.pipe.integration.config.UseCaseCameraScope
@@ -30,6 +33,9 @@
import androidx.camera.camera2.pipe.integration.impl.CameraProperties
import androidx.camera.camera2.pipe.integration.impl.UseCaseThreads
import androidx.camera.camera2.pipe.integration.impl.toParameters
+import androidx.camera.camera2.pipe.media.AndroidImage
+import androidx.camera.core.ExperimentalGetImage
+import androidx.camera.core.impl.CameraCaptureResults
import androidx.camera.core.impl.CaptureConfig
import androidx.camera.core.impl.Config
import javax.inject.Inject
@@ -43,12 +49,14 @@
class CaptureConfigAdapter @Inject constructor(
cameraProperties: CameraProperties,
private val useCaseGraphConfig: UseCaseGraphConfig,
+ private val zslControl: ZslControl,
private val threads: UseCaseThreads,
) {
private val isLegacyDevice = cameraProperties.metadata[
CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL
] == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY
+ @OptIn(ExperimentalGetImage::class)
fun mapToRequest(
captureConfig: CaptureConfig,
requestTemplate: RequestTemplate,
@@ -95,12 +103,38 @@
)
}
+ var inputRequest: InputRequest? = null
+ var requestTemplateToSubmit = RequestTemplate(captureConfig.templateType)
+ if (captureConfig.templateType == CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG &&
+ !zslControl.isZslDisabledByUserCaseConfig() &&
+ !zslControl.isZslDisabledByFlashMode()
+ ) {
+ zslControl.dequeueImageFromBuffer()?.let { imageProxy ->
+ CameraCaptureResults.retrieveCameraCaptureResult(imageProxy.imageInfo)
+ ?.let { cameraCaptureResult ->
+ check(cameraCaptureResult is CaptureResultAdapter) {
+ "Unexpected capture result type: ${cameraCaptureResult.javaClass}"
+ }
+ val imageWrapper = AndroidImage(checkNotNull(imageProxy.image))
+ val frameInfo = checkNotNull(cameraCaptureResult.unwrapAs(FrameInfo::class))
+ inputRequest = InputRequest(imageWrapper, frameInfo)
+ }
+ }
+ }
+
+ // Apply still capture template type for regular still capture case
+ if (inputRequest == null) {
+ requestTemplateToSubmit =
+ captureConfig.getStillCaptureTemplate(requestTemplate, isLegacyDevice)
+ }
+
return Request(
streams = streamIdList,
listeners = listOf(callbacks) + additionalListeners,
parameters = optionBuilder.build().toParameters(),
extras = mapOf(CAMERAX_TAG_BUNDLE to captureConfig.tagBundle),
- template = captureConfig.getStillCaptureTemplate(requestTemplate, isLegacyDevice)
+ template = requestTemplateToSubmit,
+ inputRequest = inputRequest,
)
}
@@ -117,7 +151,9 @@
// repeating template is TEMPLATE_RECORD. Note:
// TEMPLATE_VIDEO_SNAPSHOT is not supported on legacy device.
templateToModify = CameraDevice.TEMPLATE_VIDEO_SNAPSHOT
- } else if (templateType == CaptureConfig.TEMPLATE_TYPE_NONE) {
+ } else if (templateType == CaptureConfig.TEMPLATE_TYPE_NONE ||
+ templateType == CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG
+ ) {
templateToModify = CameraDevice.TEMPLATE_STILL_CAPTURE
}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureResultAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureResultAdapter.kt
index d37a3d9..85c6373 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureResultAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CaptureResultAdapter.kt
@@ -79,7 +79,7 @@
class CaptureResultAdapter(
private val requestMetadata: RequestMetadata,
private val frameNumber: FrameNumber,
- private val result: FrameInfo
+ internal val result: FrameInfo
) : CameraCaptureResult, UnsafeWrapper {
override fun getAfMode(): AfMode = result.metadata.getAfMode()
override fun getAfState(): AfState = result.metadata.getAfState()
@@ -104,7 +104,13 @@
"Failed to unwrap $this as TotalCaptureResult"
}
- override fun <T : Any> unwrapAs(type: KClass<T>): T? = result.unwrapAs(type)
+ @Suppress("UNCHECKED_CAST")
+ override fun <T : Any> unwrapAs(type: KClass<T>): T? {
+ return when (type) {
+ FrameInfo::class -> result as T
+ else -> result.unwrapAs(type)
+ }
+ }
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/ZslControl.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/ZslControl.kt
new file mode 100644
index 0000000..21b70b4
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/ZslControl.kt
@@ -0,0 +1,313 @@
+/*
+ * Copyright 2024 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.adapter
+
+import android.graphics.ImageFormat
+import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.params.InputConfiguration
+import android.hardware.camera2.params.StreamConfigurationMap
+import android.os.Build
+import android.util.Size
+import androidx.annotation.RequiresApi
+import androidx.annotation.VisibleForTesting
+import androidx.camera.camera2.pipe.CameraMetadata.Companion.supportsPrivateReprocessing
+import androidx.camera.camera2.pipe.core.Log
+import androidx.camera.camera2.pipe.integration.compat.quirk.DeviceQuirks
+import androidx.camera.camera2.pipe.integration.compat.quirk.ZslDisablerQuirk
+import androidx.camera.camera2.pipe.integration.config.CameraScope
+import androidx.camera.camera2.pipe.integration.impl.CameraProperties
+import androidx.camera.camera2.pipe.integration.impl.area
+import androidx.camera.core.ImageProxy
+import androidx.camera.core.MetadataImageReader
+import androidx.camera.core.SafeCloseImageReaderProxy
+import androidx.camera.core.impl.CameraCaptureCallback
+import androidx.camera.core.impl.DeferrableSurface
+import androidx.camera.core.impl.ImmediateSurface
+import androidx.camera.core.impl.SessionConfig
+import androidx.camera.core.impl.utils.executor.CameraXExecutors
+import androidx.camera.core.internal.utils.ZslRingBuffer
+import javax.inject.Inject
+
+interface ZslControl {
+
+ /**
+ * Adds zero-shutter lag config to [SessionConfig].
+ *
+ * @param sessionConfigBuilder session config builder.
+ */
+ fun addZslConfig(sessionConfigBuilder: SessionConfig.Builder)
+
+ /**
+ * Determines whether the provided [DeferrableSurface] belongs to ZSL.
+ *
+ * @param surface The deferrable Surface to check.
+ * @param sessionConfig The session configuration where its input configuration will be used to
+ * determine whether the deferrable Surface belongs to ZSL.
+ */
+ fun isZslSurface(surface: DeferrableSurface, sessionConfig: SessionConfig): Boolean
+
+ /**
+ * Sets the flag if zero-shutter lag needs to be disabled by user case config.
+ *
+ *
+ * Zero-shutter lag will be disabled when any of the following conditions:
+ *
+ * * Extension is ON
+ * * VideoCapture is ON
+ *
+ *
+ * @param disabled True if zero-shutter lag should be disabled. Otherwise, should not be
+ * disabled. However, enabling zero-shutter lag needs other conditions e.g.
+ * flash mode OFF, so setting to false doesn't guarantee zero-shutter lag to
+ * be always ON.
+ */
+ fun setZslDisabledByUserCaseConfig(disabled: Boolean)
+
+ /**
+ * Checks if zero-shutter lag is disabled by user case config.
+ *
+ * @return True if zero-shutter lag should be disabled. Otherwise, returns false.
+ */
+ fun isZslDisabledByUserCaseConfig(): Boolean
+
+ /**
+ * Sets the flag if zero-shutter lag needs to be disabled by flash mode.
+ *
+ *
+ * Zero-shutter lag will be disabled when flash mode is not OFF.
+ *
+ * @param disabled True if zero-shutter lag should be disabled. Otherwise, should not be
+ * disabled. However, enabling zero-shutter lag needs other conditions e.g.
+ * Extension is OFF and VideoCapture is OFF, so setting to false doesn't
+ * guarantee zero-shutter lag to be always ON.
+ */
+ fun setZslDisabledByFlashMode(disabled: Boolean)
+
+ /**
+ * Checks if zero-shutter lag is disabled by flash mode.
+ *
+ * @return True if zero-shutter lag should be disabled. Otherwise, returns false.
+ */
+ fun isZslDisabledByFlashMode(): Boolean
+
+ /**
+ * Dequeues [ImageProxy] from ring buffer.
+ *
+ * @return [ImageProxy].
+ */
+ fun dequeueImageFromBuffer(): ImageProxy?
+}
+
+@RequiresApi(Build.VERSION_CODES.M)
+@CameraScope
+class ZslControlImpl @Inject constructor(
+ private val cameraProperties: CameraProperties
+) : ZslControl {
+ private val cameraMetadata = cameraProperties.metadata
+ private val streamConfigurationMap: StreamConfigurationMap by lazy {
+ checkNotNull(cameraMetadata[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP])
+ }
+
+ @VisibleForTesting
+ internal val zslRingBuffer =
+ ZslRingBuffer(RING_BUFFER_CAPACITY) { imageProxy -> imageProxy.close() }
+
+ private var isZslDisabledByUseCaseConfig = false
+ private var isZslDisabledByFlashMode = false
+ private var isZslDisabledByQuirks = DeviceQuirks[ZslDisablerQuirk::class.java] != null
+
+ @VisibleForTesting
+ internal var reprocessingImageReader: SafeCloseImageReaderProxy? = null
+ private var metadataMatchingCaptureCallback: CameraCaptureCallback? = null
+ private var reprocessingImageDeferrableSurface: DeferrableSurface? = null
+
+ override fun addZslConfig(sessionConfigBuilder: SessionConfig.Builder) {
+ reset()
+
+ // Early return only if use case config doesn't support zsl. If flash mode doesn't
+ // support zsl, we still create reprocessing capture session but will create a
+ // regular capture request when taking pictures. So when user switches flash mode, we
+ // could create reprocessing capture request if flash mode allows.
+ if (isZslDisabledByUseCaseConfig) {
+ return
+ }
+
+ if (isZslDisabledByQuirks) {
+ return
+ }
+
+ if (!cameraMetadata.supportsPrivateReprocessing) {
+ Log.info { "ZslControlImpl: Private reprocessing isn't supported" }
+ return
+ }
+
+ val size = streamConfigurationMap.getInputSizes(FORMAT).toList().maxBy { it.area() }
+ if (size == null) {
+ Log.warn { "ZslControlImpl: Unable to find a supported size for ZSL" }
+ return
+ }
+ Log.debug { "ZslControlImpl: Selected ZSL size: $size" }
+
+ val isJpegValidOutput =
+ streamConfigurationMap.getValidOutputFormatsForInput(FORMAT).contains(ImageFormat.JPEG)
+ if (!isJpegValidOutput) {
+ Log.warn { "ZslControlImpl: JPEG isn't valid output for ZSL format" }
+ return
+ }
+
+ val metadataImageReader = MetadataImageReader(
+ size.width,
+ size.height,
+ FORMAT,
+ MAX_IMAGES
+ )
+ val metadataCaptureCallback = metadataImageReader.cameraCaptureCallback
+ val reprocImageReader = SafeCloseImageReaderProxy(metadataImageReader)
+ metadataImageReader.setOnImageAvailableListener(
+ { reader ->
+ try {
+ val imageProxy = reader.acquireLatestImage()
+ if (imageProxy != null) {
+ zslRingBuffer.enqueue(imageProxy)
+ }
+ } catch (e: IllegalStateException) {
+ Log.error { "Failed to acquire latest image" }
+ }
+ }, CameraXExecutors.ioExecutor()
+ )
+
+ // Init the reprocessing image reader surface and add into the target surfaces of capture
+ val reprocDeferrableSurface = ImmediateSurface(
+ checkNotNull(reprocImageReader.surface),
+ Size(reprocImageReader.width, reprocImageReader.height),
+ FORMAT
+ )
+
+ reprocDeferrableSurface.terminationFuture.addListener(
+ { reprocImageReader.safeClose() },
+ CameraXExecutors.mainThreadExecutor()
+ )
+ sessionConfigBuilder.addSurface(reprocDeferrableSurface)
+
+ // Init capture and session state callback and enqueue the total capture result
+ sessionConfigBuilder.addCameraCaptureCallback(metadataCaptureCallback)
+
+ // Set input configuration for reprocessing capture request
+ sessionConfigBuilder.setInputConfiguration(
+ InputConfiguration(
+ reprocImageReader.width,
+ reprocImageReader.height,
+ reprocImageReader.imageFormat,
+ )
+ )
+
+ metadataMatchingCaptureCallback = metadataCaptureCallback
+ reprocessingImageReader = reprocImageReader
+ reprocessingImageDeferrableSurface = reprocDeferrableSurface
+ }
+
+ override fun isZslSurface(surface: DeferrableSurface, sessionConfig: SessionConfig): Boolean {
+ val inputConfig = sessionConfig.inputConfiguration
+ return surface.prescribedStreamFormat == inputConfig?.format &&
+ surface.prescribedSize.width == inputConfig.width &&
+ surface.prescribedSize.height == inputConfig.height
+ }
+
+ override fun setZslDisabledByUserCaseConfig(disabled: Boolean) {
+ isZslDisabledByUseCaseConfig = disabled
+ }
+
+ override fun isZslDisabledByUserCaseConfig(): Boolean {
+ return isZslDisabledByUseCaseConfig
+ }
+
+ override fun setZslDisabledByFlashMode(disabled: Boolean) {
+ isZslDisabledByFlashMode = disabled
+ }
+
+ override fun isZslDisabledByFlashMode(): Boolean {
+ return isZslDisabledByFlashMode
+ }
+
+ override fun dequeueImageFromBuffer(): ImageProxy? {
+ return try {
+ zslRingBuffer.dequeue()
+ } catch (e: NoSuchElementException) {
+ Log.warn { "ZslControlImpl#dequeueImageFromBuffer: No such element" }
+ null
+ }
+ }
+
+ private fun reset() {
+ val reprocImageDeferrableSurface = reprocessingImageDeferrableSurface
+ if (reprocImageDeferrableSurface != null) {
+ val reprocImageReaderProxy = reprocessingImageReader
+ if (reprocImageReaderProxy != null) {
+ reprocImageDeferrableSurface.terminationFuture.addListener(
+ { reprocImageReaderProxy.safeClose() },
+ CameraXExecutors.mainThreadExecutor()
+ )
+ // Clear the listener so that no more buffer is enqueued to |zslRingBuffer|.
+ reprocImageReaderProxy.clearOnImageAvailableListener()
+ reprocessingImageReader = null
+ }
+ reprocImageDeferrableSurface.close()
+ reprocessingImageDeferrableSurface = null
+ }
+
+ val ringBuffer = zslRingBuffer
+ while (!ringBuffer.isEmpty) {
+ ringBuffer.dequeue().close()
+ }
+ }
+
+ companion object {
+ // Due to b/232268355 and feedback from pixel team that private format will have better
+ // performance, we will use private only for zsl.
+ private const val FORMAT = ImageFormat.PRIVATE
+
+ @VisibleForTesting
+ internal const val RING_BUFFER_CAPACITY = 3
+
+ @VisibleForTesting
+ internal const val MAX_IMAGES = RING_BUFFER_CAPACITY * 3
+ }
+}
+
+/**
+ * No-Op implementation for [ZslControl].
+ */
+class ZslControlNoOpImpl @Inject constructor() : ZslControl {
+ override fun addZslConfig(sessionConfigBuilder: SessionConfig.Builder) {
+ }
+
+ override fun isZslSurface(surface: DeferrableSurface, sessionConfig: SessionConfig) = false
+
+ override fun setZslDisabledByUserCaseConfig(disabled: Boolean) {
+ }
+
+ override fun isZslDisabledByUserCaseConfig() = false
+
+ override fun setZslDisabledByFlashMode(disabled: Boolean) {
+ }
+
+ override fun isZslDisabledByFlashMode() = false
+
+ override fun dequeueImageFromBuffer(): ImageProxy? {
+ return null
+ }
+}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt
index 04d93f1..247b017 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt
@@ -91,6 +91,9 @@
if (CaptureSessionOnClosedNotCalledQuirk.isEnabled()) {
quirks.add(CaptureSessionOnClosedNotCalledQuirk())
}
+ if (ZslDisablerQuirk.load()) {
+ quirks.add(ZslDisablerQuirk())
+ }
return quirks
}
}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/ZslDisablerQuirk.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/ZslDisablerQuirk.kt
new file mode 100644
index 0000000..7121f7b
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/ZslDisablerQuirk.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2024 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.compat.quirk
+
+import android.annotation.SuppressLint
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.camera.core.impl.Quirk
+
+/**
+ * QuirkSummary
+ * - Bug Id: 252818931, 261744070, 319913852
+ * - Description: On certain devices, the captured image has color issue for reprocessing. We need
+ * to disable zero-shutter lag and return false for [CameraInfo.isZslSupported].
+ * - Device(s): Samsung Fold4, Samsung s22, Xiaomi Mi 8
+ */
+@SuppressLint("CameraXQuirksClassDetector") // TODO(b/270421716): enable when kotlin is supported.
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+class ZslDisablerQuirk : Quirk {
+
+ companion object {
+ private val AFFECTED_SAMSUNG_MODEL = listOf(
+ "SM-F936",
+ "SM-S901U",
+ "SM-S908U",
+ "SM-S908U1"
+ )
+
+ private val AFFECTED_XIAOMI_MODEL = listOf(
+ "MI 8"
+ )
+
+ fun load(): Boolean {
+ return isAffectedSamsungDevices() || isAffectedXiaoMiDevices()
+ }
+
+ private fun isAffectedSamsungDevices(): Boolean {
+ return ("samsung".equals(Build.BRAND, ignoreCase = true) &&
+ isAffectedModel(AFFECTED_SAMSUNG_MODEL))
+ }
+
+ private fun isAffectedXiaoMiDevices(): Boolean {
+ return ("xiaomi".equals(Build.BRAND, ignoreCase = true) &&
+ isAffectedModel(AFFECTED_XIAOMI_MODEL))
+ }
+
+ private fun isAffectedModel(modelList: List<String>): Boolean {
+ for (model in modelList) {
+ if (Build.MODEL.uppercase().startsWith(model)) {
+ return true
+ }
+ }
+ return false
+ }
+ }
+}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/CameraConfig.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/CameraConfig.kt
index af0669e..9aaf1d4a9 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/CameraConfig.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/config/CameraConfig.kt
@@ -20,6 +20,7 @@
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.params.StreamConfigurationMap
+import android.os.Build
import androidx.annotation.Nullable
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
@@ -32,6 +33,9 @@
import androidx.camera.camera2.pipe.integration.adapter.CameraControlAdapter
import androidx.camera.camera2.pipe.integration.adapter.CameraInfoAdapter
import androidx.camera.camera2.pipe.integration.adapter.CameraInternalAdapter
+import androidx.camera.camera2.pipe.integration.adapter.ZslControl
+import androidx.camera.camera2.pipe.integration.adapter.ZslControlImpl
+import androidx.camera.camera2.pipe.integration.adapter.ZslControlNoOpImpl
import androidx.camera.camera2.pipe.integration.compat.Camera2CameraControlCompat
import androidx.camera.camera2.pipe.integration.compat.CameraCompatModule
import androidx.camera.camera2.pipe.integration.compat.EvCompCompat
@@ -174,6 +178,18 @@
@Provides
@Named("cameraQuirksValues")
fun provideCameraQuirksValues(cameraQuirks: CameraQuirks): Quirks = cameraQuirks.quirks
+
+ @CameraScope
+ @Provides
+ fun provideZslControl(
+ cameraProperties: CameraProperties
+ ): ZslControl {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ return ZslControlImpl(cameraProperties)
+ } else {
+ return ZslControlNoOpImpl()
+ }
+ }
}
@Binds
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 8aff64a8..8f5dd4e 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
@@ -32,6 +32,7 @@
import androidx.camera.camera2.pipe.CameraId
import androidx.camera.camera2.pipe.CameraPipe
import androidx.camera.camera2.pipe.CameraStream
+import androidx.camera.camera2.pipe.InputStream
import androidx.camera.camera2.pipe.OutputStream
import androidx.camera.camera2.pipe.StreamFormat
import androidx.camera.camera2.pipe.compat.CameraPipeKeys
@@ -40,6 +41,7 @@
import androidx.camera.camera2.pipe.integration.adapter.EncoderProfilesProviderAdapter
import androidx.camera.camera2.pipe.integration.adapter.SessionConfigAdapter
import androidx.camera.camera2.pipe.integration.adapter.SupportedSurfaceCombination
+import androidx.camera.camera2.pipe.integration.adapter.ZslControl
import androidx.camera.camera2.pipe.integration.compat.quirk.CameraQuirks
import androidx.camera.camera2.pipe.integration.compat.quirk.CloseCameraDeviceOnCameraGraphCloseQuirk
import androidx.camera.camera2.pipe.integration.compat.quirk.CloseCaptureSessionOnDisconnectQuirk
@@ -55,6 +57,7 @@
import androidx.camera.camera2.pipe.integration.interop.ExperimentalCamera2Interop
import androidx.camera.core.DynamicRange
import androidx.camera.core.UseCase
+import androidx.camera.core.impl.CameraControlInternal
import androidx.camera.core.impl.CameraInfoInternal
import androidx.camera.core.impl.CameraInternal
import androidx.camera.core.impl.CameraMode
@@ -107,6 +110,8 @@
private val requestListener: ComboRequestListener,
private val cameraConfig: CameraConfig,
private val builder: UseCaseCameraComponent.Builder,
+ private val cameraControl: CameraControlInternal,
+ private val zslControl: ZslControl,
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") // Java version required for Dagger
private val controls: java.util.Set<UseCaseCameraControl>,
private val camera2CameraControl: Camera2CameraControl,
@@ -216,6 +221,7 @@
if (attachedUseCases.addAll(useCases)) {
if (!addOrRemoveRepeatingUseCase(getRunningUseCases())) {
+ updateZslDisabledByUseCaseConfigStatus()
refreshAttachedUseCases(attachedUseCases)
}
}
@@ -260,6 +266,12 @@
if (addOrRemoveRepeatingUseCase(getRunningUseCases())) {
return
}
+
+ if (attachedUseCases.isEmpty()) {
+ cameraControl.setZslDisabledByUserCaseConfig(false)
+ } else {
+ updateZslDisabledByUseCaseConfigStatus()
+ }
refreshAttachedUseCases(attachedUseCases)
}
pendingUseCasesToNotifyCameraControlReady.removeAll(useCases)
@@ -568,6 +580,7 @@
cameraConfig,
cameraQuirks,
cameraGraphFlags,
+ zslControl,
isExtensions,
)
}
@@ -647,6 +660,11 @@
return predicate(captureConfig.surfaces, sessionConfig.surfaces)
}
+ private fun updateZslDisabledByUseCaseConfigStatus() {
+ val disableZsl = attachedUseCases.any { it.currentConfig.isZslDisabled(false) }
+ cameraControl.setZslDisabledByUserCaseConfig(disableZsl)
+ }
+
companion object {
internal data class UseCaseManagerConfig(
val useCases: List<UseCase>,
@@ -667,11 +685,13 @@
cameraConfig: CameraConfig,
cameraQuirks: CameraQuirks,
cameraGraphFlags: CameraGraph.Flags?,
+ zslControl: ZslControl,
isExtensions: Boolean = false,
): CameraGraph.Config {
var containsVideo = false
var operatingMode = OperatingMode.NORMAL
val streamGroupMap = mutableMapOf<Int, MutableList<CameraStream.Config>>()
+ val inputStreams = mutableListOf<InputStream.Config>()
sessionConfigAdapter.getValidSessionConfigOrNull()?.let { sessionConfig ->
operatingMode = when (sessionConfig.sessionType) {
SESSION_REGULAR -> OperatingMode.NORMAL
@@ -681,6 +701,7 @@
val physicalCameraIdForAllStreams =
sessionConfig.toCamera2ImplConfig().getPhysicalCameraId(null)
+ var zslStream: CameraStream.Config? = null
for (outputConfig in sessionConfig.outputConfigs) {
val deferrableSurface = outputConfig.surface
val physicalCameraId =
@@ -718,6 +739,21 @@
if (surface.containerClass == MediaCodec::class.java) {
containsVideo = true
}
+ if (surface != deferrableSurface) continue
+ if (zslControl.isZslSurface(surface, sessionConfig)) {
+ zslStream = stream
+ }
+ }
+ }
+ if (sessionConfig.inputConfiguration != null) {
+ zslStream?.let {
+ inputStreams.add(
+ InputStream.Config(
+ stream = it,
+ format = it.outputs.single().format.value,
+ 1,
+ )
+ )
}
}
}
@@ -798,6 +834,7 @@
camera = cameraConfig.cameraId,
streams = streamConfigMap.keys.toList(),
exclusiveStreamGroups = streamGroupMap.values.toList(),
+ input = if (inputStreams.isEmpty()) null else inputStreams,
sessionMode = operatingMode,
defaultListeners = listOf(callbackMap, requestListener),
defaultParameters = defaultParameters,
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapterTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapterTest.kt
index f83662e..70f0640 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapterTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/CaptureConfigAdapterTest.kt
@@ -32,7 +32,6 @@
import androidx.camera.core.impl.CaptureConfig
import androidx.camera.core.impl.TagBundle
import androidx.testutils.assertThrows
-import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import java.util.concurrent.Executors
import kotlinx.coroutines.CompletableDeferred
@@ -71,6 +70,7 @@
cameraStateAdapter = CameraStateAdapter(),
),
cameraProperties = fakeCameraProperties,
+ zslControl = ZslControlNoOpImpl(),
threads = fakeUseCaseThreads,
)
@@ -148,7 +148,7 @@
// Assert
runBlocking {
- Truth.assertThat(
+ assertThat(
withTimeoutOrNull(timeMillis = 5000) {
callbackAborted.await()
}
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/ZslControlTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/ZslControlTest.kt
new file mode 100644
index 0000000..77f470e0
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/ZslControlTest.kt
@@ -0,0 +1,333 @@
+/*
+ * Copyright 2024 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.adapter
+
+import android.graphics.ImageFormat
+import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.CameraDevice
+import android.hardware.camera2.params.StreamConfigurationMap
+import android.os.Build
+import android.util.Size
+import androidx.camera.camera2.pipe.CameraId
+import androidx.camera.camera2.pipe.integration.adapter.ZslControlImpl.Companion.MAX_IMAGES
+import androidx.camera.camera2.pipe.integration.adapter.ZslControlImpl.Companion.RING_BUFFER_CAPACITY
+import androidx.camera.camera2.pipe.integration.impl.CameraProperties
+import androidx.camera.camera2.pipe.integration.testing.FakeCameraProperties
+import androidx.camera.camera2.pipe.testing.FakeCameraMetadata
+import androidx.camera.core.impl.SessionConfig
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+import org.robolectric.util.ReflectionHelpers
+
+@RunWith(RobolectricCameraPipeTestRunner::class)
+@Config(minSdk = Build.VERSION_CODES.M)
+@DoNotInstrument
+class ZslControlImplTest {
+ private lateinit var zslControlImpl: ZslControlImpl
+ private lateinit var sessionConfigBuilder: SessionConfig.Builder
+
+ @Before
+ fun setUp() {
+ sessionConfigBuilder = SessionConfig.Builder().also { sessionConfigBuilder ->
+ sessionConfigBuilder.setTemplateType(CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG)
+ }
+ }
+
+ @Test
+ fun isPrivateReprocessingSupported_addZslConfig() {
+ zslControlImpl = ZslControlImpl(
+ createCameraProperties(
+ hasCapabilities = true,
+ isYuvReprocessingSupported = false,
+ isPrivateReprocessingSupported = true,
+ isJpegValidOutputFormat = true
+ )
+ )
+
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ assertThat(zslControlImpl.reprocessingImageReader).isNotNull()
+ assertThat(zslControlImpl.reprocessingImageReader!!.imageFormat)
+ .isEqualTo(ImageFormat.PRIVATE)
+ assertThat(zslControlImpl.reprocessingImageReader!!.maxImages).isEqualTo(
+ MAX_IMAGES
+ )
+ assertThat(zslControlImpl.reprocessingImageReader!!.width).isEqualTo(
+ PRIVATE_REPROCESSING_MAXIMUM_SIZE.width
+ )
+ assertThat(zslControlImpl.reprocessingImageReader!!.height).isEqualTo(
+ PRIVATE_REPROCESSING_MAXIMUM_SIZE.height
+ )
+ assertThat(zslControlImpl.zslRingBuffer.maxCapacity).isEqualTo(
+ RING_BUFFER_CAPACITY
+ )
+ }
+
+ @Test
+ fun isYuvReprocessingSupported_notAddZslConfig() {
+ zslControlImpl = ZslControlImpl(
+ createCameraProperties(
+ hasCapabilities = true,
+ isYuvReprocessingSupported = true,
+ isPrivateReprocessingSupported = false,
+ isJpegValidOutputFormat = true
+ )
+ )
+
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ assertThat(zslControlImpl.reprocessingImageReader).isNull()
+ }
+
+ @Test
+ fun isJpegNotValidOutputFormat_notAddZslConfig() {
+ zslControlImpl = ZslControlImpl(
+ createCameraProperties(
+ hasCapabilities = true,
+ isYuvReprocessingSupported = true,
+ isPrivateReprocessingSupported = false,
+ isJpegValidOutputFormat = false
+ )
+ )
+
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ assertThat(zslControlImpl.reprocessingImageReader).isNull()
+ }
+
+ @Test
+ fun isReprocessingNotSupported_notAddZslConfig() {
+ zslControlImpl = ZslControlImpl(
+ createCameraProperties(
+ hasCapabilities = true,
+ isYuvReprocessingSupported = false,
+ isPrivateReprocessingSupported = false,
+ isJpegValidOutputFormat = false
+ )
+ )
+
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ assertThat(zslControlImpl.reprocessingImageReader).isNull()
+ }
+
+ @Test
+ fun isZslDisabledByUserCaseConfig_notAddZslConfig() {
+ zslControlImpl = ZslControlImpl(
+ createCameraProperties(
+ hasCapabilities = true,
+ isYuvReprocessingSupported = false,
+ isPrivateReprocessingSupported = true,
+ isJpegValidOutputFormat = true
+ )
+ )
+ zslControlImpl.setZslDisabledByUserCaseConfig(true)
+
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ assertThat(zslControlImpl.reprocessingImageReader).isNull()
+ }
+
+ @Test
+ fun isZslDisabledByFlashMode_addZslConfig() {
+ zslControlImpl = ZslControlImpl(
+ createCameraProperties(
+ hasCapabilities = true,
+ isYuvReprocessingSupported = false,
+ isPrivateReprocessingSupported = true,
+ isJpegValidOutputFormat = true
+ )
+ )
+ zslControlImpl.setZslDisabledByFlashMode(true)
+
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ assertThat(zslControlImpl.reprocessingImageReader).isNotNull()
+ assertThat(zslControlImpl.reprocessingImageReader!!.imageFormat).isEqualTo(
+ ImageFormat.PRIVATE
+ )
+ assertThat(zslControlImpl.reprocessingImageReader!!.maxImages).isEqualTo(
+ MAX_IMAGES
+ )
+ assertThat(zslControlImpl.reprocessingImageReader!!.width).isEqualTo(
+ PRIVATE_REPROCESSING_MAXIMUM_SIZE.width
+ )
+ assertThat(zslControlImpl.reprocessingImageReader!!.height).isEqualTo(
+ PRIVATE_REPROCESSING_MAXIMUM_SIZE.height
+ )
+ assertThat(zslControlImpl.zslRingBuffer.maxCapacity).isEqualTo(
+ RING_BUFFER_CAPACITY
+ )
+ }
+
+ @Test
+ fun isZslDisabled_clearZslConfig() {
+ zslControlImpl = ZslControlImpl(
+ createCameraProperties(
+ hasCapabilities = true,
+ isYuvReprocessingSupported = false,
+ isPrivateReprocessingSupported = true,
+ isJpegValidOutputFormat = true
+ )
+ )
+
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ zslControlImpl.setZslDisabledByUserCaseConfig(true)
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ assertThat(zslControlImpl.reprocessingImageReader).isNull()
+ }
+
+ @Test
+ fun hasZslDisablerQuirk_notAddZslConfig() {
+ ReflectionHelpers.setStaticField(Build::class.java, "BRAND", "samsung")
+ ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "SM-F936B")
+
+ zslControlImpl = ZslControlImpl(
+ createCameraProperties(
+ hasCapabilities = true,
+ isYuvReprocessingSupported = false,
+ isPrivateReprocessingSupported = true,
+ isJpegValidOutputFormat = true
+ )
+ )
+
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ assertThat(zslControlImpl.reprocessingImageReader).isNull()
+ }
+
+ @Test
+ fun hasNoZslDisablerQuirk_addZslConfig() {
+ ReflectionHelpers.setStaticField(Build::class.java, "BRAND", "samsung")
+ ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "SM-G973")
+
+ zslControlImpl = ZslControlImpl(
+ createCameraProperties(
+ hasCapabilities = true,
+ isYuvReprocessingSupported = false,
+ isPrivateReprocessingSupported = true,
+ isJpegValidOutputFormat = true
+ )
+ )
+
+ zslControlImpl.addZslConfig(sessionConfigBuilder)
+
+ assertThat(zslControlImpl.reprocessingImageReader).isNotNull()
+ assertThat(zslControlImpl.reprocessingImageReader!!.imageFormat).isEqualTo(
+ ImageFormat.PRIVATE
+ )
+ assertThat(zslControlImpl.reprocessingImageReader!!.maxImages).isEqualTo(
+ MAX_IMAGES
+ )
+ assertThat(zslControlImpl.reprocessingImageReader!!.width).isEqualTo(
+ PRIVATE_REPROCESSING_MAXIMUM_SIZE.width
+ )
+ assertThat(zslControlImpl.reprocessingImageReader!!.height).isEqualTo(
+ PRIVATE_REPROCESSING_MAXIMUM_SIZE.height
+ )
+ assertThat(zslControlImpl.zslRingBuffer.maxCapacity).isEqualTo(
+ RING_BUFFER_CAPACITY
+ )
+ }
+
+ private fun createCameraProperties(
+ hasCapabilities: Boolean,
+ isYuvReprocessingSupported: Boolean,
+ isPrivateReprocessingSupported: Boolean,
+ isJpegValidOutputFormat: Boolean
+ ): CameraProperties {
+ val characteristicsMap = mutableMapOf<CameraCharacteristics.Key<*>, Any?>()
+ val capabilities = arrayListOf<Int>()
+ if (isYuvReprocessingSupported) {
+ capabilities.add(
+ CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_YUV_REPROCESSING
+ )
+ }
+ if (isPrivateReprocessingSupported) {
+ capabilities.add(
+ CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_PRIVATE_REPROCESSING
+ )
+ }
+
+ if (hasCapabilities) {
+ characteristicsMap[CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES] =
+ capabilities.toIntArray()
+
+ // Input formats
+ val streamConfigurationMap: StreamConfigurationMap = mock()
+
+ if (isYuvReprocessingSupported && isPrivateReprocessingSupported) {
+ whenever(streamConfigurationMap.inputFormats).thenReturn(
+ arrayOf(ImageFormat.YUV_420_888, ImageFormat.PRIVATE).toIntArray()
+ )
+ whenever(streamConfigurationMap.getInputSizes(ImageFormat.YUV_420_888)).thenReturn(
+ arrayOf(YUV_REPROCESSING_MAXIMUM_SIZE)
+ )
+ whenever(streamConfigurationMap.getInputSizes(ImageFormat.PRIVATE)).thenReturn(
+ arrayOf(PRIVATE_REPROCESSING_MAXIMUM_SIZE)
+ )
+ } else if (isYuvReprocessingSupported) {
+ whenever(streamConfigurationMap.inputFormats).thenReturn(
+ arrayOf(ImageFormat.YUV_420_888).toIntArray()
+ )
+ whenever(streamConfigurationMap.getInputSizes(ImageFormat.YUV_420_888)).thenReturn(
+ arrayOf(YUV_REPROCESSING_MAXIMUM_SIZE)
+ )
+ } else if (isPrivateReprocessingSupported) {
+ whenever(streamConfigurationMap.inputFormats).thenReturn(
+ arrayOf(ImageFormat.PRIVATE).toIntArray()
+ )
+ whenever(streamConfigurationMap.getInputSizes(ImageFormat.PRIVATE)).thenReturn(
+ arrayOf(PRIVATE_REPROCESSING_MAXIMUM_SIZE)
+ )
+ }
+
+ // Output formats for input
+ if (isJpegValidOutputFormat) {
+ whenever(streamConfigurationMap.getValidOutputFormatsForInput(ImageFormat.PRIVATE))
+ .thenReturn(arrayOf(ImageFormat.JPEG).toIntArray())
+ whenever(
+ streamConfigurationMap.getValidOutputFormatsForInput(ImageFormat.YUV_420_888)
+ ).thenReturn(arrayOf(ImageFormat.JPEG).toIntArray())
+ }
+
+ characteristicsMap[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP] =
+ streamConfigurationMap
+ }
+ val cameraMetadata = FakeCameraMetadata(
+ characteristics = characteristicsMap
+ )
+
+ return FakeCameraProperties(
+ cameraMetadata,
+ CameraId("0"),
+ )
+ }
+
+ companion object {
+ val YUV_REPROCESSING_MAXIMUM_SIZE = Size(4000, 3000)
+ val PRIVATE_REPROCESSING_MAXIMUM_SIZE = Size(3000, 2000)
+ }
+}
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 4ad90e72..191517a 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
@@ -24,6 +24,7 @@
import android.hardware.camera2.CaptureRequest.CONTROL_AE_MODE
import android.hardware.camera2.CaptureResult
import android.hardware.camera2.params.MeteringRectangle
+import android.media.Image
import android.os.Build
import android.os.Looper
import android.view.Surface
@@ -39,7 +40,9 @@
import androidx.camera.camera2.pipe.StreamId
import androidx.camera.camera2.pipe.integration.adapter.CameraStateAdapter
import androidx.camera.camera2.pipe.integration.adapter.CaptureConfigAdapter
+import androidx.camera.camera2.pipe.integration.adapter.CaptureResultAdapter
import androidx.camera.camera2.pipe.integration.adapter.RobolectricCameraPipeTestRunner
+import androidx.camera.camera2.pipe.integration.adapter.ZslControl
import androidx.camera.camera2.pipe.integration.adapter.asListenableFuture
import androidx.camera.camera2.pipe.integration.compat.StreamConfigurationMapCompat
import androidx.camera.camera2.pipe.integration.compat.quirk.CameraQuirks
@@ -65,10 +68,14 @@
import androidx.camera.camera2.pipe.testing.FakeRequestMetadata
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.ImageProxy
import androidx.camera.core.impl.CaptureConfig
+import androidx.camera.core.impl.DeferrableSurface
import androidx.camera.core.impl.ImmediateSurface
import androidx.camera.core.impl.MutableOptionsBundle
+import androidx.camera.core.impl.SessionConfig
import androidx.camera.core.impl.utils.futures.Futures
+import androidx.camera.core.internal.CameraCaptureResultImageInfo
import androidx.camera.testing.impl.mocks.MockScreenFlash
import androidx.testutils.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
@@ -98,6 +105,8 @@
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
import org.robolectric.annotation.Config
import org.robolectric.annotation.internal.DoNotInstrument
import org.robolectric.shadows.StreamConfigurationMapBuilder
@@ -238,8 +247,45 @@
surfaceToStreamMap = mapOf(fakeDeferrableSurface to fakeStreamId),
cameraStateAdapter = CameraStateAdapter(),
)
- private val fakeCaptureConfigAdapter =
- CaptureConfigAdapter(fakeCameraProperties, fakeUseCaseGraphConfig, fakeUseCaseThreads)
+ private val fakeZslControl = object : ZslControl {
+ var _isZslDisabledByUseCaseConfig = false
+ var _isZslDisabledByFlashMode = false
+ var imageProxyToDequeue: ImageProxy? = null
+
+ override fun addZslConfig(sessionConfigBuilder: SessionConfig.Builder) {
+ // Do nothing
+ }
+
+ override fun isZslSurface(
+ surface: DeferrableSurface,
+ sessionConfig: SessionConfig
+ ): Boolean {
+ return false
+ }
+
+ override fun setZslDisabledByUserCaseConfig(disabled: Boolean) {
+ _isZslDisabledByUseCaseConfig = disabled
+ }
+
+ override fun isZslDisabledByUserCaseConfig(): Boolean {
+ return _isZslDisabledByUseCaseConfig
+ }
+
+ override fun setZslDisabledByFlashMode(disabled: Boolean) {
+ _isZslDisabledByFlashMode = disabled
+ }
+
+ override fun isZslDisabledByFlashMode(): Boolean {
+ return _isZslDisabledByFlashMode
+ }
+
+ override fun dequeueImageFromBuffer(): ImageProxy? {
+ return imageProxyToDequeue
+ }
+ }
+ private val fakeCaptureConfigAdapter = CaptureConfigAdapter(
+ fakeCameraProperties, fakeUseCaseGraphConfig, fakeZslControl, fakeUseCaseThreads
+ )
private var runningRepeatingJob: Job? = null
set(value) {
runningRepeatingJob?.cancel()
@@ -693,6 +739,158 @@
).isFalse()
}
+ @Config(minSdk = 23)
+ @Test
+ fun submitZslCaptureRequests_withZslTemplate_templateZeroShutterLagSent(): Unit = runTest {
+ // Arrange.
+ val requestList = mutableListOf<Request>()
+ fakeCameraGraphSession.requestHandler = { requests ->
+ requestList.addAll(requests)
+ requests.complete()
+ }
+ val imageCaptureConfig = CaptureConfig.Builder().let {
+ it.addSurface(fakeDeferrableSurface)
+ it.templateType = CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG
+ it.build()
+ }
+ configureZslControl()
+
+ // Act.
+ capturePipeline.submitStillCaptures(
+ listOf(imageCaptureConfig),
+ RequestTemplate(CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG),
+ MutableOptionsBundle.create(),
+ captureMode = ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG,
+ flashMode = ImageCapture.FLASH_MODE_OFF,
+ flashType = ImageCapture.FLASH_TYPE_ONE_SHOT_FLASH,
+ ).awaitAllWithTimeout()
+ advanceUntilIdle()
+
+ // Assert.
+ val request = requestList.single()
+ assertThat(request.streams.single()).isEqualTo(fakeStreamId)
+ assertThat(request.template).isEqualTo(
+ RequestTemplate(CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG)
+ )
+ }
+
+ @Config(minSdk = 23)
+ @Test
+ fun submitZslCaptureRequests_withNoTemplate_templateStillPictureSent(): Unit = runTest {
+ // Arrange.
+ val requestList = mutableListOf<Request>()
+ fakeCameraGraphSession.requestHandler = { requests ->
+ requestList.addAll(requests)
+ requests.complete()
+ }
+ val imageCaptureConfig = CaptureConfig.Builder().let {
+ it.addSurface(fakeDeferrableSurface)
+ it.build()
+ }
+ configureZslControl()
+
+ // Act.
+ capturePipeline.submitStillCaptures(
+ listOf(imageCaptureConfig),
+ RequestTemplate(CameraDevice.TEMPLATE_PREVIEW),
+ MutableOptionsBundle.create(),
+ captureMode = ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG,
+ flashMode = ImageCapture.FLASH_MODE_OFF,
+ flashType = ImageCapture.FLASH_TYPE_ONE_SHOT_FLASH,
+ ).awaitAllWithTimeout()
+
+ // Assert.
+ val request = requestList.single()
+ assertThat(request.streams.single()).isEqualTo(fakeStreamId)
+ assertThat(request.template).isEqualTo(
+ RequestTemplate(CameraDevice.TEMPLATE_STILL_CAPTURE)
+ )
+ }
+
+ @Config(minSdk = 23)
+ @Test
+ fun submitZslCaptureRequests_withZslDisabledByUseCaseConfig_templateStillPictureSent():
+ Unit = runTest {
+ // Arrange.
+ val requestList = mutableListOf<Request>()
+ fakeCameraGraphSession.requestHandler = { requests ->
+ requestList.addAll(requests)
+ requests.complete()
+ }
+ val imageCaptureConfig = CaptureConfig.Builder().let {
+ it.addSurface(fakeDeferrableSurface)
+ it.templateType = CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG
+ it.build()
+ }
+ configureZslControl()
+ fakeZslControl.setZslDisabledByUserCaseConfig(true)
+
+ // Act.
+ capturePipeline.submitStillCaptures(
+ listOf(imageCaptureConfig),
+ RequestTemplate(CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG),
+ MutableOptionsBundle.create(),
+ captureMode = ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG,
+ flashMode = ImageCapture.FLASH_MODE_OFF,
+ flashType = ImageCapture.FLASH_TYPE_ONE_SHOT_FLASH,
+ ).awaitAllWithTimeout()
+
+ // Assert.
+ val request = requestList.single()
+ assertThat(request.streams.single()).isEqualTo(fakeStreamId)
+ assertThat(request.template).isEqualTo(
+ RequestTemplate(CameraDevice.TEMPLATE_STILL_CAPTURE)
+ )
+ }
+
+ @Config(minSdk = 23)
+ @Test
+ fun submitZslCaptureRequests_withZslDisabledByFlashMode_templateStillPictureSent():
+ Unit = runTest {
+ // Arrange.
+ val requestList = mutableListOf<Request>()
+ fakeCameraGraphSession.requestHandler = { requests ->
+ requestList.addAll(requests)
+ requests.complete()
+ }
+ val imageCaptureConfig = CaptureConfig.Builder().let {
+ it.addSurface(fakeDeferrableSurface)
+ it.templateType = CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG
+ it.build()
+ }
+ configureZslControl()
+ fakeZslControl.setZslDisabledByFlashMode(true)
+
+ // Act.
+ capturePipeline.submitStillCaptures(
+ listOf(imageCaptureConfig),
+ RequestTemplate(CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG),
+ MutableOptionsBundle.create(),
+ captureMode = ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG,
+ flashMode = ImageCapture.FLASH_MODE_OFF,
+ flashType = ImageCapture.FLASH_TYPE_ONE_SHOT_FLASH,
+ ).awaitAllWithTimeout()
+
+ // Assert.
+ val request = requestList.single()
+ assertThat(request.streams.single()).isEqualTo(fakeStreamId)
+ assertThat(request.template).isEqualTo(
+ RequestTemplate(CameraDevice.TEMPLATE_STILL_CAPTURE)
+ )
+ }
+
+ private fun configureZslControl() {
+ val fakeImageProxy: ImageProxy = mock()
+ val fakeCaptureResult = CaptureResultAdapter(
+ FakeRequestMetadata(), FrameNumber(1), FakeFrameInfo()
+ )
+ val fakeImageInfo = CameraCaptureResultImageInfo(fakeCaptureResult)
+ val fakeImage: Image = mock()
+ whenever(fakeImageProxy.imageInfo).thenReturn(fakeImageInfo)
+ whenever(fakeImageProxy.image).thenReturn(fakeImage)
+ fakeZslControl.imageProxyToDequeue = fakeImageProxy
+ }
+
@Test
fun captureFailure_taskShouldFailure(): Unit = runTest {
// Arrange.
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/StillCaptureRequestTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/StillCaptureRequestTest.kt
index e708509..397a83a 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/StillCaptureRequestTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/StillCaptureRequestTest.kt
@@ -22,6 +22,7 @@
import androidx.camera.camera2.pipe.integration.adapter.CameraStateAdapter
import androidx.camera.camera2.pipe.integration.adapter.CaptureConfigAdapter
import androidx.camera.camera2.pipe.integration.adapter.RobolectricCameraPipeTestRunner
+import androidx.camera.camera2.pipe.integration.adapter.ZslControlNoOpImpl
import androidx.camera.camera2.pipe.integration.compat.workaround.NotUseFlashModeTorchFor3aUpdate
import androidx.camera.camera2.pipe.integration.compat.workaround.NotUseTorchAsFlash
import androidx.camera.camera2.pipe.integration.config.UseCaseGraphConfig
@@ -441,6 +442,7 @@
fakeConfigAdapter = CaptureConfigAdapter(
useCaseGraphConfig = fakeUseCaseGraphConfig,
cameraProperties = fakeCameraProperties,
+ zslControl = ZslControlNoOpImpl(),
threads = fakeUseCaseThreads,
)
fakeUseCaseCameraState = UseCaseCameraState(
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 9893b78..aef3719 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
@@ -26,7 +26,6 @@
import androidx.camera.camera2.pipe.RequestTemplate
import androidx.camera.camera2.pipe.StreamId
import androidx.camera.camera2.pipe.integration.adapter.CameraStateAdapter
-import androidx.camera.camera2.pipe.integration.adapter.CaptureConfigAdapter
import androidx.camera.camera2.pipe.integration.adapter.RobolectricCameraPipeTestRunner
import androidx.camera.camera2.pipe.integration.config.UseCaseGraphConfig
import androidx.camera.camera2.pipe.integration.testing.FakeCameraGraph
@@ -78,11 +77,6 @@
surfaceToStreamMap = surfaceToStreamMap,
cameraStateAdapter = CameraStateAdapter(),
)
- private val fakeConfigAdapter = CaptureConfigAdapter(
- useCaseGraphConfig = fakeUseCaseGraphConfig,
- cameraProperties = fakeCameraProperties,
- threads = useCaseThreads,
- )
private val fakeUseCaseCameraState = UseCaseCameraState(
useCaseGraphConfig = fakeUseCaseGraphConfig,
threads = useCaseThreads,
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraTest.kt
index 8095d71..f7f7978 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/impl/UseCaseCameraTest.kt
@@ -22,7 +22,6 @@
import androidx.camera.camera2.pipe.CameraPipe
import androidx.camera.camera2.pipe.StreamId
import androidx.camera.camera2.pipe.integration.adapter.CameraStateAdapter
-import androidx.camera.camera2.pipe.integration.adapter.CaptureConfigAdapter
import androidx.camera.camera2.pipe.integration.adapter.RobolectricCameraPipeTestRunner
import androidx.camera.camera2.pipe.integration.adapter.SessionConfigAdapter
import androidx.camera.camera2.pipe.integration.compat.workaround.NoOpInactiveSurfaceCloser
@@ -75,11 +74,6 @@
surfaceToStreamMap = surfaceToStreamMap,
cameraStateAdapter = CameraStateAdapter(),
)
- private val fakeConfigAdapter = CaptureConfigAdapter(
- useCaseGraphConfig = fakeUseCaseGraphConfig,
- cameraProperties = fakeCameraProperties,
- threads = useCaseThreads,
- )
private val fakeUseCaseCameraState = UseCaseCameraState(
useCaseGraphConfig = fakeUseCaseGraphConfig,
threads = useCaseThreads,
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 127b304..f6ba121 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
@@ -30,6 +30,7 @@
import androidx.camera.camera2.pipe.integration.adapter.FakeTestUseCase
import androidx.camera.camera2.pipe.integration.adapter.RobolectricCameraPipeTestRunner
import androidx.camera.camera2.pipe.integration.adapter.TestDeferrableSurface
+import androidx.camera.camera2.pipe.integration.adapter.ZslControlNoOpImpl
import androidx.camera.camera2.pipe.integration.compat.StreamConfigurationMapCompat
import androidx.camera.camera2.pipe.integration.compat.quirk.CameraQuirks
import androidx.camera.camera2.pipe.integration.compat.workaround.OutputSizesCorrector
@@ -520,6 +521,8 @@
callbackMap = CameraCallbackMap(),
requestListener = ComboRequestListener(),
builder = useCaseCameraComponentBuilder,
+ cameraControl = fakeCamera.cameraControlInternal,
+ zslControl = ZslControlNoOpImpl(),
controls = controls as java.util.Set<UseCaseCameraControl>,
cameraProperties = FakeCameraProperties(
metadata = fakeCameraMetadata,
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraPipe.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraPipe.kt
index 90c2c5d..12c29ee 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraPipe.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraPipe.kt
@@ -25,6 +25,7 @@
import android.os.HandlerThread
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
+import androidx.camera.camera2.pipe.compat.AudioRestrictionController
import androidx.camera.camera2.pipe.config.CameraGraphConfigModule
import androidx.camera.camera2.pipe.config.CameraPipeComponent
import androidx.camera.camera2.pipe.config.CameraPipeConfigModule
@@ -116,6 +117,16 @@
}
/**
+ * This gets and sets the global [AudioRestrictionMode] tracked by [AudioRestrictionController].
+ */
+ var globalAudioRestrictionMode: AudioRestrictionMode
+ get(): AudioRestrictionMode =
+ component.cameraAudioRestrictionController().globalAudioRestrictionMode
+ set(value: AudioRestrictionMode) {
+ component.cameraAudioRestrictionController().globalAudioRestrictionMode = value
+ }
+
+ /**
* Application level configuration for [CameraPipe]. Nullable values are optional and reasonable
* defaults will be provided if values are not specified.
*/
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraPipeComponent.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraPipeComponent.kt
index 0d5c122..98dc196 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraPipeComponent.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraPipeComponent.kt
@@ -32,6 +32,8 @@
import androidx.camera.camera2.pipe.CameraPipe.CameraMetadataConfig
import androidx.camera.camera2.pipe.CameraSurfaceManager
import androidx.camera.camera2.pipe.compat.AndroidDevicePolicyManagerWrapper
+import androidx.camera.camera2.pipe.compat.AudioRestrictionController
+import androidx.camera.camera2.pipe.compat.AudioRestrictionControllerImpl
import androidx.camera.camera2.pipe.compat.DevicePolicyManagerWrapper
import androidx.camera.camera2.pipe.core.Debug
import androidx.camera.camera2.pipe.core.SystemTimeSource
@@ -71,6 +73,7 @@
fun cameraGraphComponentBuilder(): CameraGraphComponent.Builder
fun cameras(): CameraDevices
fun cameraSurfaceManager(): CameraSurfaceManager
+ fun cameraAudioRestrictionController(): AudioRestrictionController
}
@Module(includes = [ThreadConfigModule::class], subcomponents = [CameraGraphComponent::class])
@@ -165,5 +168,9 @@
@Singleton
@Provides
fun provideCameraSurfaceManager() = CameraSurfaceManager()
+
+ @Singleton
+ @Provides
+ fun provideAudioRestrictionController() = AudioRestrictionControllerImpl()
}
}
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index d44514f..d359734 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -435,7 +435,7 @@
}
@SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public interface BringIntoViewSpec {
- method public float calculateScrollDistance(float offset, float size, float containerSize);
+ method public default float calculateScrollDistance(float offset, float size, float containerSize);
method public default androidx.compose.animation.core.AnimationSpec<java.lang.Float> getScrollAnimationSpec();
property public default androidx.compose.animation.core.AnimationSpec<java.lang.Float> scrollAnimationSpec;
field public static final androidx.compose.foundation.gestures.BringIntoViewSpec.Companion Companion;
@@ -446,6 +446,11 @@
property public final androidx.compose.animation.core.AnimationSpec<java.lang.Float> DefaultScrollAnimationSpec;
}
+ public final class BringIntoViewSpec_androidKt {
+ method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.gestures.BringIntoViewSpec> getLocalBringIntoViewSpec();
+ property @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.gestures.BringIntoViewSpec> LocalBringIntoViewSpec;
+ }
+
@SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public interface Drag2DScope {
method public void dragBy(long pixels);
}
@@ -544,7 +549,7 @@
}
public final class ScrollableDefaults {
- method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec();
+ method @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec();
method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior();
method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect();
method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling);
@@ -552,7 +557,7 @@
}
public final class ScrollableKt {
- method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec);
+ method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.foundation.gestures.BringIntoViewSpec? bringIntoViewSpec);
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource);
}
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index 01da236..de43720 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -437,7 +437,7 @@
}
@SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public interface BringIntoViewSpec {
- method public float calculateScrollDistance(float offset, float size, float containerSize);
+ method public default float calculateScrollDistance(float offset, float size, float containerSize);
method public default androidx.compose.animation.core.AnimationSpec<java.lang.Float> getScrollAnimationSpec();
property public default androidx.compose.animation.core.AnimationSpec<java.lang.Float> scrollAnimationSpec;
field public static final androidx.compose.foundation.gestures.BringIntoViewSpec.Companion Companion;
@@ -448,6 +448,11 @@
property public final androidx.compose.animation.core.AnimationSpec<java.lang.Float> DefaultScrollAnimationSpec;
}
+ public final class BringIntoViewSpec_androidKt {
+ method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.gestures.BringIntoViewSpec> getLocalBringIntoViewSpec();
+ property @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.gestures.BringIntoViewSpec> LocalBringIntoViewSpec;
+ }
+
@SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public interface Drag2DScope {
method public void dragBy(long pixels);
}
@@ -546,7 +551,7 @@
}
public final class ScrollableDefaults {
- method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec();
+ method @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec();
method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior();
method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect();
method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling);
@@ -554,7 +559,7 @@
}
public final class ScrollableKt {
- method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec);
+ method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.foundation.gestures.BringIntoViewSpec? bringIntoViewSpec);
method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource);
}
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt
index 2c7ae2c..245b962 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt
@@ -18,6 +18,7 @@
package androidx.compose.foundation.demos
import android.annotation.SuppressLint
+import android.content.res.Configuration
import androidx.compose.animation.core.AnimationConstants
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.animateTo
@@ -27,6 +28,7 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.animateScrollBy
@@ -46,8 +48,10 @@
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
@@ -90,7 +94,10 @@
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Color.Companion.Red
+import androidx.compose.ui.graphics.Color.Companion.White
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
@@ -130,6 +137,7 @@
ComposableDemo("Grid drag and drop") { LazyGridDragAndDropDemo() },
ComposableDemo("Staggered grid") { LazyStaggeredGridDemo() },
ComposableDemo("Animate item placement") { AnimateItemPlacementDemo() },
+ ComposableDemo("Focus Scrolling") { BringIntoViewDemo() },
PagingDemos
)
@@ -349,7 +357,8 @@
Spacer(
Modifier
.fillParentMaxSize()
- .background(it))
+ .background(it)
+ )
}
}
}
@@ -555,6 +564,7 @@
}
}
}
+
val lazyContent: LazyListScope.() -> Unit = {
items(count) {
item1(it)
@@ -1037,9 +1047,11 @@
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AnimateItemPlacementDemo() {
- val items = remember { mutableStateListOf<Int>().apply {
- repeat(20) { add(it) }
- } }
+ val items = remember {
+ mutableStateListOf<Int>().apply {
+ repeat(20) { add(it) }
+ }
+ }
val selectedIndexes = remember { mutableStateMapOf<Int, Boolean>() }
var reverse by remember { mutableStateOf(false) }
Column {
@@ -1068,7 +1080,8 @@
LazyColumn(
Modifier
.fillMaxWidth()
- .weight(1f), reverseLayout = reverse) {
+ .weight(1f), reverseLayout = reverse
+ ) {
items(items, key = { it }) { item ->
val selected = selectedIndexes.getOrDefault(item, false)
val modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
@@ -1089,7 +1102,8 @@
Spacer(
Modifier
.width(16.dp)
- .height(height))
+ .height(height)
+ )
Text("Item $item")
}
}
@@ -1105,3 +1119,31 @@
})
}
}
+
+@Preview(uiMode = Configuration.UI_MODE_TYPE_TELEVISION)
+@Composable
+private fun BringIntoViewDemo() {
+ LazyRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ ) {
+ items(100) {
+ var color by remember { mutableStateOf(Color.White) }
+ Box(
+ modifier = Modifier
+ .size(100.dp)
+ .padding(4.dp)
+ .background(Color.Gray)
+ .onFocusChanged {
+ color = if (it.isFocused) Red else White
+ }
+ .border(5.dp, color)
+ .focusable(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = it.toString())
+ }
+ }
+ }
+}
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/ScrollableSamples.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/ScrollableSamples.kt
index 1645cb76..ae7c7ec 100644
--- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/ScrollableSamples.kt
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/ScrollableSamples.kt
@@ -17,15 +17,26 @@
package androidx.compose.foundation.samples
import androidx.annotation.Sampled
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.BringIntoViewSpec
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon
@@ -34,15 +45,20 @@
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import kotlin.math.abs
import kotlin.math.roundToInt
@Sampled
@@ -113,3 +129,68 @@
)
}
}
+
+@ExperimentalFoundationApi
+@Sampled
+@Composable
+fun FocusScrollingInLazyRowSample() {
+ // a bring into view spec that pivots around the center of the scrollable container
+ val customBringIntoViewSpec = object : BringIntoViewSpec {
+ val customAnimationSpec = tween<Float>(easing = LinearEasing)
+ override val scrollAnimationSpec: AnimationSpec<Float>
+ get() = customAnimationSpec
+
+ override fun calculateScrollDistance(
+ offset: Float,
+ size: Float,
+ containerSize: Float
+ ): Float {
+ val trailingEdgeOfItemRequestingFocus = offset + size
+
+ val sizeOfItemRequestingFocus =
+ abs(trailingEdgeOfItemRequestingFocus - offset)
+ val childSmallerThanParent = sizeOfItemRequestingFocus <= containerSize
+ val initialTargetForLeadingEdge =
+ containerSize / 2f - (sizeOfItemRequestingFocus / 2f)
+ val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
+
+ val targetForLeadingEdge =
+ if (childSmallerThanParent &&
+ spaceAvailableToShowItem < sizeOfItemRequestingFocus
+ ) {
+ containerSize - sizeOfItemRequestingFocus
+ } else {
+ initialTargetForLeadingEdge
+ }
+
+ return offset - targetForLeadingEdge
+ }
+ }
+
+ // LocalBringIntoViewSpec will apply to all scrollables in the hierarchy.
+ CompositionLocalProvider(LocalBringIntoViewSpec provides customBringIntoViewSpec) {
+ LazyRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ ) {
+ items(100) {
+ var color by remember { mutableStateOf(Color.White) }
+ Box(
+ modifier = Modifier
+ .size(100.dp)
+ .padding(4.dp)
+ .background(Color.Gray)
+ .onFocusChanged {
+ color = if (it.isFocused) Color.Red else Color.White
+ }
+ .border(5.dp, color)
+ .focusable(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = it.toString())
+ }
+ }
+ }
+ }
+}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/gestures/BringIntoViewSpec.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/gestures/BringIntoViewSpec.android.kt
new file mode 100644
index 0000000..482a642
--- /dev/null
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/gestures/BringIntoViewSpec.android.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 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.compose.foundation.gestures
+
+import android.content.pm.PackageManager.FEATURE_LEANBACK
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.BringIntoViewSpec.Companion.DefaultBringIntoViewSpec
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.compositionLocalWithComputedDefaultOf
+import androidx.compose.ui.platform.LocalContext
+import kotlin.math.abs
+
+/**
+ * A composition local to customize the focus scrolling behavior used by some scrollable containers.
+ * [LocalBringIntoViewSpec] has a platform defined behavior. If the App is running on a TV device,
+ * the scroll behavior will pivot around 30% of the container size. For other platforms, the scroll
+ * behavior will move the least to bring the requested region into view.
+ */
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@get:ExperimentalFoundationApi
+@ExperimentalFoundationApi
+actual val LocalBringIntoViewSpec: ProvidableCompositionLocal<BringIntoViewSpec> =
+ compositionLocalWithComputedDefaultOf {
+ val hasTvFeature =
+ LocalContext.currentValue.packageManager.hasSystemFeature(FEATURE_LEANBACK)
+ if (!hasTvFeature) {
+ DefaultBringIntoViewSpec
+ } else {
+ PivotBringIntoViewSpec
+ }
+ }
+
+@OptIn(ExperimentalFoundationApi::class)
+internal val PivotBringIntoViewSpec = object : BringIntoViewSpec {
+ val parentFraction = 0.3f
+ val childFraction = 0f
+ override val scrollAnimationSpec: AnimationSpec<Float> = tween<Float>(
+ durationMillis = 125,
+ easing = CubicBezierEasing(0.25f, 0.1f, .25f, 1f)
+ )
+
+ override fun calculateScrollDistance(
+ offset: Float,
+ size: Float,
+ containerSize: Float
+ ): Float {
+ val leadingEdgeOfItemRequestingFocus = offset
+ val trailingEdgeOfItemRequestingFocus = offset + size
+
+ val sizeOfItemRequestingFocus =
+ abs(trailingEdgeOfItemRequestingFocus - leadingEdgeOfItemRequestingFocus)
+ val childSmallerThanParent = sizeOfItemRequestingFocus <= containerSize
+ val initialTargetForLeadingEdge =
+ parentFraction * containerSize -
+ (childFraction * sizeOfItemRequestingFocus)
+ val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
+
+ val targetForLeadingEdge =
+ if (childSmallerThanParent &&
+ spaceAvailableToShowItem < sizeOfItemRequestingFocus
+ ) {
+ containerSize - sizeOfItemRequestingFocus
+ } else {
+ initialTargetForLeadingEdge
+ }
+
+ return leadingEdgeOfItemRequestingFocus - targetForLeadingEdge
+ }
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt
index 0504ecc..244dc7a 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt
@@ -37,9 +37,10 @@
reverseScrolling: Boolean,
flingBehavior: FlingBehavior?,
interactionSource: MutableInteractionSource?,
- bringIntoViewSpec: BringIntoViewSpec = ScrollableDefaults.bringIntoViewSpec()
+ bringIntoViewSpec: BringIntoViewSpec? = null
): Modifier {
val overscrollEffect = ScrollableDefaults.overscrollEffect()
+
return clipScrollableContainer(orientation)
.overscroll(overscrollEffect)
.scrollable(
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/BringIntoViewSpec.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/BringIntoViewSpec.kt
new file mode 100644
index 0000000..f030e6a
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/BringIntoViewSpec.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 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.compose.foundation.gestures
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Modifier
+import kotlin.math.abs
+
+/**
+ * A composition local to customize the focus scrolling behavior used by some scrollable containers.
+ * [LocalBringIntoViewSpec] has a platform defined default behavior.
+ */
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@get:ExperimentalFoundationApi
+@ExperimentalFoundationApi
+expect val LocalBringIntoViewSpec: ProvidableCompositionLocal<BringIntoViewSpec>
+
+/**
+ * The configuration of how a scrollable reacts to bring into view requests.
+ *
+ * Note: API shape and naming are still being refined, therefore API is marked as experimental.
+ *
+ * Check the following sample for a use case usage of this API:
+ * @sample androidx.compose.foundation.samples.FocusScrollingInLazyRowSample
+ */
+@ExperimentalFoundationApi
+@Stable
+interface BringIntoViewSpec {
+
+ /**
+ * An Animation Spec to be used as the animation to run to fulfill the BringIntoView requests.
+ */
+ val scrollAnimationSpec: AnimationSpec<Float> get() = DefaultScrollAnimationSpec
+
+ /**
+ * Calculate the offset needed to bring one of the scrollable container's child into view.
+ * This will be called for every frame of the scrolling animation. This means that, as the
+ * animation progresses, the offset will naturally change to fulfill the scroll request.
+ *
+ * All distances below are represented in pixels.
+ * @param offset from the side closest to the start of the container.
+ * @param size is the child size.
+ * @param containerSize Is the main axis size of the scrollable container.
+ *
+ * @return The necessary amount to scroll to satisfy the bring into view request.
+ * Returning zero from here means that the request was satisfied and the scrolling animation
+ * should stop.
+ */
+ fun calculateScrollDistance(
+ offset: Float,
+ size: Float,
+ containerSize: Float
+ ): Float = defaultCalculateScrollDistance(offset, size, containerSize)
+
+ companion object {
+
+ /**
+ * The default animation spec used by [Modifier.scrollable] to run Bring Into View requests.
+ */
+ val DefaultScrollAnimationSpec: AnimationSpec<Float> = spring()
+
+ internal val DefaultBringIntoViewSpec = object : BringIntoViewSpec {}
+
+ internal fun defaultCalculateScrollDistance(
+ offset: Float,
+ size: Float,
+ containerSize: Float
+ ): Float {
+ val trailingEdge = offset + size
+ @Suppress("UnnecessaryVariable") val leadingEdge = offset
+ return when {
+
+ // If the item is already visible, no need to scroll.
+ leadingEdge >= 0 && trailingEdge <= containerSize -> 0f
+
+ // If the item is visible but larger than the parent, we don't scroll.
+ leadingEdge < 0 && trailingEdge > containerSize -> 0f
+
+ // Find the minimum scroll needed to make one of the edges coincide with the parent's
+ // edge.
+ abs(leadingEdge) < abs(trailingEdge - containerSize) -> leadingEdge
+ else -> trailingEdge - containerSize
+ }
+ }
+ }
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt
index 9976dc1..18ce61c 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt
@@ -26,7 +26,9 @@
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.LayoutAwareModifierNode
+import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.requireLayoutCoordinates
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
@@ -65,8 +67,9 @@
private var orientation: Orientation,
private var scrollState: ScrollableState,
private var reverseDirection: Boolean,
- private var bringIntoViewSpec: BringIntoViewSpec
-) : Modifier.Node(), BringIntoViewResponder, LayoutAwareModifierNode {
+ private var bringIntoViewSpec: BringIntoViewSpec?
+) : Modifier.Node(), BringIntoViewResponder, LayoutAwareModifierNode,
+ CompositionLocalConsumerModifierNode {
/**
* Ongoing requests from [bringChildIntoView], with the invariant that it is always sorted by
@@ -111,6 +114,10 @@
return computeDestination(localRect, viewportSize)
}
+ private fun requireBringIntoViewSpec(): BringIntoViewSpec {
+ return bringIntoViewSpec ?: currentValueOf(LocalBringIntoViewSpec)
+ }
+
override suspend fun bringChildIntoView(localRect: () -> Rect?) {
// Avoid creating no-op requests and no-op animations if the request does not require
// scrolling or returns null.
@@ -171,6 +178,7 @@
}
private fun launchAnimation() {
+ val bringIntoViewSpec = requireBringIntoViewSpec()
check(!isAnimationRunning) { "launchAnimation called when previous animation was running" }
if (DEBUG) println("[$TAG] launchAnimation")
@@ -182,7 +190,7 @@
try {
isAnimationRunning = true
scrollState.scroll {
- animationState.value = calculateScrollDelta()
+ animationState.value = calculateScrollDelta(bringIntoViewSpec)
if (DEBUG) println(
"[$TAG] Starting scroll animation down from ${animationState.value}…"
)
@@ -247,7 +255,7 @@
// Compute a new scroll target taking into account any resizes,
// replacements, or added/removed requests since the last frame.
- animationState.value = calculateScrollDelta()
+ animationState.value = calculateScrollDelta(bringIntoViewSpec)
if (DEBUG) println(
"[$TAG] scroll target after frame: ${animationState.value}"
)
@@ -286,7 +294,7 @@
* Calculates how far we need to scroll to satisfy all existing BringIntoView requests and the
* focused child tracking.
*/
- private fun calculateScrollDelta(): Float {
+ private fun calculateScrollDelta(bringIntoViewSpec: BringIntoViewSpec): Float {
if (viewportSize == IntSize.Zero) return 0f
val rectangleToMakeVisible: Rect = findBringIntoViewRequest()
@@ -358,7 +366,7 @@
return when (orientation) {
Vertical -> Offset(
x = 0f,
- y = bringIntoViewSpec.calculateScrollDistance(
+ y = requireBringIntoViewSpec().calculateScrollDistance(
childBounds.top,
childBounds.bottom - childBounds.top,
size.height
@@ -366,7 +374,7 @@
)
Horizontal -> Offset(
- x = bringIntoViewSpec.calculateScrollDistance(
+ x = requireBringIntoViewSpec().calculateScrollDistance(
childBounds.left,
childBounds.right - childBounds.left,
size.width
@@ -390,7 +398,7 @@
orientation: Orientation,
state: ScrollableState,
reverseDirection: Boolean,
- bringIntoViewSpec: BringIntoViewSpec
+ bringIntoViewSpec: BringIntoViewSpec?
) {
this.orientation = orientation
this.scrollState = state
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
index cf92947..8f3b7b7 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
@@ -16,11 +16,9 @@
package androidx.compose.foundation.gestures
-import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animateDecay
-import androidx.compose.animation.core.spring
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -155,7 +153,9 @@
* @param interactionSource [MutableInteractionSource] that will be used to emit
* drag events when this scrollable is being dragged.
* @param bringIntoViewSpec The configuration that this scrollable should use to perform
- * scrolling when scroll requests are received from the focus system.
+ * scrolling when scroll requests are received from the focus system. If null is provided the
+ * system will use the behavior provided by [LocalBringIntoViewSpec] which by default has a
+ * platform dependent implementation.
*
* Note: This API is experimental as it brings support for some experimental features:
* [overscrollEffect] and [bringIntoViewSpec].
@@ -170,7 +170,7 @@
reverseDirection: Boolean = false,
flingBehavior: FlingBehavior? = null,
interactionSource: MutableInteractionSource? = null,
- bringIntoViewSpec: BringIntoViewSpec = ScrollableDefaults.bringIntoViewSpec()
+ bringIntoViewSpec: BringIntoViewSpec? = null
) = this then ScrollableElement(
state,
orientation,
@@ -191,7 +191,7 @@
val reverseDirection: Boolean,
val flingBehavior: FlingBehavior?,
val interactionSource: MutableInteractionSource?,
- val bringIntoViewSpec: BringIntoViewSpec
+ val bringIntoViewSpec: BringIntoViewSpec?
) : ModifierNodeElement<ScrollableNode>() {
override fun create(): ScrollableNode {
return ScrollableNode(
@@ -270,7 +270,7 @@
enabled: Boolean,
reverseDirection: Boolean,
interactionSource: MutableInteractionSource?,
- bringIntoViewSpec: BringIntoViewSpec
+ private val bringIntoViewSpec: BringIntoViewSpec?
) : DragGestureNode(
canDrag = CanDragCalculation,
enabled = enabled,
@@ -355,7 +355,7 @@
reverseDirection: Boolean,
flingBehavior: FlingBehavior?,
interactionSource: MutableInteractionSource?,
- bringIntoViewSpec: BringIntoViewSpec
+ bringIntoViewSpec: BringIntoViewSpec?
) {
if (this.enabled != enabled) { // enabled changed
@@ -491,80 +491,6 @@
}
/**
- * The configuration of how a scrollable reacts to bring into view requests.
- *
- * Note: API shape and naming are still being refined, therefore API is marked as experimental.
- */
-@ExperimentalFoundationApi
-@Stable
-interface BringIntoViewSpec {
-
- /**
- * A retargetable Animation Spec to be used as the animation to run to fulfill the
- * BringIntoView requests.
- */
- val scrollAnimationSpec: AnimationSpec<Float> get() = DefaultScrollAnimationSpec
-
- /**
- * Calculate the offset needed to bring one of the scrollable container's child into view.
- *
- * @param offset from the side closest to the origin (For the x-axis this is 'left',
- * for the y-axis this is 'top').
- * @param size is the child size.
- * @param containerSize Is the main axis size of the scrollable container.
- *
- * All distances above are represented in pixels.
- *
- * @return The necessary amount to scroll to satisfy the bring into view request.
- * Returning zero from here means that the request was satisfied and the scrolling animation
- * should stop.
- *
- * This will be called for every frame of the scrolling animation. This means that, as the
- * animation progresses, the offset will naturally change to fulfill the scroll request.
- */
- fun calculateScrollDistance(
- offset: Float,
- size: Float,
- containerSize: Float
- ): Float
-
- companion object {
-
- /**
- * The default animation spec used by [Modifier.scrollable] to run Bring Into View requests.
- */
- val DefaultScrollAnimationSpec: AnimationSpec<Float> = spring()
-
- internal val DefaultBringIntoViewSpec = object : BringIntoViewSpec {
-
- override val scrollAnimationSpec: AnimationSpec<Float> = DefaultScrollAnimationSpec
-
- override fun calculateScrollDistance(
- offset: Float,
- size: Float,
- containerSize: Float
- ): Float {
- val trailingEdge = offset + size
- @Suppress("UnnecessaryVariable") val leadingEdge = offset
- return when {
-
- // If the item is already visible, no need to scroll.
- leadingEdge >= 0 && trailingEdge <= containerSize -> 0f
-
- // If the item is visible but larger than the parent, we don't scroll.
- leadingEdge < 0 && trailingEdge > containerSize -> 0f
-
- // Find the minimum scroll needed to make one of the edges coincide with the parent's
- // edge.
- abs(leadingEdge) < abs(trailingEdge - containerSize) -> leadingEdge
- else -> trailingEdge - containerSize
- }
- }
- }
- }
-}
-
-/**
* Contains the default values used by [scrollable]
*/
object ScrollableDefaults {
@@ -620,6 +546,13 @@
* A default implementation for [BringIntoViewSpec] that brings a child into view
* using the least amount of effort.
*/
+ @Deprecated(
+ "This has been replaced by composition locals LocalBringIntoViewSpec",
+ replaceWith = ReplaceWith(
+ "LocalBringIntoView.current",
+ "androidx.compose.foundation.gestures.LocalBringIntoViewSpec"
+ )
+ )
@ExperimentalFoundationApi
fun bringIntoViewSpec(): BringIntoViewSpec = DefaultBringIntoViewSpec
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
index 0de86a8..8771eed 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
@@ -22,9 +22,9 @@
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollScope
-import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.TargetedFlingBehavior
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
@@ -134,7 +134,7 @@
PagerWrapperFlingBehavior(flingBehavior, state)
}
- val defaultBringIntoViewSpec = ScrollableDefaults.bringIntoViewSpec()
+ val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
val pagerBringIntoViewSpec = remember(state, defaultBringIntoViewSpec) {
PagerBringIntoViewSpec(
state,
diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/gestures/BringIntoViewSpec.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/gestures/BringIntoViewSpec.desktop.kt
new file mode 100644
index 0000000..061d648
--- /dev/null
+++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/gestures/BringIntoViewSpec.desktop.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 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.compose.foundation.gestures
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.staticCompositionLocalOf
+
+/*
+* A composition local to customize the focus scrolling behavior used by some scrollable containers.
+* [LocalBringIntoViewSpec] has a platform defined behavior. The scroll default behavior will move
+* the least to bring the requested region into view.
+*/
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@get:ExperimentalFoundationApi
+@ExperimentalFoundationApi
+actual val LocalBringIntoViewSpec: ProvidableCompositionLocal<BringIntoViewSpec> =
+ staticCompositionLocalOf {
+ BringIntoViewSpec.DefaultBringIntoViewSpec
+ }
diff --git a/datastore/datastore-core/src/androidInstrumentedTest/kotlin/androidx/datastore/core/multiprocess/MultiProcessDataStoreIpcTest.kt b/datastore/datastore-core/src/androidInstrumentedTest/kotlin/androidx/datastore/core/multiprocess/MultiProcessDataStoreIpcTest.kt
index 811c9f7..0da1391 100644
--- a/datastore/datastore-core/src/androidInstrumentedTest/kotlin/androidx/datastore/core/multiprocess/MultiProcessDataStoreIpcTest.kt
+++ b/datastore/datastore-core/src/androidInstrumentedTest/kotlin/androidx/datastore/core/multiprocess/MultiProcessDataStoreIpcTest.kt
@@ -30,7 +30,6 @@
import androidx.datastore.core.twoWayIpc.IpcUnit
import androidx.datastore.core.twoWayIpc.TwoWayIpcSubject
import androidx.datastore.testing.TestMessageProto.FooProto
-import androidx.kruth.assertThrows
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -565,12 +564,11 @@
delay(100)
// process A (could be another thread than 1.) waits to hold file lock 2 (still held by B)
- assertThrows<IOException> {
+ val localUpdate2 = async {
datastore2.updateData {
it.toBuilder().setInteger(4).build()
}
- }.hasMessageThat()
- .contains("Resource deadlock would occur")
+ }
blockWrite.complete(Unit)
commitWriteLatch1.complete(subject2, IpcUnit)
@@ -579,9 +577,11 @@
setTextAction1.await()
setTextAction2.await()
localUpdate1.await()
+ localUpdate2.await()
assertThat(datastore1.data.first().text).isEqualTo("remoteValue")
assertThat(datastore1.data.first().integer).isEqualTo(3)
assertThat(datastore2.data.first().text).isEqualTo("remoteValue")
+ assertThat(datastore2.data.first().integer).isEqualTo(4)
}
}
diff --git a/datastore/datastore-core/src/androidMain/kotlin/androidx/datastore/core/MultiProcessCoordinator.android.kt b/datastore/datastore-core/src/androidMain/kotlin/androidx/datastore/core/MultiProcessCoordinator.android.kt
index 3d3edff..e596d66 100644
--- a/datastore/datastore-core/src/androidMain/kotlin/androidx/datastore/core/MultiProcessCoordinator.android.kt
+++ b/datastore/datastore-core/src/androidMain/kotlin/androidx/datastore/core/MultiProcessCoordinator.android.kt
@@ -25,6 +25,7 @@
import java.nio.channels.FileLock
import kotlin.contracts.ExperimentalContracts
import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -43,7 +44,7 @@
FileOutputStream(lockFile).use { lockFileStream ->
var lock: FileLock? = null
try {
- lock = lockFileStream.getChannel().lock(0L, Long.MAX_VALUE, /* shared= */ false)
+ lock = getExclusiveFileLockWithRetryIfDeadlock(lockFileStream)
return block()
} finally {
lock?.release()
@@ -78,7 +79,8 @@
// will throw an IOException with EAGAIN error, instead of returning null as
// specified in {@link FileChannel#tryLock}. We only continue if the error
// message is EAGAIN, otherwise just throw it.
- if (ex.message?.startsWith(LOCK_ERROR_MESSAGE) != true) {
+ if ((ex.message?.startsWith(LOCK_ERROR_MESSAGE) != true) &&
+ (ex.message?.startsWith(DEADLOCK_ERROR_MESSAGE) != true)) {
throw ex
}
}
@@ -162,6 +164,32 @@
}
}
}
+
+ companion object {
+ // Retry with exponential backoff to get file lock if it hits "Resource deadlock would
+ // occur" error until the backoff reaches [MAX_WAIT_MILLIS].
+ private suspend fun getExclusiveFileLockWithRetryIfDeadlock(
+ lockFileStream: FileOutputStream
+ ): FileLock {
+ var backoff = INITIAL_WAIT_MILLIS
+ while (backoff <= MAX_WAIT_MILLIS) {
+ try {
+ return lockFileStream.getChannel().lock(0L, Long.MAX_VALUE, /* shared= */ false)
+ } catch (ex: IOException) {
+ if (ex.message?.contains(DEADLOCK_ERROR_MESSAGE) != true) {
+ throw ex
+ }
+ delay(backoff)
+ backoff *= 2
+ }
+ }
+ return lockFileStream.getChannel().lock(0L, Long.MAX_VALUE, /* shared= */ false)
+ }
+
+ private val DEADLOCK_ERROR_MESSAGE = "Resource deadlock would occur"
+ private val INITIAL_WAIT_MILLIS: Long = 10
+ private val MAX_WAIT_MILLIS: Long = 60000
+ }
}
/**