mirror of
https://github.com/SimpleMobileTools/Simple-Camera.git
synced 2025-01-31 15:34:48 +01:00
properly play media sound on image capture
- instead of using the OnImageSavedCallback, we now use the OnImageCaptureCallback and we play the sound in the onCapturedSuccess method to prevent the user from hearing the capture sound before the actual capture takes place - add ImageSaver and ImageUtil based on CameraX implementation - add sealed interfaces ImageCaptureOutput and VideoCaptureOutput to prevent confusion on the supported output format for images and video - ImageSaver supports saving images with all MediaOutput.ImageCaptureOutput that can be saved to disk; MediaStoreOutput, OutputStreamMediaOutput and FileMediaOutput (unused at the moment), it throws an UnsupportedOperation when trying to save a BitmapOutput. - with ImageSaver, we now have control of whether the Exif attributes are written to disk and so we can get rid of ExifRemover - delete the extension method ImageProxy.toJpegByteArray as we have a similar method in ImageUtils class
This commit is contained in:
parent
9536995b79
commit
cc3c2108cf
@ -65,7 +65,7 @@ android {
|
||||
dependencies {
|
||||
implementation 'com.github.SimpleMobileTools:Simple-Commons:2e9ca234a7'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation "androidx.exifinterface:exifinterface:1.3.3"
|
||||
implementation "androidx.exifinterface:exifinterface:1.3.4"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"
|
||||
implementation 'androidx.window:window:1.1.0-alpha03'
|
||||
|
||||
|
@ -1,11 +0,0 @@
|
||||
package com.simplemobiletools.camera.extensions
|
||||
|
||||
import androidx.camera.core.ImageProxy
|
||||
|
||||
// Only for JPEG format
|
||||
fun ImageProxy.toJpegByteArray(): ByteArray {
|
||||
val buffer = planes.first().buffer
|
||||
val jpegImageData = ByteArray(buffer.remaining())
|
||||
buffer[jpegImageData]
|
||||
return jpegImageData
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
package com.simplemobiletools.camera.helpers
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.simplemobiletools.commons.extensions.removeValues
|
||||
import java.io.IOException
|
||||
|
||||
class ExifRemover(private val contentResolver: ContentResolver) {
|
||||
companion object {
|
||||
private const val MODE = "rw"
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun removeExif(uri: Uri) {
|
||||
try {
|
||||
val fileDescriptor = contentResolver.openFileDescriptor(uri, MODE)
|
||||
if (fileDescriptor != null) {
|
||||
val exifInterface = ExifInterface(fileDescriptor.fileDescriptor)
|
||||
exifInterface.removeValues()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,304 @@
|
||||
package com.simplemobiletools.camera.helpers
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCapture.Metadata
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.internal.compat.workaround.ExifRotationAvailability
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.simplemobiletools.camera.helpers.ImageUtil.CodecFailedException
|
||||
import com.simplemobiletools.camera.helpers.ImageUtil.imageToJpegByteArray
|
||||
import com.simplemobiletools.camera.helpers.ImageUtil.jpegImageToJpegByteArray
|
||||
import com.simplemobiletools.camera.models.MediaOutput
|
||||
import com.simplemobiletools.commons.extensions.copyTo
|
||||
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
|
||||
import com.simplemobiletools.commons.helpers.isQPlus
|
||||
import java.io.*
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
/**
|
||||
* Inspired by
|
||||
* @see androidx.camera.core.ImageSaver
|
||||
* */
|
||||
class ImageSaver private constructor(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val image: ImageProxy,
|
||||
private val mediaOutput: MediaOutput.ImageCaptureOutput,
|
||||
private val metadata: Metadata,
|
||||
private val jpegQuality: Int,
|
||||
private val saveExifAttributes: Boolean,
|
||||
private val onImageSaved: (Uri) -> Unit,
|
||||
private val onError: (ImageCaptureException) -> Unit,
|
||||
) {
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TEMP_FILE_PREFIX = "SimpleCamera"
|
||||
private const val TEMP_FILE_SUFFIX = ".tmp"
|
||||
private const val COPY_BUFFER_SIZE = 1024
|
||||
private const val PENDING = 1
|
||||
private const val NOT_PENDING = 0
|
||||
|
||||
fun saveImage(
|
||||
contentResolver: ContentResolver,
|
||||
image: ImageProxy,
|
||||
mediaOutput: MediaOutput.ImageCaptureOutput,
|
||||
metadata: Metadata,
|
||||
jpegQuality: Int,
|
||||
saveExifAttributes: Boolean,
|
||||
onImageSaved: (Uri) -> Unit,
|
||||
onError: (ImageCaptureException) -> Unit,
|
||||
) = ImageSaver(
|
||||
contentResolver = contentResolver,
|
||||
image = image,
|
||||
mediaOutput = mediaOutput,
|
||||
metadata = metadata,
|
||||
jpegQuality = jpegQuality,
|
||||
saveExifAttributes = saveExifAttributes,
|
||||
onImageSaved = onImageSaved,
|
||||
onError = onError,
|
||||
).saveImage()
|
||||
}
|
||||
|
||||
|
||||
fun saveImage() {
|
||||
ensureBackgroundThread {
|
||||
// Save the image to a temp file first. This is necessary because ExifInterface only
|
||||
// supports saving to File.
|
||||
val tempFile = saveImageToTempFile()
|
||||
if (tempFile != null) {
|
||||
copyTempFileToDestination(tempFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun saveImageToTempFile(): File? {
|
||||
var saveError: SaveError? = null
|
||||
var errorMessage: String? = null
|
||||
var exception: Exception? = null
|
||||
|
||||
val tempFile = try {
|
||||
if (mediaOutput is MediaOutput.FileMediaOutput) {
|
||||
// For saving to file, write to the target folder and rename for better performance.
|
||||
File(
|
||||
mediaOutput.file.parent,
|
||||
TEMP_FILE_PREFIX + UUID.randomUUID().toString() + TEMP_FILE_SUFFIX
|
||||
)
|
||||
} else {
|
||||
File.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX)
|
||||
}
|
||||
|
||||
} catch (e: IOException) {
|
||||
postError(SaveError.FILE_IO_FAILED, "Error saving temp file", e)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val output = FileOutputStream(tempFile)
|
||||
val byteArray: ByteArray = imageToJpegByteArray(image, jpegQuality)
|
||||
output.write(byteArray)
|
||||
|
||||
if (saveExifAttributes) {
|
||||
val exifInterface = ExifInterface(tempFile)
|
||||
val imageByteArray = jpegImageToJpegByteArray(image)
|
||||
val inputStream: InputStream = ByteArrayInputStream(imageByteArray)
|
||||
ExifInterface(inputStream).copyTo(exifInterface)
|
||||
|
||||
// Overwrite the original orientation if the quirk exists.
|
||||
if (!ExifRotationAvailability().shouldUseExifOrientation(image)) {
|
||||
exifInterface.rotate(image.imageInfo.rotationDegrees)
|
||||
}
|
||||
|
||||
if (metadata.isReversedHorizontal) {
|
||||
exifInterface.flipHorizontally()
|
||||
}
|
||||
if (metadata.isReversedVertical) {
|
||||
exifInterface.flipVertically()
|
||||
}
|
||||
if (metadata.location != null) {
|
||||
exifInterface.setGpsInfo(metadata.location)
|
||||
}
|
||||
|
||||
exifInterface.saveAttributes()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
saveError = SaveError.FILE_IO_FAILED
|
||||
errorMessage = "Failed to write temp file"
|
||||
exception = e
|
||||
} catch (e: IllegalArgumentException) {
|
||||
saveError = SaveError.FILE_IO_FAILED
|
||||
errorMessage = "Failed to write temp file"
|
||||
exception = e
|
||||
} catch (e: CodecFailedException) {
|
||||
when (e.failureType) {
|
||||
CodecFailedException.FailureType.ENCODE_FAILED -> {
|
||||
saveError = SaveError.ENCODE_FAILED
|
||||
errorMessage = "Failed to encode Image"
|
||||
}
|
||||
CodecFailedException.FailureType.DECODE_FAILED -> {
|
||||
saveError = SaveError.CROP_FAILED
|
||||
errorMessage = "Failed to crop Image"
|
||||
}
|
||||
CodecFailedException.FailureType.UNKNOWN -> {
|
||||
saveError = SaveError.UNKNOWN
|
||||
errorMessage = "Failed to transcode Image"
|
||||
}
|
||||
}
|
||||
exception = e
|
||||
}
|
||||
|
||||
if (saveError != null) {
|
||||
postError(saveError, errorMessage, exception)
|
||||
tempFile.delete()
|
||||
return null
|
||||
}
|
||||
|
||||
return tempFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the temp file to user specified destination.
|
||||
*
|
||||
*
|
||||
* The temp file will be deleted afterwards.
|
||||
*/
|
||||
private fun copyTempFileToDestination(tempFile: File) {
|
||||
var saveError: SaveError? = null
|
||||
var errorMessage: String? = null
|
||||
var exception: java.lang.Exception? = null
|
||||
var outputUri: Uri? = null
|
||||
try {
|
||||
when (mediaOutput) {
|
||||
is MediaOutput.MediaStoreOutput -> {
|
||||
val values = mediaOutput.contentValues
|
||||
setContentValuePending(values, PENDING)
|
||||
outputUri = contentResolver.insert(
|
||||
mediaOutput.contentUri, values
|
||||
)
|
||||
if (outputUri == null) {
|
||||
saveError = SaveError.FILE_IO_FAILED
|
||||
errorMessage = "Failed to insert URI."
|
||||
} else {
|
||||
if (!copyTempFileToUri(tempFile, outputUri)) {
|
||||
saveError = SaveError.FILE_IO_FAILED
|
||||
errorMessage = "Failed to save to URI."
|
||||
}
|
||||
setUriNotPending(outputUri)
|
||||
}
|
||||
}
|
||||
|
||||
is MediaOutput.OutputStreamMediaOutput -> {
|
||||
copyTempFileToOutputStream(tempFile, mediaOutput.outputStream)
|
||||
outputUri = mediaOutput.uri
|
||||
}
|
||||
|
||||
is MediaOutput.FileMediaOutput -> {
|
||||
val targetFile: File = mediaOutput.file
|
||||
// Normally File#renameTo will overwrite the targetFile even if it already exists.
|
||||
// Just in case of unexpected behavior on certain platforms or devices, delete the
|
||||
// target file before renaming.
|
||||
if (targetFile.exists()) {
|
||||
targetFile.delete()
|
||||
}
|
||||
if (!tempFile.renameTo(targetFile)) {
|
||||
saveError = SaveError.FILE_IO_FAILED
|
||||
errorMessage = "Failed to rename file."
|
||||
}
|
||||
outputUri = Uri.fromFile(targetFile)
|
||||
}
|
||||
|
||||
MediaOutput.BitmapOutput -> throw UnsupportedOperationException("Bitmap output cannot be saved to disk")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
saveError = SaveError.FILE_IO_FAILED
|
||||
errorMessage = "Failed to write destination file."
|
||||
exception = e
|
||||
} catch (e: IllegalArgumentException) {
|
||||
saveError = SaveError.FILE_IO_FAILED
|
||||
errorMessage = "Failed to write destination file."
|
||||
exception = e
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
|
||||
outputUri?.let(onImageSaved) ?: postError(saveError!!, errorMessage, exception)
|
||||
}
|
||||
|
||||
private fun postError(saveError: SaveError, errorMessage: String?, exception: Exception?) {
|
||||
val imageCaptureError = if (saveError == SaveError.FILE_IO_FAILED) {
|
||||
ImageCapture.ERROR_FILE_IO
|
||||
} else {
|
||||
ImageCapture.ERROR_UNKNOWN
|
||||
}
|
||||
|
||||
onError.invoke(ImageCaptureException(imageCaptureError, errorMessage!!, exception!!))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes IS_PENDING flag during the writing to [Uri].
|
||||
*/
|
||||
private fun setUriNotPending(outputUri: Uri) {
|
||||
if (isQPlus()) {
|
||||
val values = ContentValues()
|
||||
setContentValuePending(values, NOT_PENDING)
|
||||
contentResolver.update(outputUri, values, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Set IS_PENDING flag to [ContentValues]. */
|
||||
private fun setContentValuePending(values: ContentValues, isPending: Int) {
|
||||
if (isQPlus()) {
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, isPending)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies temp file to [Uri].
|
||||
*
|
||||
* @return false if the [Uri] is not writable.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
private fun copyTempFileToUri(tempFile: File, uri: Uri): Boolean {
|
||||
contentResolver.openOutputStream(uri).use { outputStream ->
|
||||
if (outputStream == null) {
|
||||
// The URI is not writable.
|
||||
return false
|
||||
}
|
||||
copyTempFileToOutputStream(tempFile, outputStream)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun copyTempFileToOutputStream(
|
||||
tempFile: File, outputStream: OutputStream
|
||||
) {
|
||||
FileInputStream(tempFile).use { inputStream ->
|
||||
val buf = ByteArray(COPY_BUFFER_SIZE)
|
||||
var len: Int
|
||||
while (inputStream.read(buf).also { len = it } > 0) {
|
||||
outputStream.write(buf, 0, len)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Type of error that occurred during save */
|
||||
enum class SaveError {
|
||||
/** Failed to write to or close the file */
|
||||
FILE_IO_FAILED,
|
||||
|
||||
/** Failure when attempting to encode image */
|
||||
ENCODE_FAILED,
|
||||
|
||||
/** Failure when attempting to crop image */
|
||||
CROP_FAILED, UNKNOWN
|
||||
}
|
||||
}
|
@ -0,0 +1,232 @@
|
||||
package com.simplemobiletools.camera.helpers
|
||||
|
||||
import android.graphics.*
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.camera.core.ImageProxy
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Utility class for image related operations.
|
||||
* @see androidx.camera.core.internal.utils.ImageUtil
|
||||
*/
|
||||
object ImageUtil {
|
||||
|
||||
@Throws(CodecFailedException::class)
|
||||
fun imageToJpegByteArray(image: ImageProxy, jpegQuality: Int): ByteArray {
|
||||
val shouldCropImage = shouldCropImage(image)
|
||||
return when (image.format) {
|
||||
ImageFormat.JPEG -> {
|
||||
if (!shouldCropImage) {
|
||||
// When cropping is unnecessary, the byte array doesn't need to be decoded and
|
||||
// re-encoded again. Therefore, jpegQuality is unnecessary in this case.
|
||||
jpegImageToJpegByteArray(image)
|
||||
} else {
|
||||
jpegImageToJpegByteArray(image, image.cropRect, jpegQuality)
|
||||
}
|
||||
}
|
||||
ImageFormat.YUV_420_888 -> {
|
||||
yuvImageToJpegByteArray(image, if (shouldCropImage) image.cropRect else null, jpegQuality)
|
||||
}
|
||||
else -> {
|
||||
// Unrecognized image format
|
||||
byteArrayOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts JPEG [ImageProxy] to JPEG byte array.
|
||||
*/
|
||||
fun jpegImageToJpegByteArray(image: ImageProxy): ByteArray {
|
||||
require(image.format == ImageFormat.JPEG) { "Incorrect image format of the input image proxy: " + image.format }
|
||||
val planes = image.planes
|
||||
val buffer = planes[0].buffer
|
||||
val data = ByteArray(buffer.capacity())
|
||||
buffer.rewind()
|
||||
buffer[data]
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts JPEG [ImageProxy] to JPEG byte array. The input JPEG image will be cropped
|
||||
* by the specified crop rectangle and compressed by the specified quality value.
|
||||
*/
|
||||
@Throws(CodecFailedException::class)
|
||||
private fun jpegImageToJpegByteArray(
|
||||
image: ImageProxy,
|
||||
cropRect: Rect,
|
||||
@IntRange(from = 1, to = 100) jpegQuality: Int,
|
||||
): ByteArray {
|
||||
require(image.format == ImageFormat.JPEG) { "Incorrect image format of the input image proxy: " + image.format }
|
||||
var data = jpegImageToJpegByteArray(image)
|
||||
data = cropJpegByteArray(data, cropRect, jpegQuality)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts YUV_420_888 [ImageProxy] to JPEG byte array. The input YUV_420_888 image
|
||||
* will be cropped if a non-null crop rectangle is specified. The output JPEG byte array will
|
||||
* be compressed by the specified quality value.
|
||||
*/
|
||||
@Throws(CodecFailedException::class)
|
||||
private fun yuvImageToJpegByteArray(
|
||||
image: ImageProxy,
|
||||
cropRect: Rect?,
|
||||
@IntRange(from = 1, to = 100) jpegQuality: Int,
|
||||
): ByteArray {
|
||||
require(image.format == ImageFormat.YUV_420_888) { "Incorrect image format of the input image proxy: " + image.format }
|
||||
return nv21ToJpeg(
|
||||
yuv_420_888toNv21(image),
|
||||
image.width,
|
||||
image.height,
|
||||
cropRect,
|
||||
jpegQuality
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [android.media.Image] to NV21 byte array.
|
||||
*/
|
||||
private fun yuv_420_888toNv21(image: ImageProxy): ByteArray {
|
||||
val yPlane = image.planes[0]
|
||||
val uPlane = image.planes[1]
|
||||
val vPlane = image.planes[2]
|
||||
val yBuffer = yPlane.buffer
|
||||
val uBuffer = uPlane.buffer
|
||||
val vBuffer = vPlane.buffer
|
||||
yBuffer.rewind()
|
||||
uBuffer.rewind()
|
||||
vBuffer.rewind()
|
||||
val ySize = yBuffer.remaining()
|
||||
var position = 0
|
||||
val nv21 = ByteArray(ySize + image.width * image.height / 2)
|
||||
|
||||
// Add the full y buffer to the array. If rowStride > 1, some padding may be skipped.
|
||||
for (row in 0 until image.height) {
|
||||
yBuffer[nv21, position, image.width]
|
||||
position += image.width
|
||||
yBuffer.position(
|
||||
Math.min(ySize, yBuffer.position() - image.width + yPlane.rowStride)
|
||||
)
|
||||
}
|
||||
val chromaHeight = image.height / 2
|
||||
val chromaWidth = image.width / 2
|
||||
val vRowStride = vPlane.rowStride
|
||||
val uRowStride = uPlane.rowStride
|
||||
val vPixelStride = vPlane.pixelStride
|
||||
val uPixelStride = uPlane.pixelStride
|
||||
|
||||
// Interleave the u and v frames, filling up the rest of the buffer. Use two line buffers to
|
||||
// perform faster bulk gets from the byte buffers.
|
||||
val vLineBuffer = ByteArray(vRowStride)
|
||||
val uLineBuffer = ByteArray(uRowStride)
|
||||
for (row in 0 until chromaHeight) {
|
||||
vBuffer[vLineBuffer, 0, Math.min(vRowStride, vBuffer.remaining())]
|
||||
uBuffer[uLineBuffer, 0, Math.min(uRowStride, uBuffer.remaining())]
|
||||
var vLineBufferPosition = 0
|
||||
var uLineBufferPosition = 0
|
||||
for (col in 0 until chromaWidth) {
|
||||
nv21[position++] = vLineBuffer[vLineBufferPosition]
|
||||
nv21[position++] = uLineBuffer[uLineBufferPosition]
|
||||
vLineBufferPosition += vPixelStride
|
||||
uLineBufferPosition += uPixelStride
|
||||
}
|
||||
}
|
||||
return nv21
|
||||
}
|
||||
|
||||
/**
|
||||
* Crops JPEG byte array with given [android.graphics.Rect].
|
||||
*/
|
||||
@Throws(CodecFailedException::class)
|
||||
private fun cropJpegByteArray(
|
||||
data: ByteArray,
|
||||
cropRect: Rect,
|
||||
@IntRange(from = 1, to = 100) jpegQuality: Int,
|
||||
): ByteArray {
|
||||
val bitmap: Bitmap
|
||||
try {
|
||||
val decoder = BitmapRegionDecoder.newInstance(
|
||||
data, 0, data.size,
|
||||
false
|
||||
)
|
||||
bitmap = decoder.decodeRegion(cropRect, BitmapFactory.Options())
|
||||
decoder.recycle()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw CodecFailedException(
|
||||
"Decode byte array failed with illegal argument.$e",
|
||||
CodecFailedException.FailureType.DECODE_FAILED
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
throw CodecFailedException(
|
||||
"Decode byte array failed.",
|
||||
CodecFailedException.FailureType.DECODE_FAILED
|
||||
)
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
val success = bitmap.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
|
||||
if (!success) {
|
||||
throw CodecFailedException(
|
||||
"Encode bitmap failed.",
|
||||
CodecFailedException.FailureType.ENCODE_FAILED
|
||||
)
|
||||
}
|
||||
bitmap.recycle()
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
@Throws(CodecFailedException::class)
|
||||
private fun nv21ToJpeg(
|
||||
nv21: ByteArray,
|
||||
width: Int,
|
||||
height: Int,
|
||||
cropRect: Rect?,
|
||||
@IntRange(from = 1, to = 100) jpegQuality: Int,
|
||||
): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val yuv = YuvImage(nv21, ImageFormat.NV21, width, height, null)
|
||||
val success = yuv.compressToJpeg(
|
||||
cropRect ?: Rect(0, 0, width, height),
|
||||
jpegQuality, out
|
||||
)
|
||||
if (!success) {
|
||||
throw CodecFailedException(
|
||||
"YuvImage failed to encode jpeg.",
|
||||
CodecFailedException.FailureType.ENCODE_FAILED
|
||||
)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks whether the image's crop rectangle is the same as the source image size.
|
||||
*/
|
||||
private fun shouldCropImage(image: ImageProxy): Boolean {
|
||||
return shouldCropImage(
|
||||
image.width, image.height, image.cropRect.width(),
|
||||
image.cropRect.height()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the image's crop rectangle is the same as the source image size.
|
||||
*/
|
||||
private fun shouldCropImage(
|
||||
sourceWidth: Int, sourceHeight: Int, cropRectWidth: Int,
|
||||
cropRectHeight: Int
|
||||
): Boolean {
|
||||
return sourceWidth != cropRectWidth || sourceHeight != cropRectHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for error during transcoding image.
|
||||
*/
|
||||
class CodecFailedException internal constructor(message: String, val failureType: FailureType) : Exception(message) {
|
||||
enum class FailureType {
|
||||
ENCODE_FAILED, DECODE_FAILED, UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
@ -33,7 +33,7 @@ class MediaOutputHelper(
|
||||
private val mediaStorageDir = activity.config.savePhotosFolder
|
||||
private val contentResolver = activity.contentResolver
|
||||
|
||||
fun getImageMediaOutput(): MediaOutput {
|
||||
fun getImageMediaOutput(): MediaOutput.ImageCaptureOutput {
|
||||
return try {
|
||||
if (is3rdPartyIntent) {
|
||||
if (outputUri != null) {
|
||||
@ -56,7 +56,7 @@ class MediaOutputHelper(
|
||||
}
|
||||
}
|
||||
|
||||
fun getVideoMediaOutput(): MediaOutput {
|
||||
fun getVideoMediaOutput(): MediaOutput.VideoCaptureOutput {
|
||||
return try {
|
||||
if (is3rdPartyIntent) {
|
||||
if (outputUri != null) {
|
||||
|
@ -57,7 +57,6 @@ class CameraXPreview(
|
||||
private val windowMetricsCalculator = WindowMetricsCalculator.getOrCreate()
|
||||
private val videoQualityManager = VideoQualityManager(activity)
|
||||
private val imageQualityManager = ImageQualityManager(activity)
|
||||
private val exifRemover = ExifRemover(contentResolver)
|
||||
private val mediaSizeStore = MediaSizeStore(config)
|
||||
|
||||
private val orientationEventListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_NORMAL) {
|
||||
@ -422,12 +421,14 @@ class CameraXPreview(
|
||||
}
|
||||
|
||||
val mediaOutput = mediaOutputHelper.getImageMediaOutput()
|
||||
if (mediaOutput is MediaOutput.BitmapOutput) {
|
||||
imageCapture.takePicture(mainExecutor, object : OnImageCapturedCallback() {
|
||||
override fun onCaptureSuccess(image: ImageProxy) {
|
||||
ensureBackgroundThread {
|
||||
image.use {
|
||||
val bitmap = BitmapUtils.makeBitmap(image.toJpegByteArray())
|
||||
imageCapture.takePicture(mainExecutor, object : OnImageCapturedCallback() {
|
||||
override fun onCaptureSuccess(image: ImageProxy) {
|
||||
playShutterSoundIfEnabled()
|
||||
ensureBackgroundThread {
|
||||
image.use {
|
||||
if (mediaOutput is MediaOutput.BitmapOutput) {
|
||||
val imageBytes = ImageUtil.jpegImageToJpegByteArray(image)
|
||||
val bitmap = BitmapUtils.makeBitmap(imageBytes)
|
||||
activity.runOnUiThread {
|
||||
listener.toggleBottomButtons(false)
|
||||
if (bitmap != null) {
|
||||
@ -436,44 +437,31 @@ class CameraXPreview(
|
||||
cameraErrorHandler.handleImageCaptureError(ERROR_CAPTURE_FAILED)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ImageSaver.saveImage(
|
||||
contentResolver = contentResolver,
|
||||
image = image,
|
||||
mediaOutput = mediaOutput,
|
||||
metadata = metadata,
|
||||
jpegQuality = config.photoQuality,
|
||||
saveExifAttributes = config.savePhotoMetadata,
|
||||
onImageSaved = { savedUri ->
|
||||
activity.runOnUiThread {
|
||||
listener.toggleBottomButtons(false)
|
||||
listener.onMediaSaved(savedUri)
|
||||
}
|
||||
},
|
||||
onError = ::handleImageCaptureError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
handleImageCaptureError(exception)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
val outputOptionsBuilder = when (mediaOutput) {
|
||||
is MediaOutput.MediaStoreOutput -> OutputFileOptions.Builder(contentResolver, mediaOutput.contentUri, mediaOutput.contentValues)
|
||||
is MediaOutput.OutputStreamMediaOutput -> OutputFileOptions.Builder(mediaOutput.outputStream)
|
||||
else -> throw IllegalArgumentException("Unexpected option for image ")
|
||||
}
|
||||
|
||||
val outputOptions = outputOptionsBuilder.setMetadata(metadata).build()
|
||||
|
||||
imageCapture.takePicture(outputOptions, mainExecutor, object : OnImageSavedCallback {
|
||||
override fun onImageSaved(outputFileResults: OutputFileResults) {
|
||||
ensureBackgroundThread {
|
||||
val savedUri = mediaOutput.uri ?: outputFileResults.savedUri!!
|
||||
if (!config.savePhotoMetadata) {
|
||||
exifRemover.removeExif(savedUri)
|
||||
}
|
||||
|
||||
activity.runOnUiThread {
|
||||
listener.toggleBottomButtons(false)
|
||||
listener.onMediaSaved(savedUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
handleImageCaptureError(exception)
|
||||
}
|
||||
})
|
||||
}
|
||||
playShutterSoundIfEnabled()
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
handleImageCaptureError(exception)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun handleImageCaptureError(exception: ImageCaptureException) {
|
||||
@ -518,7 +506,6 @@ class CameraXPreview(
|
||||
MediaStoreOutputOptions.Builder(contentResolver, mediaOutput.contentUri).setContentValues(mediaOutput.contentValues).build()
|
||||
.let { videoCapture.output.prepareRecording(activity, it) }
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unexpected output option for video $mediaOutput")
|
||||
}
|
||||
|
||||
currentRecording = recording.withAudioEnabled()
|
||||
@ -540,7 +527,7 @@ class CameraXPreview(
|
||||
if (recordEvent.hasError()) {
|
||||
cameraErrorHandler.handleVideoRecordingError(recordEvent.error)
|
||||
} else {
|
||||
listener.onMediaSaved(mediaOutput.uri ?: recordEvent.outputResults.outputUri)
|
||||
listener.onMediaSaved(recordEvent.outputResults.outputUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,25 +9,28 @@ import java.io.OutputStream
|
||||
sealed class MediaOutput(
|
||||
open val uri: Uri?,
|
||||
) {
|
||||
sealed interface ImageCaptureOutput
|
||||
sealed interface VideoCaptureOutput
|
||||
|
||||
data class MediaStoreOutput(
|
||||
val contentValues: ContentValues,
|
||||
val contentUri: Uri,
|
||||
) : MediaOutput(null)
|
||||
) : MediaOutput(null), ImageCaptureOutput, VideoCaptureOutput
|
||||
|
||||
data class OutputStreamMediaOutput(
|
||||
val outputStream: OutputStream,
|
||||
override val uri: Uri,
|
||||
) : MediaOutput(uri)
|
||||
) : MediaOutput(uri), ImageCaptureOutput
|
||||
|
||||
data class FileDescriptorMediaOutput(
|
||||
val fileDescriptor: ParcelFileDescriptor,
|
||||
override val uri: Uri,
|
||||
) : MediaOutput(uri)
|
||||
) : MediaOutput(uri), VideoCaptureOutput
|
||||
|
||||
data class FileMediaOutput(
|
||||
val file: File,
|
||||
override val uri: Uri,
|
||||
) : MediaOutput(uri)
|
||||
) : MediaOutput(uri), VideoCaptureOutput, ImageCaptureOutput
|
||||
|
||||
object BitmapOutput : MediaOutput(null)
|
||||
object BitmapOutput : MediaOutput(null), ImageCaptureOutput
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user