1 /*
2  * Copyright 2019 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 @file:Suppress("DEPRECATION")
18 
19 package androidx.camera.integration.antelope
20 
21 import android.content.ContentResolver
22 import android.content.ContentValues
23 import android.content.Intent
24 import android.graphics.Bitmap
25 import android.graphics.ImageFormat
26 import android.graphics.Matrix
27 import android.media.ImageReader
28 import android.net.Uri
29 import android.os.Build
30 import android.os.Environment
31 import android.provider.MediaStore
32 import android.widget.Toast
33 import androidx.annotation.RequiresApi
34 import androidx.camera.core.ImageCapture
35 import androidx.camera.core.ImageCaptureException
36 import androidx.camera.core.ImageProxy
37 import androidx.camera.integration.antelope.MainActivity.Companion.PHOTOS_DIR
38 import androidx.camera.integration.antelope.MainActivity.Companion.PHOTOS_PATH
39 import androidx.camera.integration.antelope.MainActivity.Companion.logd
40 import androidx.camera.integration.antelope.cameracontrollers.CameraState
41 import androidx.camera.integration.antelope.cameracontrollers.closeCameraX
42 import androidx.camera.integration.antelope.cameracontrollers.closePreviewAndCamera
43 import androidx.exifinterface.media.ExifInterface
44 import java.io.ByteArrayInputStream
45 import java.io.File
46 import java.io.FileOutputStream
47 import java.io.IOException
48 import java.text.SimpleDateFormat
49 import java.util.Date
50 import java.util.Locale
51 
52 /**
53  * ImageReader listener for use with Camera 2 API.
54  *
55  * Extract image and write to disk
56  */
57 class ImageAvailableListener(
58     internal val activity: MainActivity,
59     internal var params: CameraParams,
60     internal val testConfig: TestConfig
61 ) : ImageReader.OnImageAvailableListener {
62 
onImageAvailablenull63     override fun onImageAvailable(reader: ImageReader) {
64         logd(
65             "onImageAvailable enter. Current test: " +
66                 testConfig.currentRunningTest +
67                 " state: " +
68                 params.state
69         )
70 
71         // Only save 1 photo each time
72         if (CameraState.IMAGE_REQUESTED != params.state) return
73         else params.state = CameraState.UNINITIALIZED
74 
75         val image = reader.acquireLatestImage()
76 
77         when (image.format) {
78             ImageFormat.JPEG -> {
79                 // Orientation
80                 @Suppress("DEPRECATION") /* defaultDisplay */
81                 val rotation = activity.windowManager.defaultDisplay.rotation
82                 val capturedImageRotation = getOrientation(params, rotation)
83 
84                 params.timer.imageReaderEnd = System.currentTimeMillis()
85                 params.timer.imageSaveStart = System.currentTimeMillis()
86 
87                 val bytes = ByteArray(image.planes[0].buffer.remaining())
88                 image.planes[0].buffer.get(bytes)
89 
90                 params.backgroundHandler?.post(
91                     ImageSaver(
92                         activity,
93                         bytes,
94                         capturedImageRotation,
95                         params.isFront,
96                         params,
97                         testConfig
98                     )
99                 )
100             }
101 
102             // TODO: add RAW support
103             ImageFormat.RAW_SENSOR -> {}
104             else -> {}
105         }
106 
107         image.close()
108     }
109 }
110 
111 /** Asynchronously save ByteArray to disk */
112 class ImageSaver
113 internal constructor(
114     private val activity: MainActivity,
115     private val bytes: ByteArray,
116     private val rotation: Int,
117     private val flip: Boolean,
118     private val params: CameraParams,
119     private val testConfig: TestConfig
120 ) : Runnable {
121 
runnull122     override fun run() {
123         logd("ImageSaver. ImageSaver is running, saving image to disk.")
124 
125         // TODO: Once Android supports HDR+ detection add this in
126         //        if (isHDRPlus(bytes))
127         //            params.timer.isHDRPlus = true;
128 
129         writeFile(activity, bytes)
130 
131         params.timer.imageSaveEnd = System.currentTimeMillis()
132 
133         // The test is over only if the capture call back has already been hit
134         // It is possible to be here before the callback is hit
135         if (0L != params.timer.captureEnd) {
136             if (TestType.MULTI_PHOTO_CHAIN == testConfig.currentRunningTest) {
137                 testEnded(activity, params, testConfig)
138             } else {
139                 logd("ImageSaver: photo saved, test is finished, closing the camera.")
140                 testConfig.testFinished = true
141                 closePreviewAndCamera(activity, params, testConfig)
142             }
143         }
144     }
145 }
146 
147 /** Rotate a given Bitmap by degrees */
rotateBitmapnull148 fun rotateBitmap(original: Bitmap, degrees: Float): Bitmap {
149     val matrix = Matrix()
150     matrix.postRotate(degrees)
151     return Bitmap.createBitmap(original, 0, 0, original.width, original.height, matrix, true)
152 }
153 
154 /** Scale a given Bitmap by scaleFactor */
scaleBitmapnull155 fun scaleBitmap(bitmap: Bitmap, scaleFactor: Float): Bitmap {
156     val scaledWidth = Math.round(bitmap.width * scaleFactor)
157     val scaledHeight = Math.round(bitmap.height * scaleFactor)
158 
159     return Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true)
160 }
161 
162 /** Flip a Bitmap horizontal */
horizontalFlipnull163 fun horizontalFlip(bitmap: Bitmap): Bitmap {
164     val matrix = Matrix()
165     matrix.preScale(-1.0f, 1.0f)
166     return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
167 }
168 
169 /** Generate a timestamp to append to saved filenames. */
generateTimestampnull170 fun generateTimestamp(): String {
171     val sdf = SimpleDateFormat("yyyy_MM_dd_HH_mm_ss_SSS", Locale.US)
172     return sdf.format(Date())
173 }
174 
175 /** Actually write a byteArray file to disk. Assume the file is a jpg and use that extension */
writeFilenull176 fun writeFile(activity: MainActivity, bytes: ByteArray) {
177     if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
178         writeFileAfterQ(activity, bytes)
179     } else {
180         writeFileBeforeQ(activity, bytes)
181     }
182 }
183 
184 /**
185  * When the platform is Android Pie and Pie below, Environment.getExternalStoragePublicDirectory
186  * (Environment.DIRECTORY_DOCUMENTS) can work. For Q, set requestLegacyExternalStorage = true to
187  * make it workable. Ref:
188  * https://developer.android.com/training/data-storage/use-cases#opt-out-scoped-storage
189  */
writeFileBeforeQnull190 fun writeFileBeforeQ(activity: MainActivity, bytes: ByteArray) {
191     val jpgFile =
192         File(
193             Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM),
194             File.separatorChar +
195                 PHOTOS_DIR +
196                 File.separatorChar +
197                 "Antelope" +
198                 generateTimestamp() +
199                 ".jpg"
200         )
201 
202     val photosDir =
203         File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), PHOTOS_DIR)
204 
205     if (!photosDir.exists()) {
206         val createSuccess = photosDir.mkdir()
207         if (!createSuccess) {
208             activity.runOnUiThread {
209                 Toast.makeText(
210                         activity,
211                         "DCIM/" + PHOTOS_DIR + " creation failed.",
212                         Toast.LENGTH_SHORT
213                     )
214                     .show()
215             }
216             logd("Photo storage directory DCIM/" + PHOTOS_DIR + " creation failed!!")
217         } else {
218             logd("Photo storage directory DCIM/" + PHOTOS_DIR + " did not exist. Created.")
219         }
220     }
221 
222     var output: FileOutputStream? = null
223     try {
224         output = FileOutputStream(jpgFile)
225         output.write(bytes)
226     } catch (e: IOException) {
227         e.printStackTrace()
228     } finally {
229         if (null != output) {
230             try {
231                 output.close()
232 
233                 if (!PrefHelper.getAutoDelete(activity)) {
234                     // File is written, let media scanner know
235                     val scannerIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
236                     scannerIntent.data = Uri.fromFile(jpgFile)
237                     activity.sendBroadcast(scannerIntent)
238 
239                     // File is written, now delete it.
240                     // TODO: make sure this does not add extra latency
241                 } else {
242                     jpgFile.delete()
243                 }
244             } catch (e: IOException) {
245                 e.printStackTrace()
246             }
247         }
248     }
249     logd("writeFile: Completed.")
250 }
251 
252 /**
253  * R and R above, change to use MediaStore to access the shared media files. Ref:
254  * https://developer.android.com/training/data-storage/shared
255  */
writeFileAfterQnull256 fun writeFileAfterQ(activity: MainActivity, bytes: ByteArray) {
257     val resolver: ContentResolver = activity.contentResolver
258     val contentValues =
259         ContentValues().apply {
260             put(MediaStore.MediaColumns.DISPLAY_NAME, generateTimestamp().toString() + ".jpg")
261             put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
262             put(MediaStore.MediaColumns.RELATIVE_PATH, PHOTOS_PATH)
263         }
264 
265     val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
266     if (imageUri != null) {
267         val output = activity.contentResolver.openOutputStream(imageUri)
268         try {
269             output?.write(bytes)
270         } catch (e: IOException) {
271             e.printStackTrace()
272         } finally {
273             if (null != output) {
274                 try {
275                     output.close()
276                 } catch (e: IOException) {
277                     e.printStackTrace()
278                 }
279             }
280         }
281         logd("writeFile: Completed.")
282         if (PrefHelper.getAutoDelete(activity)) {
283             val result = resolver.delete(imageUri, null, null)
284             if (result > 0) {
285                 logd("Delete image $imageUri completed.")
286             }
287         }
288     } else {
289         activity.runOnUiThread {
290             Toast.makeText(activity, "Image file creation failed.", Toast.LENGTH_SHORT).show()
291         }
292     }
293 }
294 
295 /** Delete all the photos generated by testing */
deleteTestPhotosnull296 fun deleteTestPhotos(activity: MainActivity) {
297     if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
298         deleteTestPhotosAfterQ(activity)
299     } else {
300         deleteTestPhotosBeforeQ(activity)
301     }
302 
303     activity.runOnUiThread {
304         Toast.makeText(activity, "All test photos deleted", Toast.LENGTH_SHORT).show()
305     }
306     logd("All photos in storage directory DCIM/" + PHOTOS_DIR + " deleted.")
307 }
308 
309 /**
310  * When the platform is Android Pie and Pie below, Environment.getExternalStoragePublicDirectory
311  * (Environment.DIRECTORY_DOCUMENTS) can work. For Q, set requestLegacyExternalStorage = true to
312  * make it workable. Ref:
313  * https://developer.android.com/training/data-storage/use-cases#opt-out-scoped-storage
314  */
deleteTestPhotosBeforeQnull315 fun deleteTestPhotosBeforeQ(activity: MainActivity) {
316     val photosDir =
317         File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), PHOTOS_DIR)
318 
319     if (photosDir.exists()) {
320 
321         for (photo in photosDir.listFiles()!!) photo.delete()
322 
323         // Files are deleted, let media scanner know
324         val scannerIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
325         scannerIntent.data = Uri.fromFile(photosDir)
326         activity.sendBroadcast(scannerIntent)
327     }
328 }
329 
330 /**
331  * R and R above, change to use MediaStore to delete the photo files. Ref:
332  * https://developer.android.com/training/data-storage/shared
333  */
deleteTestPhotosAfterQnull334 fun deleteTestPhotosAfterQ(activity: MainActivity) {
335     val imageDirUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
336     val resolver: ContentResolver = activity.contentResolver
337     val selection = MediaStore.MediaColumns.RELATIVE_PATH + " like ?"
338     val selectionArgs = arrayOf("%$PHOTOS_PATH%")
339 
340     resolver.delete(imageDirUri, selection, selectionArgs)
341 }
342 
343 /**
344  * Try to detect if a saved image file has had HDR effects applied to it by examining the EXIF tag.
345  *
346  * Note: this does not currently work.
347  */
348 @RequiresApi(24)
isHDRPlusnull349 fun isHDRPlus(bytes: ByteArray?): Boolean {
350     if (24 <= Build.VERSION.SDK_INT) {
351         val bytestream = ByteArrayInputStream(bytes)
352         val exif = ExifInterface(bytestream)
353         val software: String = exif.getAttribute(ExifInterface.TAG_SOFTWARE) ?: ""
354         val makernote: String = exif.getAttribute(ExifInterface.TAG_MAKER_NOTE) ?: ""
355         logd("In isHDRPlus, software: " + software + ", makernote: " + makernote)
356         if (software.contains("HDR+") || makernote.contains("HDRP")) {
357             logd("Photo is HDR+: " + software + ", " + makernote)
358             return true
359         }
360     }
361     return false
362 }
363 
364 /**
365  * ImageReader listener for use with Camera X API.
366  *
367  * Extract image and write to disk
368  */
369 class CameraXImageAvailableListener(
370     internal val activity: MainActivity,
371     internal var params: CameraParams,
372     internal val testConfig: TestConfig
373 ) : ImageCapture.OnImageCapturedCallback() {
374 
375     /** Image was captured successfully */
onCaptureSuccessnull376     override fun onCaptureSuccess(image: ImageProxy) {
377         logd(
378             "CameraXImageAvailableListener onCaptureSuccess. Current test: " +
379                 testConfig.currentRunningTest
380         )
381 
382         when (image.format) {
383             ImageFormat.JPEG -> {
384                 params.timer.imageReaderEnd = System.currentTimeMillis()
385 
386                 // TODO As of CameraX 0.3.0 has a bug so capture session callbacks are never called.
387                 // As a workaround for now we use this onCaptureSuccess callback as the only measure
388                 // of capture timing (as opposed to capture callback + image ready for camera2
389                 // Remove these lines when bug is fixed
390                 // /////////////////////////////////////////////////////////////////////////////////
391                 params.timer.captureEnd = System.currentTimeMillis()
392 
393                 params.timer.imageReaderStart = System.currentTimeMillis()
394                 params.timer.imageReaderEnd = System.currentTimeMillis()
395 
396                 // End Remove lines ////////////////////////////////////////////////////////////////
397 
398                 // Orientation
399                 val rotation = activity.windowManager.defaultDisplay.rotation
400                 val capturedImageRotation = getOrientation(params, rotation)
401 
402                 params.timer.imageSaveStart = System.currentTimeMillis()
403 
404                 val bytes = ByteArray(image.planes[0].buffer.remaining())
405                 image.planes[0].buffer.get(bytes)
406 
407                 params.backgroundHandler?.post(
408                     ImageSaver(
409                         activity,
410                         bytes,
411                         capturedImageRotation,
412                         params.isFront,
413                         params,
414                         testConfig
415                     )
416                 )
417             }
418             ImageFormat.RAW_SENSOR -> {}
419             else -> {}
420         }
421 
422         image.close()
423     }
424 
425     /** Camera X was unable to capture a still image and threw an error */
onErrornull426     override fun onError(exception: ImageCaptureException) {
427         logd("CameraX ImageCallback onError. Error: " + exception.message)
428         params.timer.imageReaderEnd = System.currentTimeMillis()
429         params.timer.imageSaveStart = System.currentTimeMillis()
430         params.timer.imageSaveEnd = System.currentTimeMillis()
431 
432         // The test is over only if the capture call back has already been hit
433         // It is possible to be here before the callback is hit
434         if (0L != params.timer.captureEnd) {
435             if (TestType.MULTI_PHOTO_CHAIN == testConfig.currentRunningTest) {
436                 testEnded(activity, params, testConfig)
437             } else {
438                 testConfig.testFinished = true
439                 closeCameraX(activity, params, testConfig)
440             }
441         }
442     }
443 }
444