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
+    }
 }
 
 /**