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