Merge "Fix DeviceCompatibilityTest fail on some devices" into androidx-main
diff --git a/bluetooth/integration-tests/testapp/build.gradle b/bluetooth/integration-tests/testapp/build.gradle
index 6c8a1f3..9856b9f 100644
--- a/bluetooth/integration-tests/testapp/build.gradle
+++ b/bluetooth/integration-tests/testapp/build.gradle
@@ -45,26 +45,25 @@
 }
 
 dependencies {
-    implementation(libs.kotlinStdlib)
     implementation(project(":bluetooth:bluetooth"))
 
+    implementation(libs.kotlinStdlib)
+    implementation(libs.kotlinCoroutinesAndroid)
+
     implementation("androidx.activity:activity-ktx:1.8.0")
-    implementation("androidx.appcompat:appcompat:1.6.1")
-    implementation(libs.constraintLayout)
     implementation("androidx.core:core-ktx:1.12.0")
-    implementation("androidx.fragment:fragment-ktx:1.6.1")
+    implementation("androidx.fragment:fragment-ktx:1.6.2")
     implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2")
     implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
-    implementation("androidx.navigation:navigation-fragment-ktx:2.7.4")
-    implementation("androidx.navigation:navigation-ui-ktx:2.7.4")
+    implementation("androidx.navigation:navigation-fragment-ktx:2.7.5")
+    implementation("androidx.navigation:navigation-ui-ktx:2.7.5")
     implementation("androidx.recyclerview:recyclerview:1.3.2")
 
+    implementation(libs.constraintLayout)
+    implementation(libs.material)
+
     implementation(libs.hiltAndroid)
     kapt(libs.hiltCompiler)
 
-    implementation(libs.material)
-
-    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
-
     kaptAndroidTest(libs.hiltCompiler)
 }
diff --git a/bluetooth/integration-tests/testapp/src/main/AndroidManifest.xml b/bluetooth/integration-tests/testapp/src/main/AndroidManifest.xml
index 45bf89a..a6f68cb 100644
--- a/bluetooth/integration-tests/testapp/src/main/AndroidManifest.xml
+++ b/bluetooth/integration-tests/testapp/src/main/AndroidManifest.xml
@@ -9,7 +9,7 @@
         android:theme="@style/Theme.TestApp"
         tools:ignore="GoogleAppIndexingWarning,MissingApplicationIcon">
         <activity
-            android:name=".MainActivity"
+            android:name=".ui.main.MainActivity"
             android:exported="true">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserFragment.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserFragment.kt
index b86258e..5dd5ec4 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserFragment.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserFragment.kt
@@ -143,8 +143,8 @@
 
     override fun onDestroyView() {
         super.onDestroyView()
-        _binding = null
         isAdvertising = false
+        _binding = null
     }
 
     private fun initData() {
@@ -261,7 +261,6 @@
                 TAG, "bluetoothLe.advertise() called with: " +
                     "viewModel.advertiseParams = ${viewModel.advertiseParams}"
             )
-
             isAdvertising = true
 
             bluetoothLe.advertise(viewModel.advertiseParams) {
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/connections/ConnectionsFragment.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/connections/ConnectionsFragment.kt
index ef2b34c..dfef4b3 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/connections/ConnectionsFragment.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/connections/ConnectionsFragment.kt
@@ -13,7 +13,6 @@
 import androidx.bluetooth.BluetoothDevice
 import androidx.bluetooth.BluetoothLe
 import androidx.bluetooth.GattCharacteristic
-import androidx.bluetooth.integration.testapp.MainViewModel
 import androidx.bluetooth.integration.testapp.R
 import androidx.bluetooth.integration.testapp.data.connection.DeviceConnection
 import androidx.bluetooth.integration.testapp.data.connection.OnCharacteristicActionClick
@@ -21,6 +20,7 @@
 import androidx.bluetooth.integration.testapp.databinding.FragmentConnectionsBinding
 import androidx.bluetooth.integration.testapp.ui.common.getColor
 import androidx.bluetooth.integration.testapp.ui.common.toast
+import androidx.bluetooth.integration.testapp.ui.main.MainViewModel
 import androidx.core.view.isVisible
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.activityViewModels
@@ -53,7 +53,7 @@
 
     private var deviceServicesAdapter: DeviceServicesAdapter? = null
 
-    private val connectScope = CoroutineScope(Dispatchers.Default + Job())
+    private val connectScope = CoroutineScope(Dispatchers.Main + Job())
 
     private val onTabSelectedListener = object : TabLayout.OnTabSelectedListener {
         override fun onTabSelected(tab: Tab) {
@@ -121,8 +121,8 @@
 
     override fun onDestroyView() {
         super.onDestroyView()
-        _binding = null
         connectScope.cancel()
+        _binding = null
     }
 
     private fun initData() {
@@ -185,9 +185,7 @@
 
         deviceConnection.job = connectScope.launch {
             deviceConnection.status = Status.CONNECTING
-            launch(Dispatchers.Main) {
-                updateDeviceUI(deviceConnection)
-            }
+            updateDeviceUI(deviceConnection)
 
             try {
                 Log.d(
@@ -200,9 +198,7 @@
 
                     deviceConnection.status = Status.CONNECTED
                     deviceConnection.services = services
-                    launch(Dispatchers.Main) {
-                        updateDeviceUI(deviceConnection)
-                    }
+                    updateDeviceUI(deviceConnection)
 
                     deviceConnection.onCharacteristicActionClick =
                         object : OnCharacteristicActionClick {
@@ -250,9 +246,7 @@
                 }
 
                 deviceConnection.status = Status.DISCONNECTED
-                launch(Dispatchers.Main) {
-                    updateDeviceUI(deviceConnection)
-                }
+                updateDeviceUI(deviceConnection)
             }
         }
     }
@@ -269,9 +263,7 @@
             Log.d(TAG, "readCharacteristic() result: result = $result")
 
             deviceConnection.storeValueFor(characteristic, result.getOrNull())
-            launch(Dispatchers.Main) {
-                updateDeviceUI(deviceConnection)
-            }
+            updateDeviceUI(deviceConnection)
         }
     }
 
@@ -299,9 +291,7 @@
                     val result = gattClientScope.writeCharacteristic(characteristic, value)
                     Log.d(TAG, "writeCharacteristic() result: result = $result")
 
-                    launch(Dispatchers.Main) {
-                        toast("Called write with: $editTextValueString, result = $result").show()
-                    }
+                    toast("Called write with: $editTextValueString, result = $result").show()
                 }
             }
             .setNegativeButton(getString(R.string.cancel), null)
@@ -339,6 +329,8 @@
 
     @SuppressLint("NotifyDataSetChanged")
     private fun updateDeviceUI(deviceConnection: DeviceConnection) {
+        if (_binding == null) return
+
         binding.progressIndicatorDeviceConnection.isVisible = false
         binding.buttonReconnect.isVisible = false
         binding.buttonDisconnect.isVisible = false
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/gatt_server/GattServerFragment.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/gatt_server/GattServerFragment.kt
index e778e25..067ee68 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/gatt_server/GattServerFragment.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/gatt_server/GattServerFragment.kt
@@ -116,8 +116,8 @@
 
     override fun onDestroyView() {
         super.onDestroyView()
-        _binding = null
         isGattServerOpen = false
+        _binding = null
     }
 
     private fun onAddGattService() {
@@ -234,7 +234,6 @@
                 TAG, "bluetoothLe.openGattServer() called with: " +
                     "viewModel.gattServerServices = ${viewModel.gattServerServices}"
             )
-
             isGattServerOpen = true
 
             bluetoothLe.openGattServer(viewModel.gattServerServices) {
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/main/MainActivity.kt
similarity index 93%
rename from bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt
rename to bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/main/MainActivity.kt
index e2b26b8..d7c46ac 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/main/MainActivity.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package androidx.bluetooth.integration.testapp
+package androidx.bluetooth.integration.testapp.ui.main
 
 import android.Manifest
 import android.bluetooth.BluetoothAdapter
@@ -28,7 +28,9 @@
 import android.os.Bundle
 import android.util.Log
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
 import androidx.appcompat.app.AppCompatActivity
+import androidx.bluetooth.integration.testapp.R
 import androidx.bluetooth.integration.testapp.databinding.ActivityMainBinding
 import androidx.core.content.ContextCompat
 import androidx.core.view.isVisible
@@ -67,6 +69,8 @@
             binding.layoutBluetoothDisabled.isVisible = value.not()
         }
 
+    private val viewModel by viewModels<MainViewModel>()
+
     private lateinit var binding: ActivityMainBinding
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -101,6 +105,10 @@
                 startActivity(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
             }
         }
+
+        viewModel.navigateToConnections.observe(this) {
+            binding.bottomNavigationView.selectedItemId = R.id.navigation_connections
+        }
     }
 
     override fun onStart() {
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainViewModel.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/main/MainViewModel.kt
similarity index 68%
rename from bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainViewModel.kt
rename to bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/main/MainViewModel.kt
index cf09607..f72c02a 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainViewModel.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/main/MainViewModel.kt
@@ -14,10 +14,13 @@
  * limitations under the License.
  */
 
-package androidx.bluetooth.integration.testapp
+package androidx.bluetooth.integration.testapp.ui.main
 
 import androidx.bluetooth.BluetoothDevice
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
+import kotlin.random.Random
 
 class MainViewModel : ViewModel() {
 
@@ -26,4 +29,12 @@
     }
 
     var selectedBluetoothDevice: BluetoothDevice? = null
+
+    val navigateToConnections: LiveData<Int>
+        get() = _navigateToConnections
+    private val _navigateToConnections = MutableLiveData<Int>()
+
+    fun navigateToConnections() {
+        _navigateToConnections.value = Random.nextInt()
+    }
 }
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/scanner/ScannerFragment.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/scanner/ScannerFragment.kt
index ecbe691..35b1e8c 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/scanner/ScannerFragment.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/scanner/ScannerFragment.kt
@@ -24,14 +24,13 @@
 import android.view.ViewGroup
 import androidx.bluetooth.BluetoothDevice
 import androidx.bluetooth.BluetoothLe
-import androidx.bluetooth.integration.testapp.MainViewModel
 import androidx.bluetooth.integration.testapp.R
 import androidx.bluetooth.integration.testapp.databinding.FragmentScannerBinding
 import androidx.bluetooth.integration.testapp.ui.common.getColor
+import androidx.bluetooth.integration.testapp.ui.main.MainViewModel
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.activityViewModels
 import androidx.fragment.app.viewModels
-import androidx.navigation.fragment.findNavController
 import androidx.recyclerview.widget.DividerItemDecoration
 import androidx.recyclerview.widget.LinearLayoutManager
 import dagger.hilt.android.AndroidEntryPoint
@@ -108,8 +107,8 @@
 
     override fun onDestroyView() {
         super.onDestroyView()
-        _binding = null
         isScanning = false
+        _binding = null
     }
 
     @SuppressLint("MissingPermission")
@@ -118,7 +117,6 @@
 
         scanJob = scanScope.launch {
             Log.d(TAG, "bluetoothLe.scan() called")
-
             isScanning = true
 
             try {
@@ -144,6 +142,6 @@
         isScanning = false
 
         mainViewModel.selectedBluetoothDevice = bluetoothDevice
-        findNavController().navigate(R.id.action_navigation_scanner_to_navigation_connections)
+        mainViewModel.navigateToConnections()
     }
 }
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml
index 00cafae..46b7f42 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml
@@ -21,7 +21,7 @@
     android:id="@+id/container"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context=".MainActivity">
+    tools:context=".ui.main.MainActivity">
 
     <LinearLayout
         android:id="@+id/layout_bluetooth_disabled"
@@ -62,11 +62,11 @@
         android:name="androidx.navigation.fragment.NavHostFragment"
         android:layout_width="0dp"
         android:layout_height="0dp"
+        app:defaultNavHost="true"
+        app:layout_constraintBottom_toTopOf="@+id/bottom_navigation_view"
         app:layout_constraintLeft_toLeftOf="parent"
         app:layout_constraintRight_toRightOf="parent"
         app:layout_constraintTop_toBottomOf="@+id/layout_bluetooth_disabled"
-        app:layout_constraintBottom_toTopOf="@+id/bottom_navigation_view"
-        app:defaultNavHost="true"
         app:navGraph="@navigation/nav_graph" />
 
     <com.google.android.material.bottomnavigation.BottomNavigationView
diff --git a/bluetooth/integration-tests/testapp/src/main/res/navigation/nav_graph.xml b/bluetooth/integration-tests/testapp/src/main/res/navigation/nav_graph.xml
index 057f265..30cfd4e 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/navigation/nav_graph.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/navigation/nav_graph.xml
@@ -24,20 +24,13 @@
         android:id="@+id/navigation_scanner"
         android:name="androidx.bluetooth.integration.testapp.ui.scanner.ScannerFragment"
         android:label="@string/title_scanner"
-        tools:layout="@layout/fragment_scanner" >
-        <action
-            android:id="@+id/action_navigation_scanner_to_navigation_connections"
-            app:destination="@id/navigation_connections"
-            app:popUpTo="@id/navigation_scanner"
-            app:popUpToInclusive="true" />
-    </fragment>
+        tools:layout="@layout/fragment_scanner" />
 
     <fragment
         android:id="@+id/navigation_connections"
         android:name="androidx.bluetooth.integration.testapp.ui.connections.ConnectionsFragment"
         android:label="@string/title_connections"
-        tools:layout="@layout/fragment_connections">
-    </fragment>
+        tools:layout="@layout/fragment_connections" />
 
     <fragment
         android:id="@+id/navigation_advertiser"
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/ApiCompat.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/ApiCompat.kt
index 0b02cc5..14bd45f 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/ApiCompat.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/ApiCompat.kt
@@ -33,6 +33,7 @@
 import android.hardware.camera2.params.OutputConfiguration
 import android.hardware.camera2.params.SessionConfiguration
 import android.media.ImageReader
+import android.media.ImageWriter
 import android.os.Build
 import android.os.Handler
 import android.util.Size
@@ -314,6 +315,16 @@
     ): ImageReader {
         return ImageReader.newInstance(width, height, format, capacity, usage)
     }
+
+    @JvmStatic
+    @DoNotInline
+    fun imageWriterNewInstance(
+        surface: Surface,
+        maxImages: Int,
+        format: Int
+    ): ImageWriter {
+        return ImageWriter.newInstance(surface, maxImages, format)
+    }
 }
 
 @RequiresApi(Build.VERSION_CODES.R)
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/AndroidImageReaders.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/AndroidImageReaders.kt
index f7bfb6f..8da15df 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/AndroidImageReaders.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/AndroidImageReaders.kt
@@ -102,7 +102,7 @@
         // One of the worst cases observed is the HAL reserving 10 images, which gives a maximum
         // capacity of 54 (64 - 10). For safety and compatibility reasons, set the maximum capacity
         // to be 54, which leaves headroom for an app configured limit of 50.
-        internal const val IMAGERREADER_MAX_CAPACITY = 54
+        internal const val IMAGEREADER_MAX_CAPACITY = 54
 
         /**
          * Create and configure a new ImageReader instance as an [ImageReaderWrapper].
@@ -121,9 +121,9 @@
             require(width > 0) { "Width ($width) must be > 0" }
             require(height > 0) { "Height ($height) must be > 0" }
             require(capacity > 0) { "Capacity ($capacity) must be > 0" }
-            require(capacity <= IMAGERREADER_MAX_CAPACITY) {
+            require(capacity <= IMAGEREADER_MAX_CAPACITY) {
                 "Capacity for creating new ImageSources is restricted to " +
-                    "$IMAGERREADER_MAX_CAPACITY. Android has undocumented internal limits that " +
+                    "$IMAGEREADER_MAX_CAPACITY. Android has undocumented internal limits that " +
                     "are different depending on which device the ImageReader is created on."
             }
 
@@ -231,9 +231,9 @@
             executor: Executor
         ): ImageReaderWrapper {
             require(capacity > 0) { "Capacity ($capacity) must be > 0" }
-            require(capacity <= AndroidImageReader.IMAGERREADER_MAX_CAPACITY) {
+            require(capacity <= AndroidImageReader.IMAGEREADER_MAX_CAPACITY) {
                 "Capacity for creating new ImageSources is restricted to " +
-                    "${AndroidImageReader.IMAGERREADER_MAX_CAPACITY}. Android has undocumented " +
+                    "${AndroidImageReader.IMAGEREADER_MAX_CAPACITY}. Android has undocumented " +
                     "internal limits that are different depending on which device the " +
                     "MultiResolutionImageReader is created on."
             }
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/AndroidImageWriter.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/AndroidImageWriter.kt
new file mode 100644
index 0000000..28ec921
--- /dev/null
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/AndroidImageWriter.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.media
+
+import android.media.Image
+import android.media.ImageWriter
+import android.os.Build
+import android.os.Handler
+import android.view.Surface
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.InputId
+import androidx.camera.camera2.pipe.StreamFormat
+import androidx.camera.camera2.pipe.compat.Api29Compat
+import androidx.camera.camera2.pipe.core.Log
+import androidx.camera.camera2.pipe.media.AndroidImageReader.Companion.IMAGEREADER_MAX_CAPACITY
+import kotlin.reflect.KClass
+import kotlinx.atomicfu.atomic
+
+/**
+ * Implements an [ImageWriterWrapper] using an [ImageWriter].
+ */
+@RequiresApi(Build.VERSION_CODES.M)
+class AndroidImageWriter private constructor(
+    private val imageWriter: ImageWriter,
+    private val inputId: InputId
+) : ImageWriterWrapper, ImageWriter.OnImageReleasedListener {
+    private val onImageReleasedListener = atomic<ImageWriterWrapper.OnImageReleasedListener?>(null)
+    override val maxImages: Int = imageWriter.maxImages
+
+    override val format: Int = imageWriter.format
+
+    override fun queueInputImage(image: ImageWrapper) {
+        imageWriter.queueInputImage(image.unwrapAs(Image::class))
+    }
+
+    override fun dequeueInputImage(): ImageWrapper {
+        val image = imageWriter.dequeueInputImage()
+        return AndroidImage(image)
+    }
+
+    override fun setOnImageReleasedListener(
+        onImageReleasedListener: ImageWriterWrapper.OnImageReleasedListener
+    ) {
+        this.onImageReleasedListener.value = onImageReleasedListener
+    }
+
+    override fun onImageReleased(writer: ImageWriter?) {
+        onImageReleasedListener.value?.onImageReleased(inputId)
+    }
+
+    override fun close() = imageWriter.close()
+
+    @Suppress("UNCHECKED_CAST")
+    override fun <T : Any> unwrapAs(type: KClass<T>): T? = when (type) {
+        ImageWriter::class -> imageWriter as T?
+        else -> null
+    }
+
+    override fun toString(): String {
+        return "ImageWriter-${StreamFormat(imageWriter.format).name}-" +
+            "inputId$inputId"
+    }
+
+    companion object {
+        /**
+         * Create and configure a new ImageWriter instance as an [ImageWriter].
+         *
+         * See [ImageWriter.newInstance] for details.
+         */
+        fun create(
+            surface: Surface,
+            maxImages: Int,
+            format: Int?,
+            inputId: InputId,
+            handler: Handler
+        ): ImageWriterWrapper {
+            require(maxImages > 0) { "Max images ($maxImages) must be > 0" }
+            require(maxImages <= IMAGEREADER_MAX_CAPACITY) {
+                "Max images for ImageWriters is restricted to " +
+                    "$IMAGEREADER_MAX_CAPACITY to prevent overloading downstream " +
+                    "consumer components."
+            }
+
+            // Create and configure a new ImageWriter
+            val imageWriter =
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && format != null) {
+                    Api29Compat.imageWriterNewInstance(surface, maxImages, format)
+                } else {
+                    if (format != null) {
+                        Log.warn {
+                            "Ignoring format ($format) for $inputId. Android " +
+                                "${Build.VERSION.SDK_INT} does not support creating ImageWriters " +
+                                "with formats. This may lead to unexpected behaviors."
+                        }
+                    }
+                    ImageWriter.newInstance(surface, maxImages)
+                }
+
+            val androidImageWriter = AndroidImageWriter(imageWriter, inputId)
+            imageWriter.setOnImageReleasedListener(
+                androidImageWriter, handler
+            )
+            return androidImageWriter
+        }
+    }
+}
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/ImageSource.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/ImageSource.kt
index 89a7b4e..2efd693 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/ImageSource.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/ImageSource.kt
@@ -26,7 +26,7 @@
 import androidx.camera.camera2.pipe.OutputId
 import androidx.camera.camera2.pipe.UnsafeWrapper
 import androidx.camera.camera2.pipe.core.Log
-import androidx.camera.camera2.pipe.media.AndroidImageReader.Companion.IMAGERREADER_MAX_CAPACITY
+import androidx.camera.camera2.pipe.media.AndroidImageReader.Companion.IMAGEREADER_MAX_CAPACITY
 import java.util.concurrent.Executor
 import kotlin.reflect.KClass
 import kotlinx.atomicfu.atomic
@@ -67,7 +67,7 @@
 
     companion object {
         private const val IMAGE_CAPACITY_MARGIN = 2
-        private const val IMAGE_SOURCE_CAPACITY = IMAGERREADER_MAX_CAPACITY - IMAGE_CAPACITY_MARGIN
+        private const val IMAGE_SOURCE_CAPACITY = IMAGEREADER_MAX_CAPACITY - IMAGE_CAPACITY_MARGIN
 
         fun create(
             imageReader: ImageReaderWrapper
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/ImageWriterWrapper.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/ImageWriterWrapper.kt
new file mode 100644
index 0000000..57b4d9ee
--- /dev/null
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/media/ImageWriterWrapper.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.media
+
+import android.media.ImageWriter
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.pipe.InputId
+import androidx.camera.camera2.pipe.UnsafeWrapper
+
+/**
+ * Simplified wrapper for [ImageWriter]-like classes.
+ */
+
+@RequiresApi(Build.VERSION_CODES.M)
+interface ImageWriterWrapper : UnsafeWrapper, AutoCloseable {
+
+    /**
+     * Get the ImageWriter format.
+     * @see [ImageWriter.getFormat]
+     */
+    val format: Int
+
+    /**
+     * Get the maximum number of images that can be dequeued from the ImageWriter simultaneously.
+     * @see [ImageWriter.getMaxImages]
+     */
+    val maxImages: Int
+
+    /**
+     * Queue an input Image back to ImageWriter for the downstream consumer to access.
+     * @see [ImageWriter.queueInputImage]
+     */
+    fun queueInputImage(image: ImageWrapper)
+
+    /**
+     * Dequeue the next available input Image for the application to produce data into.
+     * @see [ImageWriter.dequeueInputImage]
+     */
+    fun dequeueInputImage(): ImageWrapper
+
+    /**
+     * Set the [OnImageReleasedListener]. Setting additional listeners will override the previous listener.]
+     */
+    fun setOnImageReleasedListener(onImageReleasedListener: OnImageReleasedListener)
+
+    /**
+     * The OnImageListener adapts the standard [ImageWriter.OnImageReleasedListener] to retrieve
+     * images returned to the ImageWriter.
+     */
+    fun interface OnImageReleasedListener {
+        /**
+         * Handle the [ImageWrapper] that has been released back to [ImageWriterWrapper].
+         */
+        fun onImageReleased(inputId: InputId)
+    }
+
+    interface Builder {
+        fun build(): ImageWriterWrapper
+    }
+}
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
index 6537491..84c5c81 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
@@ -661,11 +661,14 @@
         mZoomControl.addZoomOption(builder);
 
         int aeMode = CaptureRequest.CONTROL_AE_MODE_ON;
+
+        if (mFocusMeteringControl.isExternalFlashAeModeEnabled()) {
+            aeMode = CaptureRequest.CONTROL_AE_MODE_ON_EXTERNAL_FLASH;
+        }
+
         if (mIsTorchOn) {
             builder.setCaptureRequestOptionWithPriority(CaptureRequest.FLASH_MODE,
                     CaptureRequest.FLASH_MODE_TORCH, Config.OptionPriority.REQUIRED);
-        } else if (mFocusMeteringControl.isExternalFlashAeModeEnabled()) {
-            aeMode = CaptureRequest.CONTROL_AE_MODE_ON_EXTERNAL_FLASH;
         } else {
             switch (mFlashMode) {
                 case FLASH_MODE_OFF:
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java
index 247473c..2125a4b 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java
@@ -733,6 +733,17 @@
                             uiAppliedFuture),
                     mExecutor
             ).transformAsync(
+                    // Won't have any effect if CONTROL_AE_MODE_ON_EXTERNAL_FLASH is supported
+                    input -> CallbackToFutureAdapter.getFuture(
+                            completer -> {
+                                Logger.d(TAG, "ScreenFlashTask#preCapture: enable torch");
+                                // TODO: Enable torch only if actual flash unit doesn't exist
+                                mCameraControl.enableTorchInternal(true);
+                                completer.set(null);
+                                return "EnableTorchInternal";
+                            }),
+                    mExecutor
+            ).transformAsync(
                     input -> mCameraControl.getFocusMeteringControl().triggerAePrecapture(),
                     mExecutor
             ).transformAsync(
@@ -751,6 +762,7 @@
         @Override
         public void postCapture() {
             Logger.d(TAG, "ScreenFlashTask#postCapture");
+            mCameraControl.enableTorchInternal(false);
             mCameraControl.getFocusMeteringControl().enableExternalFlashAeMode(false).addListener(
                     () -> Log.d(TAG, "enableExternalFlashAeMode disabled"), mExecutor
             );