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