Merge "Add Camera2Extensions auto validation tests" into androidx-main
diff --git a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/camera2extensions/Camera2ExtensionsActivityTest.kt b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/camera2extensions/Camera2ExtensionsActivityTest.kt
new file mode 100644
index 0000000..ad26829
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/camera2extensions/Camera2ExtensionsActivityTest.kt
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2022 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.integration.extensions.camera2extensions
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import androidx.camera.camera2.Camera2Config
+import androidx.camera.integration.extensions.Camera2ExtensionsActivity
+import androidx.camera.integration.extensions.INTENT_EXTRA_CAMERA_ID
+import androidx.camera.integration.extensions.INTENT_EXTRA_EXTENSION_MODE
+import androidx.camera.integration.extensions.util.Camera2ExtensionsTestUtil
+import androidx.camera.integration.extensions.util.Camera2ExtensionsTestUtil.isCamera2ExtensionModeSupported
+import androidx.camera.integration.extensions.util.EXTENSIONS_TEST_APP_PACKAGE
+import androidx.camera.integration.extensions.util.waitForCaptureSessionConfiguredIdle
+import androidx.camera.integration.extensions.util.waitForImageSavedIdle
+import androidx.camera.integration.extensions.util.waitForPreviewIdle
+import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.CoreAppTestUtil
+import androidx.camera.testing.LabTestRule
+import androidx.camera.testing.StressTestRule
+import androidx.lifecycle.Lifecycle
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.GrantPermissionRule
+import androidx.test.uiautomator.UiDevice
+import org.junit.After
+import org.junit.Assume
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.ClassRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+/**
+ * Auto validation tests for Camera2 Extensions with [Camera2ExtensionsActivity]
+ */
+@LargeTest
+@RunWith(Parameterized::class)
+@SdkSuppress(minSdkVersion = 31)
+class Camera2ExtensionsActivityTest(
+ private val cameraId: String,
+ private val extensionMode: Int
+) {
+ private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ @get:Rule
+ val useCamera = CameraUtil.grantCameraPermissionAndPreTest(
+ CameraUtil.PreTestCameraIdList(Camera2Config.defaultConfig())
+ )
+
+ @get:Rule
+ val permissionRule: GrantPermissionRule =
+ GrantPermissionRule.grant(
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ )
+
+ @get:Rule
+ val labTest: LabTestRule = LabTestRule()
+
+ @Before
+ fun setup() {
+ Assume.assumeTrue(CameraUtil.deviceHasCamera())
+ CoreAppTestUtil.assumeCompatibleDevice()
+ // Clears the device UI and check if there is no dialog or lock screen on the top of the
+ // window before start the test.
+ CoreAppTestUtil.prepareDeviceUI(InstrumentationRegistry.getInstrumentation())
+ // Uses the natural orientation throughout these tests to ensure the activity isn't
+ // recreated unexpectedly. This will also freeze the sensors until
+ // mDevice.unfreezeRotation() in the tearDown() method. Any simulated rotations will be
+ // explicitly initiated from within the test.
+ device.setOrientationNatural()
+ }
+
+ @After
+ fun tearDown() {
+ // Unfreezes rotation so the device can choose the orientation via its own policy. Be nice
+ // to other tests :)
+ device.unfreezeRotation()
+ device.pressHome()
+ }
+
+ companion object {
+ @ClassRule
+ @JvmField val stressTest = StressTestRule()
+
+ @Parameterized.Parameters(name = "cameraId = {0}, extensionMode = {1}")
+ @JvmStatic
+ fun parameters() = Camera2ExtensionsTestUtil.getAllCameraIdExtensionModeCombinations()
+ }
+
+ @LabTestRule.LabTestOnly
+ @Test
+ fun checkPreviewUpdated() {
+ val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+ cameraId,
+ extensionMode
+ )
+ with(activityScenario) { // Launches activity
+ use { // Ensures that ActivityScenario is cleaned up properly
+ // Waits for preview to receive enough frames for its IdlingResource to idle.
+ waitForPreviewIdle()
+ }
+ }
+ }
+
+ @LabTestRule.LabTestOnly
+ @Test
+ fun canCaptureSingleImage() {
+ val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+ cameraId,
+ extensionMode
+ )
+ with(activityScenario) { // Launches activity
+ use { // Ensures that ActivityScenario is cleaned up properly
+ // Triggers the capture function and waits for the image being saved
+ waitForImageSavedIdle()
+ }
+ }
+ }
+
+ @LabTestRule.LabTestOnly
+ @Test
+ fun checkPreviewUpdated_afterPauseResume() {
+ val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+ cameraId,
+ extensionMode
+ )
+ with(activityScenario) { // Launches activity
+ use { // Ensures that ActivityScenario is cleaned up properly
+ // Waits for preview to receive enough frames for its IdlingResource to idle.
+ waitForPreviewIdle()
+
+ // Pauses and resumes the activity
+ moveToState(Lifecycle.State.CREATED)
+ moveToState(Lifecycle.State.RESUMED)
+
+ // Waits for preview to receive enough frames again
+ waitForPreviewIdle()
+ }
+ }
+ }
+
+ @LabTestRule.LabTestOnly
+ @Test
+ fun canCaptureImage_afterPauseResume() {
+ val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+ cameraId,
+ extensionMode
+ )
+ with(activityScenario) { // Launches activity
+ use { // Ensures that ActivityScenario is cleaned up properly
+ // Triggers the capture function and waits for the image being saved
+ waitForImageSavedIdle()
+
+ // Pauses and resumes the activity
+ moveToState(Lifecycle.State.CREATED)
+ moveToState(Lifecycle.State.RESUMED)
+
+ // Waits for the capture session configured again after resuming the activity
+ waitForCaptureSessionConfiguredIdle()
+
+ // Triggers the capture function and waits for the image being saved again
+ waitForImageSavedIdle()
+ }
+ }
+ }
+
+ @LabTestRule.LabTestOnly
+ @Test
+ fun canCaptureMultipleImages() {
+ val activityScenario = launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+ cameraId,
+ extensionMode
+ )
+ with(activityScenario) { // Launches activity
+ use { // Ensures that ActivityScenario is cleaned up properly
+ repeat(5) {
+ // Triggers the capture function and waits for the image being saved
+ waitForImageSavedIdle()
+ }
+ }
+ }
+ }
+
+ private fun launchCamera2ExtensionsActivityAndWaitForCaptureSessionConfigured(
+ cameraId: String,
+ extensionMode: Int
+ ): ActivityScenario<Camera2ExtensionsActivity> {
+ val context = ApplicationProvider.getApplicationContext<Context>()
+ assumeTrue(isCamera2ExtensionModeSupported(context, cameraId, extensionMode))
+ val intent = context.packageManager
+ .getLaunchIntentForPackage(EXTENSIONS_TEST_APP_PACKAGE)!!.apply {
+ putExtra(INTENT_EXTRA_CAMERA_ID, cameraId)
+ putExtra(INTENT_EXTRA_EXTENSION_MODE, extensionMode)
+ setClassName(context, Camera2ExtensionsActivity::class.java.name)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+
+ val activityScenario: ActivityScenario<Camera2ExtensionsActivity> =
+ ActivityScenario.launch(intent)
+
+ // Waits for the capture session being configured
+ activityScenario.waitForCaptureSessionConfiguredIdle()
+
+ return activityScenario
+ }
+}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsActivityTestExtensions.kt b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsActivityTestExtensions.kt
new file mode 100644
index 0000000..f7a24cd
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsActivityTestExtensions.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2022 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.integration.extensions.util
+
+import androidx.annotation.RequiresApi
+import androidx.camera.integration.extensions.Camera2ExtensionsActivity
+import androidx.camera.integration.extensions.R
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.IdlingRegistry
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.assertion.ViewAssertions
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.testutils.withActivity
+
+const val EXTENSIONS_TEST_APP_PACKAGE = "androidx.camera.integration.extensions"
+
+/**
+ * Waits until the capture session has been configured and its idling resource has become idle.
+ */
+@RequiresApi(31)
+internal fun ActivityScenario<Camera2ExtensionsActivity>.waitForCaptureSessionConfiguredIdle() {
+ val idlingResource = withActivity {
+ getCaptureSessionConfiguredIdlingResource()
+ }
+ try {
+ IdlingRegistry.getInstance().register(idlingResource)
+ // Waits for the CaptureSessionConfiguredIdlingResource becoming idle
+ Espresso.onIdle()
+ } finally { // Always releases the idling resource, in case of timeout exceptions.
+ IdlingRegistry.getInstance().unregister(idlingResource)
+ }
+}
+
+/**
+ * Waits until the preview has received frames and its idling resource has become idle.
+ */
+@RequiresApi(31)
+internal fun ActivityScenario<Camera2ExtensionsActivity>.waitForPreviewIdle() {
+ val idlingResource = withActivity {
+ resetPreviewIdlingResource()
+ getPreviewIdlingResource()
+ }
+ try {
+ IdlingRegistry.getInstance().register(idlingResource)
+ // Waits for the PreviewIdlingResource becoming idle
+ Espresso.onView(ViewMatchers.withId(R.id.viewFinder))
+ .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
+ } finally { // Always releases the idling resource, in case of timeout exceptions.
+ IdlingRegistry.getInstance().unregister(idlingResource)
+ }
+}
+
+/**
+ * Waits until captured image has been saved and its idling resource has become idle.
+ */
+@RequiresApi(31)
+internal fun ActivityScenario<Camera2ExtensionsActivity>.waitForImageSavedIdle() {
+ val idlingResource = withActivity {
+ getImageSavedIdlingResource()
+ }
+ try {
+ IdlingRegistry.getInstance().register(idlingResource)
+ // Performs click action and waits for the ImageSavedIdlingResource becoming idle
+ Espresso.onView(ViewMatchers.withId(R.id.Picture)).perform(ViewActions.click())
+ } finally { // Always releases the idling resource, in case of timeout exceptions.
+ IdlingRegistry.getInstance().unregister(idlingResource)
+ withActivity { deleteSessionImages() }
+ }
+}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsTestUtil.kt b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsTestUtil.kt
new file mode 100644
index 0000000..d2195a8
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/util/Camera2ExtensionsTestUtil.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 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.integration.extensions.util
+
+import android.content.Context
+import android.hardware.camera2.CameraExtensionCharacteristics
+import android.hardware.camera2.CameraManager
+import androidx.annotation.RequiresApi
+import androidx.camera.testing.CameraUtil
+
+@RequiresApi(31)
+object Camera2ExtensionsTestUtil {
+
+ /**
+ * Gets a list of all camera id and extension mode combinations.
+ */
+ @JvmStatic
+ fun getAllCameraIdExtensionModeCombinations(): List<Array<Any>> =
+ arrayListOf<Array<Any>>().apply {
+ CameraUtil.getBackwardCompatibleCameraIdListOrThrow().forEach { cameraId ->
+ AVAILABLE_EXTENSION_MODES.forEach { mode ->
+ add(arrayOf(cameraId, mode))
+ }
+ }
+ }
+
+ @JvmStatic
+ fun isCamera2ExtensionModeSupported(
+ context: Context,
+ cameraId: String,
+ extensionMode: Int
+ ): Boolean {
+ val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
+ val extensionCharacteristics = cameraManager.getCameraExtensionCharacteristics(cameraId)
+ return extensionCharacteristics.supportedExtensions.contains(extensionMode)
+ }
+
+ /**
+ * Camera2 extension modes
+ */
+ @Suppress("DEPRECATION") // EXTENSION_BEAUTY
+ @JvmStatic
+ val AVAILABLE_EXTENSION_MODES = arrayOf(
+ CameraExtensionCharacteristics.EXTENSION_AUTOMATIC,
+ CameraExtensionCharacteristics.EXTENSION_BEAUTY,
+ CameraExtensionCharacteristics.EXTENSION_BOKEH,
+ CameraExtensionCharacteristics.EXTENSION_HDR,
+ CameraExtensionCharacteristics.EXTENSION_NIGHT,
+ )
+}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/Camera2ExtensionsActivity.kt b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/Camera2ExtensionsActivity.kt
index d682e82..c284eb7 100644
--- a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/Camera2ExtensionsActivity.kt
+++ b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/Camera2ExtensionsActivity.kt
@@ -17,6 +17,7 @@
package androidx.camera.integration.extensions
import android.annotation.SuppressLint
+import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.SurfaceTexture
@@ -30,40 +31,46 @@
import android.hardware.camera2.params.ExtensionSessionConfiguration
import android.hardware.camera2.params.OutputConfiguration
import android.media.ImageReader
+import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
+import android.util.Size
import android.view.Menu
import android.view.MenuItem
import android.view.Surface
import android.view.TextureView
+import android.view.View
import android.view.ViewStub
import android.widget.Button
+import android.widget.FrameLayout
import android.widget.Toast
import androidx.annotation.RequiresApi
+import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.impl.utils.futures.Futures
-import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.calculateRelativeImageRotationDegrees
-import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.createExtensionCaptureCallback
-import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.getDisplayRotationDegrees
import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.getExtensionModeStringFromId
import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.getLensFacingCameraId
import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.pickPreviewResolution
import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.pickStillImageResolution
-import androidx.camera.integration.extensions.utils.Camera2ExtensionsUtil.transformPreview
import androidx.camera.integration.extensions.utils.FileUtil
+import androidx.camera.integration.extensions.utils.TransformUtil.calculateRelativeImageRotationDegrees
+import androidx.camera.integration.extensions.utils.TransformUtil.surfaceRotationToRotationDegrees
+import androidx.camera.integration.extensions.utils.TransformUtil.transformTextureView
import androidx.camera.integration.extensions.validation.CameraValidationResultActivity
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.concurrent.futures.CallbackToFutureAdapter.Completer
import androidx.core.util.Preconditions
import androidx.lifecycle.lifecycleScope
+import androidx.test.espresso.idling.CountingIdlingResource
import com.google.common.util.concurrent.ListenableFuture
import java.text.Format
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.Deferred
@@ -77,6 +84,15 @@
private const val TAG = "Camera2ExtensionsAct~"
private const val EXTENSION_MODE_INVALID = -1
+private const val FRAMES_UNTIL_VIEW_IS_READY = 10
+
+// Launch the activity with the specified camera id.
+@VisibleForTesting
+const val INTENT_EXTRA_CAMERA_ID = "camera_id"
+
+// Launch the activity with the specified extension mode.
+@VisibleForTesting
+const val INTENT_EXTRA_EXTENSION_MODE = "extension_mode"
@RequiresApi(31)
class Camera2ExtensionsActivity : AppCompatActivity() {
@@ -127,6 +143,8 @@
private var currentExtensionIdx = -1
private val supportedExtensionModes = mutableListOf<Int>()
+ private lateinit var containerView: View
+
private lateinit var textureView: TextureView
private lateinit var previewSurface: Surface
@@ -139,7 +157,7 @@
height: Int
) {
previewSurface = Surface(surfaceTexture)
- openCameraWithExtensionMode(currentCameraId)
+ setupAndStartPreview(currentCameraId, currentExtensionMode)
}
override fun onSurfaceTextureSizeChanged(
@@ -154,10 +172,31 @@
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
+ if (captureProcessStartedIdlingResource.isIdleNow &&
+ receivedPreviewFrameCount.getAndIncrement() >= FRAMES_UNTIL_VIEW_IS_READY &&
+ !previewIdlingResource.isIdleNow
+ ) {
+ previewIdlingResource.decrement()
+ }
}
}
- private val captureCallbacks = createExtensionCaptureCallback()
+ private val captureCallbacks = object : CameraExtensionSession.ExtensionCaptureCallback() {
+ override fun onCaptureProcessStarted(
+ session: CameraExtensionSession,
+ request: CaptureRequest
+ ) {
+ if (receivedCaptureProcessStartedCount.getAndIncrement() >=
+ FRAMES_UNTIL_VIEW_IS_READY && !captureProcessStartedIdlingResource.isIdleNow
+ ) {
+ captureProcessStartedIdlingResource.decrement()
+ }
+ }
+
+ override fun onCaptureFailed(session: CameraExtensionSession, request: CaptureRequest) {
+ Log.e(TAG, "onCaptureFailed!!")
+ }
+ }
private var restartOnStart = false
@@ -167,6 +206,37 @@
private var imageSaveTerminationFuture: ListenableFuture<Any?> = Futures.immediateFuture(null)
+ /**
+ * Used to wait for the capture session is configured.
+ */
+ private val captureSessionConfiguredIdlingResource =
+ CountingIdlingResource("captureSessionConfigured").apply { increment() }
+ /**
+ * Used to wait for the ExtensionCaptureCallback#onCaptureProcessStarted is called which means
+ * an image is captured and extension processing is triggered.
+ */
+ private val captureProcessStartedIdlingResource =
+ CountingIdlingResource("captureProcessStarted").apply { increment() }
+
+ /**
+ * Used to wait for the preview is ready. This will become idle after
+ * captureProcessStartedIdlingResource becomes idle and
+ * [SurfaceTextureListener#onSurfaceTextureUpdated()] is also called. It means that there has
+ * been images captured to trigger the extension processing and the preview's SurfaceTexture is
+ * also updated by [SurfaceTexture#updateTexImage()] calls.
+ */
+ private val previewIdlingResource = CountingIdlingResource("preview").apply { increment() }
+
+ /**
+ * Used to trigger a picture taking action and waits for the image being saved.
+ */
+ private val imageSavedIdlingResource = CountingIdlingResource("imageSaved")
+
+ private val receivedCaptureProcessStartedCount: AtomicLong = AtomicLong(0)
+ private val receivedPreviewFrameCount: AtomicLong = AtomicLong(0)
+
+ private lateinit var sessionImageUriSet: SessionMediaUriSet
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate()")
@@ -191,8 +261,15 @@
return
}
- updateExtensionInfo()
+ sessionImageUriSet = SessionMediaUriSet(contentResolver)
+ // Gets params from extra bundle
+ intent.extras?.let { bundle ->
+ currentCameraId = bundle.getString(INTENT_EXTRA_CAMERA_ID, currentCameraId)
+ currentExtensionMode = bundle.getInt(INTENT_EXTRA_EXTENSION_MODE, currentExtensionMode)
+ }
+
+ updateExtensionInfo()
setupTextureView()
enableUiControl(false)
setupUiControl()
@@ -240,7 +317,8 @@
private fun setupTextureView() {
val viewFinderStub = findViewById<ViewStub>(R.id.viewFinderStub)
viewFinderStub.layoutResource = R.layout.full_textureview
- textureView = viewFinderStub.inflate() as TextureView
+ containerView = viewFinderStub.inflate()
+ textureView = containerView.findViewById(R.id.textureView)
textureView.surfaceTextureListener = surfaceTextureListener
}
@@ -260,7 +338,7 @@
restartPreview = true
extensionModeToggleButton.text = getExtensionModeStringFromId(currentExtensionMode)
- closeCaptureSession()
+ closeCaptureSessionAsync()
}
val cameraSwitchButton = findViewById<Button>(R.id.Switch)
@@ -280,12 +358,13 @@
currentCameraId = newCameraId
restartCamera = true
- closeCamera()
+ closeCameraAsync()
}
val captureButton = findViewById<Button>(R.id.Picture)
captureButton.setOnClickListener {
enableUiControl(false)
+ resetImageSavedIdlingResource()
takePicture()
}
}
@@ -296,7 +375,7 @@
activityStopped = false
if (restartOnStart) {
restartOnStart = false
- openCameraWithExtensionMode(currentCameraId)
+ setupAndStartPreview(currentCameraId, currentExtensionMode)
}
}
@@ -306,8 +385,8 @@
// Needs to close the camera first. Otherwise, the next activity might be failed to open
// the camera and configure the capture session.
runBlocking {
- closeCaptureSession().await()
- closeCamera().await()
+ closeCaptureSessionAsync().await()
+ closeCameraAsync().await()
}
restartOnStart = true
activityStopped = true
@@ -323,34 +402,101 @@
Log.d(TAG, "onDestroy()--")
}
- private fun closeCamera(): Deferred<Unit> = lifecycleScope.async(cameraTaskDispatcher) {
+ private fun closeCameraAsync(): Deferred<Unit> = lifecycleScope.async(cameraTaskDispatcher) {
Log.d(TAG, "closeCamera()++")
cameraDevice?.close()
cameraDevice = null
Log.d(TAG, "closeCamera()--")
}
- private fun closeCaptureSession(): Deferred<Unit> = lifecycleScope.async(cameraTaskDispatcher) {
- Log.d(TAG, "closeCaptureSession()++")
- try {
- cameraExtensionSession?.close()
- cameraExtensionSession = null
- } catch (e: Exception) {
- Log.e(TAG, e.toString())
+ private fun closeCaptureSessionAsync(): Deferred<Unit> =
+ lifecycleScope.async(cameraTaskDispatcher) {
+ Log.d(TAG, "closeCaptureSession()++")
+ resetCaptureSessionConfiguredIdlingResource()
+ try {
+ cameraExtensionSession?.close()
+ cameraExtensionSession = null
+ } catch (e: Exception) {
+ Log.e(TAG, e.toString())
+ }
+ Log.d(TAG, "closeCaptureSession()--")
}
- Log.d(TAG, "closeCaptureSession()--")
+
+ /**
+ * Sets up the UI layout settings for the specified camera and extension mode. And then,
+ * triggers to open the camera and capture session to start the preview with the extension mode
+ * enabled.
+ */
+ @Suppress("DEPRECATION") /* defaultDisplay */
+ private fun setupAndStartPreview(cameraId: String, extensionMode: Int) {
+ if (!textureView.isAvailable) {
+ Toast.makeText(
+ this, "TextureView is invalid!!",
+ Toast.LENGTH_SHORT
+ ).show()
+ finish()
+ return
+ }
+
+ val previewResolution = pickPreviewResolution(
+ cameraManager,
+ cameraId,
+ resources.displayMetrics,
+ extensionMode
+ )
+
+ if (previewResolution == null) {
+ Toast.makeText(
+ this,
+ "Invalid preview extension sizes!.",
+ Toast.LENGTH_SHORT
+ ).show()
+ finish()
+ return
+ }
+
+ Log.d(TAG, "Set default buffer size to previewResolution: $previewResolution")
+
+ textureView.surfaceTexture?.setDefaultBufferSize(
+ previewResolution.width,
+ previewResolution.height
+ )
+
+ textureView.layoutParams =
+ FrameLayout.LayoutParams(previewResolution.width, previewResolution.height)
+
+ val containerViewSize = Size(containerView.width, containerView.height)
+
+ val lensFacing =
+ cameraManager.getCameraCharacteristics(cameraId)[CameraCharacteristics.LENS_FACING]
+
+ transformTextureView(
+ textureView,
+ containerViewSize,
+ previewResolution,
+ windowManager.defaultDisplay.rotation,
+ cameraSensorRotationDegrees,
+ lensFacing == CameraCharacteristics.LENS_FACING_BACK
+ )
+
+ startPreview(cameraId, extensionMode)
}
- private fun openCameraWithExtensionMode(cameraId: String) =
+ /**
+ * Opens the camera and capture session to start the preview with the extension mode enabled.
+ */
+ private fun startPreview(cameraId: String, extensionMode: Int) =
lifecycleScope.launch(cameraTaskDispatcher) {
Log.d(TAG, "openCameraWithExtensionMode()++ cameraId: $cameraId")
- cameraDevice = openCamera(cameraManager, cameraId)
- cameraExtensionSession = openCaptureSession()
+ if (cameraDevice == null || cameraDevice!!.id != cameraId) {
+ cameraDevice = openCamera(cameraManager, cameraId)
+ }
+ cameraExtensionSession = openCaptureSession(extensionMode)
lifecycleScope.launch(Dispatchers.Main) {
if (activityStopped) {
- closeCaptureSession()
- closeCamera()
+ closeCaptureSessionAsync()
+ closeCameraAsync()
}
}
Log.d(TAG, "openCameraWithExtensionMode()--")
@@ -382,7 +528,7 @@
if (restartCamera) {
restartCamera = false
updateExtensionInfo()
- openCameraWithExtensionMode(currentCameraId)
+ setupAndStartPreview(currentCameraId, currentExtensionMode)
}
}
}
@@ -407,10 +553,9 @@
/**
* Opens and returns the extensions session (as the result of the suspend coroutine)
*/
- private suspend fun openCaptureSession(): CameraExtensionSession =
+ private suspend fun openCaptureSession(extensionMode: Int): CameraExtensionSession =
suspendCancellableCoroutine { cont ->
Log.d(TAG, "openCaptureSession")
- setupPreview()
if (stillImageReader != null) {
val imageReaderToClose = stillImageReader!!
@@ -426,7 +571,7 @@
outputConfig.add(OutputConfiguration(stillImageReader!!.surface))
outputConfig.add(OutputConfiguration(previewSurface))
val extensionConfiguration = ExtensionSessionConfiguration(
- currentExtensionMode, outputConfig,
+ extensionMode, outputConfig,
cameraTaskDispatcher.asExecutor(), object : CameraExtensionSession.StateCallback() {
override fun onClosed(session: CameraExtensionSession) {
Log.d(TAG, "CaptureSession - onClosed: $session")
@@ -435,8 +580,11 @@
if (restartPreview) {
restartPreview = false
+ val newExtensionMode = currentExtensionMode
+
lifecycleScope.launch(cameraTaskDispatcher) {
- cameraExtensionSession = openCaptureSession()
+ cameraExtensionSession =
+ openCaptureSession(newExtensionMode)
}
}
}
@@ -453,7 +601,12 @@
cameraTaskDispatcher.asExecutor(), captureCallbacks
)
cont.resume(session)
- runOnUiThread { enableUiControl(true) }
+ runOnUiThread {
+ enableUiControl(true)
+ if (!captureSessionConfiguredIdlingResource.isIdleNow) {
+ captureSessionConfiguredIdlingResource.decrement()
+ }
+ }
} catch (e: CameraAccessException) {
Log.e(TAG, e.toString())
cont.resumeWithException(
@@ -478,47 +631,14 @@
}
}
- @Suppress("DEPRECATION") /* defaultDisplay */
- private fun setupPreview() {
- if (!textureView.isAvailable) {
- Toast.makeText(
- this, "TextureView is invalid!!",
- Toast.LENGTH_SHORT
- ).show()
- finish()
- return
- }
-
- val previewResolution = pickPreviewResolution(
- cameraManager,
- currentCameraId,
- resources.displayMetrics,
- currentExtensionMode
- )
-
- if (previewResolution == null) {
- Toast.makeText(
- this,
- "Invalid preview extension sizes!.",
- Toast.LENGTH_SHORT
- ).show()
- finish()
- return
- }
-
- textureView.surfaceTexture?.setDefaultBufferSize(
- previewResolution.width,
- previewResolution.height
- )
- transformPreview(textureView, previewResolution, windowManager.defaultDisplay.rotation)
- }
-
private fun setupImageReader(): ImageReader {
val (size, format) = pickStillImageResolution(
extensionCharacteristics,
currentExtensionMode
)
+ Log.d(TAG, "Setup image reader - size: $size, format: $format")
+
return ImageReader.newInstance(size.width, size.height, format, 1)
}
@@ -542,9 +662,15 @@
stillImageReader!!.setOnImageAvailableListener(
{ reader: ImageReader ->
lifecycleScope.launch(cameraTaskDispatcher) {
- acquireImageAndSave(reader)
+ acquireImageAndSave(reader)?.let { sessionImageUriSet.add(it) }
+
stillImageReader!!.setOnImageAvailableListener(null, null)
takePictureCompleter?.set(null)
+
+ if (!imageSavedIdlingResource.isIdleNow) {
+ imageSavedIdlingResource.decrement()
+ }
+
lifecycleScope.launch(Dispatchers.Main) {
enableUiControl(true)
}
@@ -582,7 +708,8 @@
/**
* Acquires the latest image from the image reader and save it to the Pictures folder
*/
- private fun acquireImageAndSave(imageReader: ImageReader) {
+ private fun acquireImageAndSave(imageReader: ImageReader): Uri? {
+ var uri: Uri? = null
try {
val formatter: Format =
SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
@@ -592,13 +719,13 @@
}"
val rotationDegrees = calculateRelativeImageRotationDegrees(
- (getDisplayRotationDegrees(display!!.rotation)),
+ (surfaceRotationToRotationDegrees(display!!.rotation)),
cameraSensorRotationDegrees,
currentCameraId == backCameraId
)
imageReader.acquireLatestImage().let { image ->
- val uri = FileUtil.saveImage(
+ uri = FileUtil.saveImage(
image,
fileName,
".jpg",
@@ -622,6 +749,8 @@
} catch (e: Exception) {
Log.e(TAG, e.toString())
}
+
+ return uri
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -649,8 +778,8 @@
// Needs to close the camera first. Otherwise, the next activity might be failed to open
// the camera and configure the capture session.
runBlocking {
- closeCaptureSession().await()
- closeCamera().await()
+ closeCaptureSessionAsync().await()
+ closeCameraAsync().await()
}
val intent = Intent()
@@ -658,4 +787,66 @@
intent.setClassName(this, className)
startActivity(intent)
}
+
+ @VisibleForTesting
+ fun getCaptureSessionConfiguredIdlingResource(): CountingIdlingResource =
+ captureSessionConfiguredIdlingResource
+
+ @VisibleForTesting
+ fun getPreviewIdlingResource(): CountingIdlingResource = previewIdlingResource
+
+ @VisibleForTesting
+ fun getImageSavedIdlingResource(): CountingIdlingResource = imageSavedIdlingResource
+
+ private fun resetCaptureSessionConfiguredIdlingResource() {
+ if (captureSessionConfiguredIdlingResource.isIdleNow) {
+ captureSessionConfiguredIdlingResource.increment()
+ }
+ }
+
+ @VisibleForTesting
+ fun resetPreviewIdlingResource() {
+ receivedCaptureProcessStartedCount.set(0)
+ receivedPreviewFrameCount.set(0)
+
+ if (captureProcessStartedIdlingResource.isIdleNow) {
+ captureProcessStartedIdlingResource.increment()
+ }
+
+ if (previewIdlingResource.isIdleNow) {
+ previewIdlingResource.increment()
+ }
+ }
+
+ private fun resetImageSavedIdlingResource() {
+ if (imageSavedIdlingResource.isIdleNow) {
+ imageSavedIdlingResource.increment()
+ }
+ }
+
+ @VisibleForTesting
+ fun deleteSessionImages() {
+ sessionImageUriSet.deleteAllUris()
+ }
+
+ private class SessionMediaUriSet constructor(val contentResolver: ContentResolver) {
+ private val mSessionMediaUris: MutableSet<Uri> = mutableSetOf()
+
+ fun add(uri: Uri) {
+ synchronized(mSessionMediaUris) {
+ mSessionMediaUris.add(uri)
+ }
+ }
+
+ fun deleteAllUris() {
+ synchronized(mSessionMediaUris) {
+ val it =
+ mSessionMediaUris.iterator()
+ while (it.hasNext()) {
+ contentResolver.delete(it.next(), null, null)
+ it.remove()
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt
index 8bbd835..7d9f688 100644
--- a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt
+++ b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/Camera2ExtensionsUtil.kt
@@ -18,24 +18,16 @@
import android.annotation.SuppressLint
import android.graphics.ImageFormat
-import android.graphics.Matrix
import android.graphics.Point
import android.graphics.SurfaceTexture
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraExtensionCharacteristics
-import android.hardware.camera2.CameraExtensionSession
-import android.hardware.camera2.CameraExtensionSession.ExtensionCaptureCallback
import android.hardware.camera2.CameraManager
-import android.hardware.camera2.CaptureRequest
import android.os.Build
import android.util.DisplayMetrics
import android.util.Log
import android.util.Size
-import android.view.Surface
-import android.view.TextureView
import androidx.annotation.RequiresApi
-import java.math.BigDecimal
-import java.math.RoundingMode
import java.util.stream.Collectors
private const val TAG = "Camera2ExtensionsUtil"
@@ -83,49 +75,6 @@
}
/**
- * Creates a default extension capture callback implementation.
- */
- @RequiresApi(Build.VERSION_CODES.S)
- @JvmStatic
- fun createExtensionCaptureCallback(): ExtensionCaptureCallback {
- return object : ExtensionCaptureCallback() {
- override fun onCaptureStarted(
- session: CameraExtensionSession,
- request: CaptureRequest,
- timestamp: Long
- ) {
- }
-
- override fun onCaptureProcessStarted(
- session: CameraExtensionSession,
- request: CaptureRequest
- ) {
- }
-
- override fun onCaptureFailed(
- session: CameraExtensionSession,
- request: CaptureRequest
- ) {
- Log.v(TAG, "onCaptureProcessFailed")
- }
-
- override fun onCaptureSequenceCompleted(
- session: CameraExtensionSession,
- sequenceId: Int
- ) {
- Log.v(TAG, "onCaptureProcessSequenceCompleted: $sequenceId")
- }
-
- override fun onCaptureSequenceAborted(
- session: CameraExtensionSession,
- sequenceId: Int
- ) {
- Log.v(TAG, "onCaptureProcessSequenceAborted: $sequenceId")
- }
- }
- }
-
- /**
* Picks a preview resolution that is both close/same as the display size and supported by camera
* and extensions.
*/
@@ -213,135 +162,4 @@
return Pair(stillCaptureSize, stillFormat)
}
-
- /**
- * Transforms the texture view to display the content of resolution in correct direction and
- * aspect ratio.
- */
- @JvmStatic
- fun transformPreview(textureView: TextureView, resolution: Size, displayRotation: Int) {
- if (resolution.width == 0 || resolution.height == 0) {
- return
- }
- if (textureView.width == 0 || textureView.height == 0) {
- return
- }
- val matrix = Matrix()
- val left: Int = textureView.left
- val right: Int = textureView.right
- val top: Int = textureView.top
- val bottom: Int = textureView.bottom
-
- // Compute the preview ui size based on the available width, height, and ui orientation.
- val viewWidth = right - left
- val viewHeight = bottom - top
- val displayRotationDegrees: Int = getDisplayRotationDegrees(displayRotation)
- val scaled: Size = calculatePreviewViewDimens(
- resolution, viewWidth, viewHeight, displayRotation
- )
-
- // Compute the center of the view.
- val centerX = (viewWidth / 2).toFloat()
- val centerY = (viewHeight / 2).toFloat()
-
- // Do corresponding rotation to correct the preview direction
- matrix.postRotate((-displayRotationDegrees).toFloat(), centerX, centerY)
-
- // Compute the scale value for center crop mode
- var xScale = scaled.width / viewWidth.toFloat()
- var yScale = scaled.height / viewHeight.toFloat()
- if (displayRotationDegrees % 180 == 90) {
- xScale = scaled.width / viewHeight.toFloat()
- yScale = scaled.height / viewWidth.toFloat()
- }
-
- // Only two digits after the decimal point are valid for postScale. Need to get ceiling of
- // two digits floating value to do the scale operation. Otherwise, the result may be scaled
- // not large enough and will have some blank lines on the screen.
- xScale = BigDecimal(xScale.toDouble()).setScale(2, RoundingMode.CEILING).toFloat()
- yScale = BigDecimal(yScale.toDouble()).setScale(2, RoundingMode.CEILING).toFloat()
-
- // Do corresponding scale to resolve the deformation problem
- matrix.postScale(xScale, yScale, centerX, centerY)
- textureView.setTransform(matrix)
- }
-
- /**
- * Converts the display rotation to degrees value.
- *
- * @return One of 0, 90, 180, 270.
- */
- @JvmStatic
- fun getDisplayRotationDegrees(displayRotation: Int): Int = when (displayRotation) {
- Surface.ROTATION_0 -> 0
- Surface.ROTATION_90 -> 90
- Surface.ROTATION_180 -> 180
- Surface.ROTATION_270 -> 270
- else -> throw UnsupportedOperationException(
- "Unsupported display rotation: $displayRotation"
- )
- }
-
- /**
- * Calculates the delta between a source rotation and destination rotation.
- *
- * <p>A typical use of this method would be calculating the angular difference between the
- * display orientation (destRotationDegrees) and camera sensor orientation
- * (sourceRotationDegrees).
- *
- * @param destRotationDegrees The destination rotation relative to the device's natural
- * rotation.
- * @param sourceRotationDegrees The source rotation relative to the device's natural rotation.
- * @param isOppositeFacing Whether the source and destination planes are facing opposite
- * directions.
- */
- @JvmStatic
- fun calculateRelativeImageRotationDegrees(
- destRotationDegrees: Int,
- sourceRotationDegrees: Int,
- isOppositeFacing: Boolean
- ): Int {
- val result: Int = if (isOppositeFacing) {
- (sourceRotationDegrees - destRotationDegrees + 360) % 360
- } else {
- (sourceRotationDegrees + destRotationDegrees) % 360
- }
-
- return result
- }
-
- /**
- * Calculates the preview size which can display the source image in correct aspect ratio.
- */
- @JvmStatic
- private fun calculatePreviewViewDimens(
- srcSize: Size,
- parentWidth: Int,
- parentHeight: Int,
- displayRotation: Int
- ): Size {
- var inWidth = srcSize.width
- var inHeight = srcSize.height
- if (displayRotation == 0 || displayRotation == 180) {
- // Need to reverse the width and height since we're in landscape orientation.
- inWidth = srcSize.height
- inHeight = srcSize.width
- }
- var outWidth = parentWidth
- var outHeight = parentHeight
- if (inWidth != 0 && inHeight != 0) {
- val vfRatio = inWidth / inHeight.toFloat()
- val parentRatio = parentWidth / parentHeight.toFloat()
-
- // Match shortest sides together.
- if (vfRatio < parentRatio) {
- outWidth = parentWidth
- outHeight = Math.round(parentWidth / vfRatio)
- } else {
- outWidth = Math.round(parentHeight * vfRatio)
- outHeight = parentHeight
- }
- }
- return Size(outWidth, outHeight)
- }
}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/TransformUtil.kt b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/TransformUtil.kt
new file mode 100644
index 0000000..fb3c5ef
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/TransformUtil.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright 2022 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.integration.extensions.utils
+
+import android.graphics.Matrix
+import android.graphics.Rect
+import android.graphics.RectF
+import android.util.Size
+import android.view.Surface
+import android.view.TextureView
+import androidx.core.graphics.toRect
+
+// Normalized space (-1, -1) - (1, 1).
+private val NORMALIZED_RECT = RectF(-1f, -1f, 1f, 1f)
+
+object TransformUtil {
+
+ /**
+ * Converts [Surface] rotation to rotation degrees: 90, 180, 270 or 0.
+ */
+ @JvmStatic
+ fun surfaceRotationToRotationDegrees(rotationValue: Int): Int = when (rotationValue) {
+ Surface.ROTATION_0 -> 0
+ Surface.ROTATION_90 -> 90
+ Surface.ROTATION_180 -> 180
+ Surface.ROTATION_270 -> 270
+ else -> throw UnsupportedOperationException(
+ "Unsupported display rotation: $rotationValue"
+ )
+ }
+
+ /**
+ * Calculates the delta between a source rotation and destination rotation.
+ *
+ * <p>A typical use of this method would be calculating the angular difference between the
+ * display orientation (destRotationDegrees) and camera sensor orientation
+ * (sourceRotationDegrees).
+ *
+ * @param destRotationDegrees The destination rotation relative to the device's natural
+ * rotation.
+ * @param sourceRotationDegrees The source rotation relative to the device's natural rotation.
+ * @param isOppositeFacing Whether the source and destination planes are facing opposite
+ * directions.
+ */
+ @JvmStatic
+ fun calculateRelativeImageRotationDegrees(
+ destRotationDegrees: Int,
+ sourceRotationDegrees: Int,
+ isOppositeFacing: Boolean
+ ): Int = if (isOppositeFacing) {
+ (sourceRotationDegrees - destRotationDegrees + 360) % 360
+ } else {
+ (sourceRotationDegrees + destRotationDegrees) % 360
+ }
+
+ /**
+ * Calculates the transformation and applies it to the inner view of [TextureView] preview.
+ *
+ * [TextureView] needs a preliminary correction since it doesn't handle the
+ * display rotation.
+ */
+ @JvmStatic
+ fun transformTextureView(
+ preview: TextureView,
+ containerViewSize: Size,
+ resolution: Size,
+ targetRotation: Int,
+ sensorRotationDegrees: Int,
+ isOppositeFacing: Boolean
+ ) {
+ // For TextureView, correct the orientation to match the target rotation.
+ preview.setTransform(getTextureViewCorrectionMatrix(resolution, targetRotation))
+
+ val surfaceRectInPreview = getTransformedSurfaceRect(
+ containerViewSize,
+ resolution,
+ calculateRelativeImageRotationDegrees(
+ surfaceRotationToRotationDegrees(targetRotation),
+ sensorRotationDegrees,
+ isOppositeFacing
+ )
+ )
+
+ preview.pivotX = 0f
+ preview.pivotY = 0f
+ preview.scaleX = surfaceRectInPreview.width() / resolution.width
+ preview.scaleY = surfaceRectInPreview.height() / resolution.height
+ preview.translationX = surfaceRectInPreview.left - preview.left
+ preview.translationY = surfaceRectInPreview.top - preview.top
+ }
+
+ /**
+ * Creates a matrix that makes [TextureView]'s rotation matches the target rotation.
+ *
+ * The value should be applied by calling [TextureView.setTransform]. Usually the target
+ * rotation is the display rotation. In that case, this matrix will just make a [TextureView]
+ * works like a SurfaceView. If not, then it will further correct it to the desired rotation.
+ *
+ */
+ @JvmStatic
+ private fun getTextureViewCorrectionMatrix(resolution: Size, targetRotation: Int): Matrix {
+ val surfaceRect = RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
+ val rotationDegrees = -surfaceRotationToRotationDegrees(targetRotation)
+ return getRectToRect(surfaceRect, surfaceRect, rotationDegrees)
+ }
+
+ /**
+ * Gets the transform from one {@link Rect} to another with rotation degrees.
+ *
+ * <p> Following is how the source is mapped to the target with a 90° rotation. The rect
+ * <a, b, c, d> is mapped to <a', b', c', d'>.
+ *
+ * <pre>
+ * a----------b d'-----------a'
+ * | source | -90°-> | |
+ * d----------c | target |
+ * | |
+ * c'-----------b'
+ * </pre>
+ */
+ @JvmStatic
+ private fun getRectToRect(
+ source: RectF,
+ target: RectF,
+ rotationDegrees: Int
+ ): Matrix = Matrix().apply {
+ // Map source to normalized space.
+ setRectToRect(source, NORMALIZED_RECT, Matrix.ScaleToFit.FILL)
+ // Add rotation.
+ postRotate(rotationDegrees.toFloat())
+ // Restore the normalized space to target's coordinates.
+ postConcat(getNormalizedToBuffer(target))
+ }
+
+ /**
+ * Gets the transform from a normalized space (-1, -1) - (1, 1) to the given rect.
+ */
+ @JvmStatic
+ private fun getNormalizedToBuffer(viewPortRect: RectF): Matrix = Matrix().apply {
+ setRectToRect(
+ NORMALIZED_RECT,
+ viewPortRect,
+ Matrix.ScaleToFit.FILL
+ )
+ }
+
+ /**
+ * Gets the transformed [Surface] rect in the preview coordinates.
+ *
+ * Returns desired rect of the inner view that once applied, the only part visible to
+ * end users is the crop rect.
+ */
+ @JvmStatic
+ private fun getTransformedSurfaceRect(
+ containerViewSize: Size,
+ resolution: Size,
+ rotationDegrees: Int
+ ): RectF {
+ val surfaceToPreviewMatrix = getSurfaceToPreviewMatrix(
+ containerViewSize,
+ resolution,
+ rotationDegrees
+ )
+ val rect =
+ RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
+ surfaceToPreviewMatrix.mapRect(rect)
+ return rect
+ }
+
+ /**
+ * Calculates the transformation from [Surface] coordinates to the preview coordinates.
+ *
+ * The calculation is based on making the crop rect to center fill the preview.
+ */
+ @JvmStatic
+ private fun getSurfaceToPreviewMatrix(
+ containerViewSize: Size,
+ resolution: Size,
+ rotationDegrees: Int
+ ): Matrix {
+ val surfaceRect = RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
+
+ // Get the target of the mapping, the coordinates of the crop rect in the preview.
+ val previewCropRect = getPreviewCropRect(
+ containerViewSize, surfaceRect.toRect(), rotationDegrees
+ )
+
+ return getRectToRect(surfaceRect, previewCropRect, rotationDegrees)
+ }
+
+ /**
+ * Gets the crop rect in the preview coordinates.
+ */
+ @JvmStatic
+ private fun getPreviewCropRect(
+ containerViewSize: Size,
+ surfaceCropRect: Rect,
+ rotationDegrees: Int
+ ): RectF {
+ val containerViewRect = RectF(
+ 0f, 0f, containerViewSize.width.toFloat(),
+ containerViewSize.height.toFloat()
+ )
+ val rotatedCropRectSize = getRotatedCropRectSize(surfaceCropRect, rotationDegrees)
+ val rotatedCropRect =
+ RectF(0f, 0f, rotatedCropRectSize.width.toFloat(), rotatedCropRectSize.height.toFloat())
+
+ Matrix().apply {
+ // android.graphics.Matrix doesn't support fill scale types. The workaround is
+ // mapping inversely from destination to source, then invert the matrix.
+ setRectToRect(containerViewRect, rotatedCropRect, Matrix.ScaleToFit.CENTER)
+ invert(this)
+ mapRect(rotatedCropRect)
+ }
+
+ return rotatedCropRect
+ }
+
+ /**
+ * Returns crop rect size with target rotation applied.
+ */
+ @JvmStatic
+ private fun getRotatedCropRectSize(surfaceCropRect: Rect, rotationDegrees: Int): Size =
+ if (is90or270(rotationDegrees)) {
+ Size(surfaceCropRect.height(), surfaceCropRect.width())
+ } else Size(surfaceCropRect.width(), surfaceCropRect.height())
+
+ /**
+ * Returns true if the rotation degrees is 90 or 270.
+ */
+ @JvmStatic
+ private fun is90or270(rotationDegrees: Int): Boolean {
+ if (rotationDegrees == 90 || rotationDegrees == 270) {
+ return true
+ }
+ if (rotationDegrees == 0 || rotationDegrees == 180) {
+ return false
+ }
+ throw IllegalArgumentException("Invalid rotation degrees: $rotationDegrees")
+ }
+}
\ No newline at end of file
diff --git a/camera/integration-tests/extensionstestapp/src/main/res/layout/activity_camera_extensions.xml b/camera/integration-tests/extensionstestapp/src/main/res/layout/activity_camera_extensions.xml
index 1a8adfa..586c25a 100644
--- a/camera/integration-tests/extensionstestapp/src/main/res/layout/activity_camera_extensions.xml
+++ b/camera/integration-tests/extensionstestapp/src/main/res/layout/activity_camera_extensions.xml
@@ -26,6 +26,7 @@
<ViewStub
android:id="@+id/viewFinderStub"
+ android:inflatedId="@id/viewFinder"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
diff --git a/camera/integration-tests/extensionstestapp/src/main/res/layout/full_textureview.xml b/camera/integration-tests/extensionstestapp/src/main/res/layout/full_textureview.xml
index 18ebf88..5d76592 100644
--- a/camera/integration-tests/extensionstestapp/src/main/res/layout/full_textureview.xml
+++ b/camera/integration-tests/extensionstestapp/src/main/res/layout/full_textureview.xml
@@ -14,6 +14,13 @@
limitations under the License.
-->
-<TextureView xmlns:android="http://schemas.android.com/apk/res/android"
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
- android:layout_height="match_parent" />
+ android:layout_height="match_parent">
+
+ <TextureView
+ android:id="@+id/textureView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
+
+</FrameLayout>
diff --git a/camera/integration-tests/extensionstestapp/src/main/res/values/ids.xml b/camera/integration-tests/extensionstestapp/src/main/res/values/ids.xml
new file mode 100644
index 0000000..18de602
--- /dev/null
+++ b/camera/integration-tests/extensionstestapp/src/main/res/values/ids.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Copyright 2022 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.
+ -->
+
+<resources>
+ <item type="id" name="viewFinder" />
+</resources>