1 /* <lambda>null2 * Copyright 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.camera.integration.extensions.validation 18 19 import android.content.ContentValues 20 import android.content.Intent 21 import android.content.res.Configuration 22 import android.net.Uri 23 import android.os.Build 24 import android.os.Bundle 25 import android.provider.MediaStore 26 import android.util.Log 27 import android.view.GestureDetector 28 import android.view.Menu 29 import android.view.MenuItem 30 import android.view.MotionEvent 31 import android.view.ScaleGestureDetector 32 import android.view.View 33 import android.widget.ImageButton 34 import android.widget.ImageView 35 import android.widget.Toast 36 import androidx.appcompat.app.AppCompatActivity 37 import androidx.camera.integration.extensions.Camera2ExtensionsActivity 38 import androidx.camera.integration.extensions.ExtensionTestType.TEST_TYPE_CAMERA2_EXTENSION 39 import androidx.camera.integration.extensions.ExtensionTestType.TEST_TYPE_CAMERA2_EXTENSION_STREAM_CONFIG_LATENCY 40 import androidx.camera.integration.extensions.ExtensionTestType.TEST_TYPE_CAMERAX_EXTENSION 41 import androidx.camera.integration.extensions.INVALID_EXTENSION_MODE 42 import androidx.camera.integration.extensions.INVALID_LENS_FACING 43 import androidx.camera.integration.extensions.IntentExtraKey.INTENT_EXTRA_KEY_CAMERA_ID 44 import androidx.camera.integration.extensions.IntentExtraKey.INTENT_EXTRA_KEY_ERROR_CODE 45 import androidx.camera.integration.extensions.IntentExtraKey.INTENT_EXTRA_KEY_EXTENSION_MODE 46 import androidx.camera.integration.extensions.IntentExtraKey.INTENT_EXTRA_KEY_IMAGE_ROTATION_DEGREES 47 import androidx.camera.integration.extensions.IntentExtraKey.INTENT_EXTRA_KEY_IMAGE_URI 48 import androidx.camera.integration.extensions.IntentExtraKey.INTENT_EXTRA_KEY_LENS_FACING 49 import androidx.camera.integration.extensions.IntentExtraKey.INTENT_EXTRA_KEY_REQUEST_CODE 50 import androidx.camera.integration.extensions.IntentExtraKey.INTENT_EXTRA_KEY_TEST_TYPE 51 import androidx.camera.integration.extensions.R 52 import androidx.camera.integration.extensions.TestResultType.TEST_RESULT_FAILED 53 import androidx.camera.integration.extensions.TestResultType.TEST_RESULT_PASSED 54 import androidx.camera.integration.extensions.ValidationErrorCode.ERROR_CODE_BIND_TO_LIFECYCLE_FAILED 55 import androidx.camera.integration.extensions.ValidationErrorCode.ERROR_CODE_EXTENSION_MODE_NOT_SUPPORT 56 import androidx.camera.integration.extensions.ValidationErrorCode.ERROR_CODE_NONE 57 import androidx.camera.integration.extensions.ValidationErrorCode.ERROR_CODE_SAVE_IMAGE_FAILED 58 import androidx.camera.integration.extensions.ValidationErrorCode.ERROR_CODE_TAKE_PICTURE_FAILED 59 import androidx.camera.integration.extensions.utils.FileUtil.copyTempFileToOutputLocation 60 import androidx.camera.integration.extensions.validation.CameraValidationResultActivity.Companion.getLensFacingStringFromInt 61 import androidx.camera.integration.extensions.validation.PhotoFragment.Companion.decodeImageToBitmap 62 import androidx.core.app.ActivityCompat 63 import androidx.fragment.app.Fragment 64 import androidx.fragment.app.FragmentActivity 65 import androidx.viewpager2.adapter.FragmentStateAdapter 66 import androidx.viewpager2.widget.ViewPager2 67 import java.text.Format 68 import java.text.SimpleDateFormat 69 import java.util.Calendar 70 import java.util.Locale 71 import kotlin.math.max 72 import kotlin.math.min 73 74 private const val TAG = "ImageValidationActivity" 75 private const val SAVED_STATE_KEY_NOT_FIRST_LAUNCH = "NotFirstLaunch" 76 private const val SAVED_STATE_KEY_CURRENT_INDEX = "CurrentIndex" 77 78 /** 79 * Activity to show the captured images of the specified extension mode and validate the results. 80 * 81 * Testers can trigger to take a pictures again and press the PASSED or FAILED button to report the 82 * validation result. 83 */ 84 class ImageValidationActivity : AppCompatActivity() { 85 86 private var extensionMode = INVALID_EXTENSION_MODE 87 private val result = Intent() 88 private var lensFacing = INVALID_LENS_FACING 89 private lateinit var testResults: TestResults 90 private lateinit var testType: String 91 private lateinit var cameraId: String 92 private lateinit var failButton: ImageButton 93 private lateinit var passButton: ImageButton 94 private lateinit var captureButton: ImageButton 95 private lateinit var viewPager: ViewPager2 96 private lateinit var photoImageView: ImageView 97 private val imageCaptureActivityRequestCode = ImageCaptureActivity::class.java.hashCode() % 1000 98 private val imageUris = arrayListOf<Uri>() 99 private val imageRotationDegrees = arrayListOf<Int>() 100 private var scaledBitmapWidth = 0 101 private var scaledBitmapHeight = 0 102 private var currentScale = 1.0f 103 private var translationX = 0.0f 104 private var translationY = 0.0f 105 private var currentIndex = 0 106 107 @Suppress("UNCHECKED_CAST", "DEPRECATION") 108 override fun onCreate(savedInstanceState: Bundle?) { 109 super.onCreate(savedInstanceState) 110 setContentView(R.layout.image_validation_activity) 111 testResults = TestResults.getInstance(this) 112 113 testType = intent?.getStringExtra(INTENT_EXTRA_KEY_TEST_TYPE)!! 114 cameraId = intent?.getStringExtra(INTENT_EXTRA_KEY_CAMERA_ID)!! 115 lensFacing = intent.getIntExtra(INTENT_EXTRA_KEY_LENS_FACING, INVALID_LENS_FACING) 116 extensionMode = intent.getIntExtra(INTENT_EXTRA_KEY_EXTENSION_MODE, INVALID_EXTENSION_MODE) 117 118 savedInstanceState?.let { bundle -> 119 bundle.getSerializable("imageUris")?.let { serializable -> 120 imageUris.addAll(serializable as ArrayList<Uri>) 121 } 122 currentIndex = bundle.getInt(SAVED_STATE_KEY_CURRENT_INDEX, 0) 123 } 124 125 savedInstanceState?.let { bundle -> 126 bundle.getIntegerArrayList("imageRotationDegrees")?.let { serializable -> 127 imageRotationDegrees.addAll(serializable as ArrayList<Int>) 128 } 129 } 130 131 result.putExtra(INTENT_EXTRA_KEY_EXTENSION_MODE, extensionMode) 132 val requestCode = intent.getIntExtra(INTENT_EXTRA_KEY_REQUEST_CODE, -1) 133 setResult(requestCode, result) 134 135 val extensionModeString = TestResults.getExtensionModeStringFromId(testType, extensionMode) 136 supportActionBar?.title = 137 if (testType == TEST_TYPE_CAMERAX_EXTENSION) { 138 resources.getString(R.string.camerax_extensions_validator) 139 } else { 140 resources.getString(R.string.camera2_extensions_validator) 141 } 142 143 supportActionBar!!.subtitle = 144 "Camera $cameraId [${getLensFacingStringFromInt(lensFacing)}][$extensionModeString]" 145 146 photoImageView = findViewById(R.id.photo_image_view) 147 148 photoImageView.addOnLayoutChangeListener { 149 _: View?, 150 left: Int, 151 top: Int, 152 right: Int, 153 bottom: Int, 154 oldLeft: Int, 155 oldTop: Int, 156 oldRight: Int, 157 oldBottom: Int -> 158 if (imageUris.isEmpty()) { 159 return@addOnLayoutChangeListener 160 } 161 val isSizeChanged = 162 right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop 163 if (isSizeChanged) { 164 tryShowCaptureResults() 165 } 166 } 167 168 viewPager = findViewById(R.id.photo_view_pager) 169 viewPager.adapter = PhotoPagerAdapter(this) 170 viewPager.registerOnPageChangeCallback( 171 object : ViewPager2.OnPageChangeCallback() { 172 override fun onPageSelected(position: Int) { 173 super.onPageSelected(position) 174 currentIndex = position 175 tryShowCaptureResults() 176 } 177 } 178 ) 179 180 setupButtonControls() 181 setupGestureControls() 182 183 // Launches ImageCaptureActivity automatically only when not-first-launch flag is not true 184 if (savedInstanceState?.getBoolean(SAVED_STATE_KEY_NOT_FIRST_LAUNCH) != true) { 185 startImageCaptureActivity(testType, cameraId, extensionMode) 186 } 187 } 188 189 override fun onSaveInstanceState(outState: Bundle) { 190 super.onSaveInstanceState(outState) 191 outState.putBoolean(SAVED_STATE_KEY_NOT_FIRST_LAUNCH, true) 192 outState.putInt(SAVED_STATE_KEY_CURRENT_INDEX, currentIndex) 193 outState.putSerializable("imageUris", imageUris) 194 outState.putIntegerArrayList("imageRotationDegrees", imageRotationDegrees) 195 } 196 197 @Suppress("DEPRECATION") 198 @Deprecated("Deprecated in ComponentActivity") 199 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 200 super.onActivityResult(requestCode, resultCode, data) 201 202 if (requestCode != imageCaptureActivityRequestCode) { 203 return 204 } 205 206 val errorCode = data?.getIntExtra(INTENT_EXTRA_KEY_ERROR_CODE, ERROR_CODE_NONE) 207 208 // Returns with error 209 if ( 210 errorCode == ERROR_CODE_BIND_TO_LIFECYCLE_FAILED || 211 errorCode == ERROR_CODE_EXTENSION_MODE_NOT_SUPPORT || 212 errorCode == ERROR_CODE_TAKE_PICTURE_FAILED || 213 errorCode == ERROR_CODE_SAVE_IMAGE_FAILED 214 ) { 215 Log.e(TAG, "Failed to take a picture with error code: $errorCode") 216 testResults.updateTestResultAndSave( 217 testType, 218 cameraId, 219 extensionMode, 220 TEST_RESULT_FAILED 221 ) 222 finish() 223 return 224 } 225 226 val uri = data?.getParcelableExtra(INTENT_EXTRA_KEY_IMAGE_URI) as Uri? 227 228 // Returns without capturing a picture 229 if (uri == null) { 230 // Closes the activity if there is no image captured. 231 if (imageUris.isEmpty()) { 232 finish() 233 return 234 } 235 } 236 237 uri?.let { 238 val rotationDegrees = data?.getIntExtra(INTENT_EXTRA_KEY_IMAGE_ROTATION_DEGREES, 0)!! 239 imageUris.add(it) 240 imageRotationDegrees.add(rotationDegrees) 241 } 242 243 viewPager.adapter = PhotoPagerAdapter(this) 244 viewPager.currentItem = if (uri != null) imageUris.size - 1 else currentIndex 245 viewPager.visibility = View.VISIBLE 246 (viewPager.adapter as PhotoPagerAdapter).notifyDataSetChanged() 247 248 tryShowCaptureResults() 249 } 250 251 override fun onConfigurationChanged(newConfig: Configuration) { 252 super.onConfigurationChanged(newConfig) 253 resetAndHidePhotoImageView() 254 updateScaledBitmapDims(scaledBitmapWidth, scaledBitmapHeight) 255 } 256 257 override fun onCreateOptionsMenu(menu: Menu): Boolean { 258 menuInflater.inflate(R.menu.image_validation_menu, menu) 259 return true 260 } 261 262 override fun onOptionsItemSelected(item: MenuItem): Boolean { 263 return when (item.itemId) { 264 R.id.menu_save_image -> { 265 saveCurrentImage() 266 true 267 } 268 else -> super.onOptionsItemSelected(item) 269 } 270 } 271 272 private fun saveCurrentImage() { 273 val formatter: Format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US) 274 val savedFileName = 275 "${imageUris[viewPager.currentItem].lastPathSegment}" + 276 "[${formatter.format(Calendar.getInstance().time)}].jpg" 277 278 val contentValues = 279 ContentValues().apply { 280 put(MediaStore.MediaColumns.DISPLAY_NAME, savedFileName) 281 put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") 282 put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/ExtensionsValidation") 283 } 284 285 val outputUri = 286 copyTempFileToOutputLocation( 287 contentResolver, 288 imageUris[viewPager.currentItem], 289 MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 290 contentValues 291 ) 292 293 if (outputUri != null) { 294 Toast.makeText( 295 this, 296 "Image is saved as Pictures/ExtensionsValidation/$savedFileName.", 297 Toast.LENGTH_LONG 298 ) 299 .show() 300 } else { 301 Toast.makeText(this, "Failed to export the CSV file!", Toast.LENGTH_LONG).show() 302 } 303 } 304 305 private fun setupButtonControls() { 306 failButton = findViewById(R.id.fail_button) 307 failButton.setOnClickListener { 308 testResults.updateTestResultAndSave( 309 testType, 310 cameraId, 311 extensionMode, 312 TEST_RESULT_FAILED 313 ) 314 finish() 315 } 316 317 passButton = findViewById(R.id.pass_button) 318 passButton.setOnClickListener { 319 testResults.updateTestResultAndSave( 320 testType, 321 cameraId, 322 extensionMode, 323 TEST_RESULT_PASSED 324 ) 325 finish() 326 } 327 328 captureButton = findViewById(R.id.capture_button) 329 captureButton.setOnClickListener { 330 startImageCaptureActivity(testType, cameraId, extensionMode) 331 } 332 } 333 334 private fun startImageCaptureActivity(testType: String, cameraId: String, mode: Int) { 335 val intent = 336 if ( 337 Build.VERSION.SDK_INT >= 31 && 338 (testType == TEST_TYPE_CAMERA2_EXTENSION || 339 testType == TEST_TYPE_CAMERA2_EXTENSION_STREAM_CONFIG_LATENCY) 340 ) 341 Intent(this, Camera2ExtensionsActivity::class.java) 342 else Intent(this, ImageCaptureActivity::class.java) 343 344 intent.putExtra(INTENT_EXTRA_KEY_CAMERA_ID, cameraId) 345 intent.putExtra(INTENT_EXTRA_KEY_LENS_FACING, lensFacing) 346 intent.putExtra(INTENT_EXTRA_KEY_EXTENSION_MODE, mode) 347 intent.putExtra(INTENT_EXTRA_KEY_REQUEST_CODE, imageCaptureActivityRequestCode) 348 349 ActivityCompat.startActivityForResult(this, intent, imageCaptureActivityRequestCode, null) 350 } 351 352 /** Adapter class used to present a fragment containing one photo or video as a page */ 353 inner class PhotoPagerAdapter(fragmentActivity: FragmentActivity) : 354 FragmentStateAdapter(fragmentActivity) { 355 override fun getItemCount(): Int { 356 return imageUris.size 357 } 358 359 override fun createFragment(position: Int): Fragment { 360 // Set scale gesture listener to the fragments inside the ViewPager2 so that we can 361 // switch to another photo view which supports the translation function in the X 362 // direction. Otherwise, the fragments inside the ViewPager2 will eat the X direction 363 // movement events for the ViewPager2's page switch function. But we'll need the 364 // translation function in X direction after the photo is zoomed in. 365 val scaleGestureListener: ScaleGestureDetector.SimpleOnScaleGestureListener = 366 object : ScaleGestureDetector.SimpleOnScaleGestureListener() { 367 override fun onScale(detector: ScaleGestureDetector): Boolean { 368 updatePhotoViewScale(detector.scaleFactor) 369 return true 370 } 371 } 372 373 return PhotoFragment( 374 imageUris[position], 375 imageRotationDegrees[position], 376 scaleGestureListener 377 ) 378 } 379 } 380 381 private fun setupGestureControls() { 382 // Registers the scale gesture event to allow the users to scale the photo image view 383 // between 1.0 and 3.0 times. 384 val scaleGestureListener: ScaleGestureDetector.SimpleOnScaleGestureListener = 385 object : ScaleGestureDetector.SimpleOnScaleGestureListener() { 386 override fun onScale(detector: ScaleGestureDetector): Boolean { 387 updatePhotoViewScale(detector.scaleFactor) 388 return true 389 } 390 } 391 392 // Registers double tap event to reset and hide the photo image view. 393 val onDoubleTapGestureListener: GestureDetector.OnGestureListener = 394 object : GestureDetector.SimpleOnGestureListener() { 395 override fun onDoubleTap(e: MotionEvent): Boolean { 396 resetAndHidePhotoImageView() 397 return true 398 } 399 } 400 401 val scaleDetector = ScaleGestureDetector(this, scaleGestureListener) 402 val doubleTapDetector = GestureDetector(this, onDoubleTapGestureListener) 403 var previousX = 0.0f 404 var previousY = 0.0f 405 406 photoImageView.setOnTouchListener { _, e: MotionEvent -> 407 if (photoImageView.visibility != View.VISIBLE) { 408 return@setOnTouchListener false 409 } 410 411 val doubleTapProcessed = doubleTapDetector.onTouchEvent(e) 412 val scaleGestureProcessed = scaleDetector.onTouchEvent(e) 413 414 when (e.actionMasked) { 415 MotionEvent.ACTION_DOWN -> { 416 previousX = e.x 417 previousY = e.y 418 } 419 MotionEvent.ACTION_MOVE -> { 420 updatePhotoImageViewTranslation(e.x, e.y, previousX, previousY) 421 } 422 } 423 424 doubleTapProcessed || scaleGestureProcessed 425 } 426 } 427 428 internal fun updatePhotoViewScale(scaleFactor: Float) { 429 currentScale *= scaleFactor 430 431 // Don't let the object get too small or too large. 432 currentScale = max(1.0f, min(currentScale, 3.0f)) 433 434 photoImageView.scaleX = currentScale 435 photoImageView.scaleY = currentScale 436 437 // Shows the photoImageView when the scale is larger than 1.0f. Hides the photoImageView 438 // when the scale has been reduced as 1.0f. 439 if (photoImageView.visibility != View.VISIBLE && currentScale > 1.0f) { 440 photoImageView.visibility = View.VISIBLE 441 viewPager.visibility = View.INVISIBLE 442 } else if (photoImageView.visibility == View.VISIBLE && currentScale == 1.0f) { 443 resetAndHidePhotoImageView() 444 } 445 } 446 447 private fun updatePhotoImageViewTranslation( 448 x: Float, 449 y: Float, 450 previousX: Float, 451 previousY: Float 452 ) { 453 val newTranslationX = translationX + x - previousX 454 455 if (scaledBitmapWidth * currentScale > photoImageView.width) { 456 val maxTranslationX = (scaledBitmapWidth * currentScale - photoImageView.width) / 2 457 458 translationX = 459 if (newTranslationX >= 0) { 460 if (maxTranslationX - newTranslationX >= 0) { 461 newTranslationX 462 } else { 463 maxTranslationX 464 } 465 } else { 466 if (maxTranslationX + newTranslationX >= 0) { 467 newTranslationX 468 } else { 469 -maxTranslationX 470 } 471 } 472 photoImageView.translationX = translationX 473 } 474 475 val newTranslationY = translationY + y - previousY 476 477 if (scaledBitmapHeight * currentScale > photoImageView.height) { 478 val maxTranslationY = (scaledBitmapHeight * currentScale - photoImageView.height) / 2 479 480 translationY = 481 if (newTranslationY >= 0) { 482 if (maxTranslationY - newTranslationY >= 0) { 483 newTranslationY 484 } else { 485 maxTranslationY 486 } 487 } else { 488 if (maxTranslationY + newTranslationY >= 0) { 489 newTranslationY 490 } else { 491 -maxTranslationY 492 } 493 } 494 photoImageView.translationY = translationY 495 } 496 } 497 498 internal fun tryShowCaptureResults() { 499 if (photoImageView.width == 0) { 500 return 501 } 502 503 updatePhotoImageView() 504 resetAndHidePhotoImageView() 505 } 506 507 internal fun updatePhotoImageView() { 508 val bitmap = 509 decodeImageToBitmap( 510 this@ImageValidationActivity.contentResolver, 511 imageUris[viewPager.currentItem], 512 imageRotationDegrees[viewPager.currentItem] 513 ) 514 515 photoImageView.setImageBitmap(bitmap) 516 updateScaledBitmapDims(bitmap.width, bitmap.height) 517 518 // Updates the index and file name to the subtitle 519 supportActionBar!!.subtitle = 520 "[${viewPager.currentItem + 1}/${imageUris.size}]" + 521 "${imageUris[viewPager.currentItem].lastPathSegment}" 522 } 523 524 private fun updateScaledBitmapDims(width: Int, height: Int) { 525 val scale: Float 526 if (width * photoImageView.height / photoImageView.width > height) { 527 scale = photoImageView.width.toFloat() / width 528 } else { 529 scale = photoImageView.height.toFloat() / height 530 } 531 532 scaledBitmapWidth = (width * scale).toInt() 533 scaledBitmapHeight = (height * scale).toInt() 534 } 535 536 internal fun resetAndHidePhotoImageView() { 537 viewPager.visibility = View.VISIBLE 538 photoImageView.visibility = View.INVISIBLE 539 photoImageView.scaleX = 1.0f 540 photoImageView.scaleY = 1.0f 541 photoImageView.translationX = 0.0f 542 photoImageView.translationY = 0.0f 543 currentScale = 1.0f 544 translationX = 0.0f 545 translationY = 0.0f 546 } 547 } 548