Merge pull request #320 from KryptKode/feat/camera-x

Feat/camera x
This commit is contained in:
Tibor Kaputa 2022-06-30 21:37:04 +02:00 committed by GitHub
commit 198faec306
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 479 additions and 115 deletions

View File

@ -8,6 +8,7 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.util.Size
import android.view.KeyEvent import android.view.KeyEvent
import android.view.OrientationEventListener import android.view.OrientationEventListener
import android.view.View import android.view.View
@ -27,7 +28,7 @@ import com.simplemobiletools.camera.helpers.ORIENT_LANDSCAPE_LEFT
import com.simplemobiletools.camera.helpers.ORIENT_LANDSCAPE_RIGHT import com.simplemobiletools.camera.helpers.ORIENT_LANDSCAPE_RIGHT
import com.simplemobiletools.camera.helpers.ORIENT_PORTRAIT import com.simplemobiletools.camera.helpers.ORIENT_PORTRAIT
import com.simplemobiletools.camera.helpers.PhotoProcessor import com.simplemobiletools.camera.helpers.PhotoProcessor
import com.simplemobiletools.camera.implementations.CameraXPreview import com.simplemobiletools.camera.implementations.CameraXInitializer
import com.simplemobiletools.camera.implementations.CameraXPreviewListener import com.simplemobiletools.camera.implementations.CameraXPreviewListener
import com.simplemobiletools.camera.implementations.MyCameraImpl import com.simplemobiletools.camera.implementations.MyCameraImpl
import com.simplemobiletools.camera.interfaces.MyPreview import com.simplemobiletools.camera.interfaces.MyPreview
@ -38,6 +39,7 @@ import com.simplemobiletools.commons.helpers.PERMISSION_CAMERA
import com.simplemobiletools.commons.helpers.PERMISSION_RECORD_AUDIO import com.simplemobiletools.commons.helpers.PERMISSION_RECORD_AUDIO
import com.simplemobiletools.commons.helpers.PERMISSION_WRITE_STORAGE import com.simplemobiletools.commons.helpers.PERMISSION_WRITE_STORAGE
import com.simplemobiletools.commons.helpers.REFRESH_PATH import com.simplemobiletools.commons.helpers.REFRESH_PATH
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.commons.models.Release import com.simplemobiletools.commons.models.Release
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.android.synthetic.main.activity_main.btn_holder import kotlinx.android.synthetic.main.activity_main.btn_holder
@ -50,7 +52,7 @@ import kotlinx.android.synthetic.main.activity_main.toggle_camera
import kotlinx.android.synthetic.main.activity_main.toggle_flash import kotlinx.android.synthetic.main.activity_main.toggle_flash
import kotlinx.android.synthetic.main.activity_main.toggle_photo_video import kotlinx.android.synthetic.main.activity_main.toggle_photo_video
import kotlinx.android.synthetic.main.activity_main.video_rec_curr_timer import kotlinx.android.synthetic.main.activity_main.video_rec_curr_timer
import kotlinx.android.synthetic.main.activity_main.view_finder import kotlinx.android.synthetic.main.activity_main.preview_view
import kotlinx.android.synthetic.main.activity_main.view_holder import kotlinx.android.synthetic.main.activity_main.view_holder
class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, CameraXPreviewListener { class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, CameraXPreviewListener {
@ -68,7 +70,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
private var mPreviewUri: Uri? = null private var mPreviewUri: Uri? = null
private var mIsInPhotoMode = false private var mIsInPhotoMode = false
private var mIsCameraAvailable = false private var mIsCameraAvailable = false
private var mIsVideoCaptureIntent = false
private var mIsHardwareShutterHandled = false private var mIsHardwareShutterHandled = false
private var mCurrVideoRecTimer = 0 private var mCurrVideoRecTimer = 0
var mLastHandledOrientation = 0 var mLastHandledOrientation = 0
@ -101,7 +102,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
scheduleFadeOut() scheduleFadeOut()
mFocusCircleView.setStrokeColor(getProperPrimaryColor()) mFocusCircleView.setStrokeColor(getProperPrimaryColor())
if (mIsVideoCaptureIntent && mIsInPhotoMode) { if (isVideoCaptureIntent() && mIsInPhotoMode) {
handleTogglePhotoVideo() handleTogglePhotoVideo()
checkButtons() checkButtons()
} }
@ -132,9 +133,17 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
} }
private fun initVariables() { private fun initVariables() {
mIsInPhotoMode = config.initPhotoMode mIsInPhotoMode = if (isVideoCaptureIntent()) {
Log.w(TAG, "initializeCamera: video capture")
false
} else if (isImageCaptureIntent()) {
Log.w(TAG, "initializeCamera: image capture mode")
true
} else {
config.initPhotoMode
}
Log.w(TAG, "initInPhotoMode = $mIsInPhotoMode")
mIsCameraAvailable = false mIsCameraAvailable = false
mIsVideoCaptureIntent = false
mIsHardwareShutterHandled = false mIsHardwareShutterHandled = false
mCurrVideoRecTimer = 0 mCurrVideoRecTimer = 0
mLastHandledOrientation = 0 mLastHandledOrientation = 0
@ -187,10 +196,13 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
} }
} }
private fun isImageCaptureIntent() = intent?.action == MediaStore.ACTION_IMAGE_CAPTURE || intent?.action == MediaStore.ACTION_IMAGE_CAPTURE_SECURE private fun isImageCaptureIntent(): Boolean = intent?.action == MediaStore.ACTION_IMAGE_CAPTURE || intent?.action == MediaStore.ACTION_IMAGE_CAPTURE_SECURE
private fun isVideoCaptureIntent(): Boolean = intent?.action == MediaStore.ACTION_VIDEO_CAPTURE
private fun checkImageCaptureIntent() { private fun checkImageCaptureIntent() {
if (isImageCaptureIntent()) { if (isImageCaptureIntent()) {
Log.i(TAG, "isImageCaptureIntent: ")
hideIntentButtons() hideIntentButtons()
val output = intent.extras?.get(MediaStore.EXTRA_OUTPUT) val output = intent.extras?.get(MediaStore.EXTRA_OUTPUT)
if (output != null && output is Uri) { if (output != null && output is Uri) {
@ -201,7 +213,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
private fun checkVideoCaptureIntent() { private fun checkVideoCaptureIntent() {
if (intent?.action == MediaStore.ACTION_VIDEO_CAPTURE) { if (intent?.action == MediaStore.ACTION_VIDEO_CAPTURE) {
mIsVideoCaptureIntent = true Log.i(TAG, "checkVideoCaptureIntent: ")
mIsInPhotoMode = false mIsInPhotoMode = false
hideIntentButtons() hideIntentButtons()
shutter.setImageResource(R.drawable.ic_video_rec) shutter.setImageResource(R.drawable.ic_video_rec)
@ -220,7 +232,15 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
) )
checkVideoCaptureIntent() checkVideoCaptureIntent()
mPreview = CameraXPreview(this, view_finder, this) val outputUri = intent.extras?.get(MediaStore.EXTRA_OUTPUT) as? Uri
val is3rdPartyIntent = isVideoCaptureIntent() || isImageCaptureIntent()
mPreview = CameraXInitializer(this).createCameraXPreview(
preview_view,
listener = this,
outputUri = outputUri,
is3rdPartyIntent = is3rdPartyIntent,
initInPhotoMode = mIsInPhotoMode,
)
checkImageCaptureIntent() checkImageCaptureIntent()
mPreview?.setIsImageCaptureIntent(isImageCaptureIntent()) mPreview?.setIsImageCaptureIntent(isImageCaptureIntent())
@ -235,7 +255,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
mFadeHandler = Handler() mFadeHandler = Handler()
setupPreviewImage(true) setupPreviewImage(true)
val initialFlashlightState = config.flashlightState val initialFlashlightState = FLASH_OFF
mPreview!!.setFlashlightState(initialFlashlightState) mPreview!!.setFlashlightState(initialFlashlightState)
updateFlashlightState(initialFlashlightState) updateFlashlightState(initialFlashlightState)
} }
@ -312,7 +332,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
togglePhotoVideo() togglePhotoVideo()
} else { } else {
toast(R.string.no_audio_permissions) toast(R.string.no_audio_permissions)
if (mIsVideoCaptureIntent) { if (isVideoCaptureIntent()) {
finish() finish()
} }
} }
@ -324,7 +344,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
return return
} }
if (mIsVideoCaptureIntent) { if (isVideoCaptureIntent()) {
mPreview?.initVideoMode() mPreview?.initVideoMode()
} }
@ -356,7 +376,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
mPreview?.initVideoMode() mPreview?.initVideoMode()
initVideoButtons() initVideoButtons()
} catch (e: Exception) { } catch (e: Exception) {
if (!mIsVideoCaptureIntent) { if (!isVideoCaptureIntent()) {
toast(R.string.video_mode_error) toast(R.string.video_mode_error)
} }
} }
@ -544,6 +564,27 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
override fun onMediaCaptured(uri: Uri) { override fun onMediaCaptured(uri: Uri) {
loadLastTakenMedia(uri) loadLastTakenMedia(uri)
ensureBackgroundThread {
if (isImageCaptureIntent()) {
val bitmap = contentResolver.loadThumbnail(uri, Size(30, 30), null)
Intent().apply {
data = uri
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
putExtra("data", bitmap)
setResult(Activity.RESULT_OK, this)
}
Log.w(TAG, "onMediaCaptured: exiting uri=$uri")
finish()
} else if (isVideoCaptureIntent()) {
Intent().apply {
data = uri
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
setResult(Activity.RESULT_OK, this)
}
Log.w(TAG, "onMediaCaptured: video exiting uri=$uri")
finish()
}
}
} }
override fun onChangeFlashMode(flashMode: Int) { override fun onChangeFlashMode(flashMode: Int) {
@ -587,7 +628,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
fun videoSaved(uri: Uri) { fun videoSaved(uri: Uri) {
setupPreviewImage(false) setupPreviewImage(false)
if (mIsVideoCaptureIntent) { if (isVideoCaptureIntent()) {
Intent().apply { Intent().apply {
data = uri data = uri
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION flags = Intent.FLAG_GRANT_READ_URI_PERMISSION

View File

@ -17,10 +17,19 @@ fun Context.getOutputMediaFile(isPhoto: Boolean): String {
} }
} }
val mediaName = getRandomMediaName(isPhoto)
return if (isPhoto) {
"${mediaStorageDir.path}/$mediaName.jpg"
} else {
"${mediaStorageDir.path}/$mediaName.mp4"
}
}
fun getRandomMediaName(isPhoto: Boolean): String {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
return if (isPhoto) { return if (isPhoto) {
"${mediaStorageDir.path}/IMG_$timestamp.jpg" "IMG_$timestamp"
} else { } else {
"${mediaStorageDir.path}/VID_$timestamp.mp4" "VID_$timestamp"
} }
} }

View File

@ -0,0 +1,45 @@
package com.simplemobiletools.camera.helpers
import android.content.Context
import android.widget.Toast
import androidx.camera.core.CameraState
import androidx.camera.core.ImageCapture
import androidx.camera.video.VideoRecordEvent
import com.simplemobiletools.camera.R
import com.simplemobiletools.commons.extensions.toast
class CameraErrorHandler(
private val context: Context,
) {
fun handleCameraError(error: CameraState.StateError?) {
when (error?.code) {
CameraState.ERROR_MAX_CAMERAS_IN_USE,
CameraState.ERROR_CAMERA_IN_USE -> context.toast(R.string.camera_in_use_error, Toast.LENGTH_LONG)
CameraState.ERROR_CAMERA_FATAL_ERROR -> context.toast(R.string.camera_unavailable)
CameraState.ERROR_STREAM_CONFIG -> context.toast(R.string.camera_configure_error)
CameraState.ERROR_CAMERA_DISABLED -> context.toast(R.string.camera_disabled_by_admin_error)
CameraState.ERROR_DO_NOT_DISTURB_MODE_ENABLED -> context.toast(R.string.camera_dnd_error, Toast.LENGTH_LONG)
CameraState.ERROR_OTHER_RECOVERABLE_ERROR -> {}
}
}
fun handleImageCaptureError(imageCaptureError: Int) {
when (imageCaptureError) {
ImageCapture.ERROR_FILE_IO -> context.toast(R.string.photo_not_saved)
else -> context.toast(R.string.photo_capture_failed)
}
}
fun handleVideoRecordingError(error: Int) {
when (error) {
VideoRecordEvent.Finalize.ERROR_INSUFFICIENT_STORAGE -> context.toast(R.string.video_capture_insufficient_storage_error)
VideoRecordEvent.Finalize.ERROR_NONE -> {}
else -> context.toast(R.string.video_recording_failed)
}
}
fun showSaveToInternalStorage() {
context.toast(R.string.save_error_internal_storage)
}
}

View File

@ -0,0 +1,210 @@
package com.simplemobiletools.camera.helpers
import android.content.ContentValues
import android.net.Uri
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.util.Log
import com.simplemobiletools.camera.extensions.config
import com.simplemobiletools.camera.extensions.getOutputMediaFile
import com.simplemobiletools.camera.extensions.getRandomMediaName
import com.simplemobiletools.camera.models.MediaOutput
import com.simplemobiletools.commons.activities.BaseSimpleActivity
import com.simplemobiletools.commons.extensions.createDocumentUriFromRootTree
import com.simplemobiletools.commons.extensions.createDocumentUriUsingFirstParentTreeUri
import com.simplemobiletools.commons.extensions.getAndroidSAFUri
import com.simplemobiletools.commons.extensions.getDocumentFile
import com.simplemobiletools.commons.extensions.getDoesFilePathExist
import com.simplemobiletools.commons.extensions.getFileOutputStreamSync
import com.simplemobiletools.commons.extensions.getFilenameFromPath
import com.simplemobiletools.commons.extensions.getMimeType
import com.simplemobiletools.commons.extensions.hasProperStoredAndroidTreeUri
import com.simplemobiletools.commons.extensions.hasProperStoredFirstParentUri
import com.simplemobiletools.commons.extensions.hasProperStoredTreeUri
import com.simplemobiletools.commons.extensions.isAccessibleWithSAFSdk30
import com.simplemobiletools.commons.extensions.isRestrictedSAFOnlyRoot
import com.simplemobiletools.commons.extensions.needsStupidWritePermissions
import java.io.File
import java.io.OutputStream
class MediaOutputHelper(
private val activity: BaseSimpleActivity,
private val errorHandler: CameraErrorHandler,
private val outputUri: Uri?,
private val is3rdPartyIntent: Boolean,
) {
companion object {
private const val TAG = "MediaOutputHelper"
private const val MODE = "rw"
private const val IMAGE_MIME_TYPE = "image/jpeg"
private const val VIDEO_MIME_TYPE = "video/mp4"
}
private val mediaStorageDir = activity.config.savePhotosFolder
private val contentResolver = activity.contentResolver
fun getImageMediaOutput(): MediaOutput {
return if (is3rdPartyIntent) {
if (outputUri != null) {
val outputStream = openOutputStream(outputUri)
if (outputStream != null) {
MediaOutput.OutputStreamMediaOutput(outputStream, outputUri)
} else {
errorHandler.showSaveToInternalStorage()
getMediaStoreOutput(isPhoto = true)
}
} else {
getMediaStoreOutput(isPhoto = true)
}
} else {
getOutputStreamMediaOutput() ?: getMediaStoreOutput(isPhoto = true)
}
}
fun getVideoMediaOutput(): MediaOutput {
return if (is3rdPartyIntent) {
if (outputUri != null) {
val fileDescriptor = openFileDescriptor(outputUri)
if (fileDescriptor != null) {
MediaOutput.FileDescriptorMediaOutput(fileDescriptor, outputUri)
} else {
errorHandler.showSaveToInternalStorage()
getMediaStoreOutput(isPhoto = false)
}
} else {
getMediaStoreOutput(isPhoto = false)
}
} else {
getFileDescriptorMediaOutput() ?: getMediaStoreOutput(isPhoto = false)
}
}
private fun getMediaStoreOutput(isPhoto: Boolean): MediaOutput.MediaStoreOutput {
val contentValues = getContentValues(isPhoto)
val contentUri = if (isPhoto) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
return MediaOutput.MediaStoreOutput(contentValues, contentUri)
}
private fun getContentValues(isPhoto: Boolean): ContentValues {
val mimeType = if (isPhoto) IMAGE_MIME_TYPE else VIDEO_MIME_TYPE
return ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, getRandomMediaName(isPhoto))
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
}
}
private fun getOutputStreamMediaOutput(): MediaOutput.OutputStreamMediaOutput? {
var mediaOutput: MediaOutput.OutputStreamMediaOutput? = null
val canWrite = canWriteToFilePath(mediaStorageDir)
Log.i(TAG, "getMediaOutput: canWrite=${canWrite}")
if (canWrite) {
val path = activity.getOutputMediaFile(true)
val uri = getUriForFilePath(path)
val outputStream = activity.getFileOutputStreamSync(path, path.getMimeType())
if (uri != null && outputStream != null) {
mediaOutput = MediaOutput.OutputStreamMediaOutput(outputStream, uri)
}
}
Log.i(TAG, "OutputStreamMediaOutput: $mediaOutput")
return mediaOutput
}
private fun openOutputStream(uri: Uri): OutputStream? {
return try {
Log.i(TAG, "uri: $uri")
contentResolver.openOutputStream(uri)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun getFileDescriptorMediaOutput(): MediaOutput.FileDescriptorMediaOutput? {
var mediaOutput: MediaOutput.FileDescriptorMediaOutput? = null
val canWrite = canWriteToFilePath(mediaStorageDir)
Log.i(TAG, "getMediaOutput: canWrite=${canWrite}")
if (canWrite) {
val path = activity.getOutputMediaFile(false)
val uri = getUriForFilePath(path)
if (uri != null) {
val fileDescriptor = contentResolver.openFileDescriptor(uri, MODE)
if (fileDescriptor != null) {
mediaOutput = MediaOutput.FileDescriptorMediaOutput(fileDescriptor, uri)
}
}
}
Log.i(TAG, "FileDescriptorMediaOutput: $mediaOutput")
return mediaOutput
}
private fun openFileDescriptor(uri: Uri): ParcelFileDescriptor? {
return try {
Log.i(TAG, "uri: $uri")
contentResolver.openFileDescriptor(uri, MODE)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun canWriteToFilePath(path: String): Boolean {
return when {
activity.isRestrictedSAFOnlyRoot(path) -> activity.hasProperStoredAndroidTreeUri(path)
activity.needsStupidWritePermissions(path) -> activity.hasProperStoredTreeUri(false)
activity.isAccessibleWithSAFSdk30(path) -> activity.hasProperStoredFirstParentUri(path)
else -> File(path).canWrite()
}
}
private fun getUriForFilePath(path: String): Uri? {
val targetFile = File(path)
return when {
activity.isRestrictedSAFOnlyRoot(path) -> activity.getAndroidSAFUri(path)
activity.needsStupidWritePermissions(path) -> {
targetFile.parentFile?.let { parentFile ->
val documentFile =
if (activity.getDoesFilePathExist(parentFile.absolutePath)) {
activity.getDocumentFile(parentFile.path)
} else {
val parentDocumentFile = parentFile.parent?.let {
activity.getDocumentFile(it)
}
parentDocumentFile?.createDirectory(parentFile.name)
?: activity.getDocumentFile(parentFile.absolutePath)
}
if (documentFile == null) {
return Uri.fromFile(targetFile)
}
try {
if (activity.getDoesFilePathExist(path)) {
activity.createDocumentUriFromRootTree(path)
} else {
documentFile.createFile(path.getMimeType(), path.getFilenameFromPath())?.uri
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
activity.isAccessibleWithSAFSdk30(path) -> {
try {
activity.createDocumentUriUsingFirstParentTreeUri(path)
} catch (e: Exception) {
e.printStackTrace()
null
} ?: Uri.fromFile(targetFile)
}
else -> return Uri.fromFile(targetFile)
}
}
}

View File

@ -0,0 +1,46 @@
package com.simplemobiletools.camera.implementations
import android.net.Uri
import androidx.camera.view.PreviewView
import com.simplemobiletools.camera.helpers.CameraErrorHandler
import com.simplemobiletools.camera.helpers.MediaOutputHelper
import com.simplemobiletools.commons.activities.BaseSimpleActivity
class CameraXInitializer(private val activity: BaseSimpleActivity) {
fun createCameraXPreview(
previewView: PreviewView,
listener: CameraXPreviewListener,
outputUri: Uri?,
is3rdPartyIntent: Boolean,
initInPhotoMode: Boolean,
): CameraXPreview {
val cameraErrorHandler = newCameraErrorHandler()
val mediaOutputHelper = newMediaOutputHelper(cameraErrorHandler, outputUri, is3rdPartyIntent)
return CameraXPreview(
activity,
previewView,
mediaOutputHelper,
cameraErrorHandler,
listener,
initInPhotoMode,
)
}
private fun newMediaOutputHelper(
cameraErrorHandler: CameraErrorHandler,
outputUri: Uri?,
is3rdPartyIntent: Boolean,
): MediaOutputHelper {
return MediaOutputHelper(
activity,
cameraErrorHandler,
outputUri,
is3rdPartyIntent,
)
}
private fun newCameraErrorHandler(): CameraErrorHandler {
return CameraErrorHandler(activity)
}
}

View File

@ -17,7 +17,13 @@ import android.view.OrientationEventListener
import android.view.ScaleGestureDetector import android.view.ScaleGestureDetector
import android.view.Surface import android.view.Surface
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.* import androidx.camera.core.AspectRatio
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.CameraState
import androidx.camera.core.DisplayOrientedMeteringPointFactory
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY import androidx.camera.core.ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
import androidx.camera.core.ImageCapture.FLASH_MODE_AUTO import androidx.camera.core.ImageCapture.FLASH_MODE_AUTO
import androidx.camera.core.ImageCapture.FLASH_MODE_OFF import androidx.camera.core.ImageCapture.FLASH_MODE_OFF
@ -26,7 +32,11 @@ import androidx.camera.core.ImageCapture.Metadata
import androidx.camera.core.ImageCapture.OnImageSavedCallback import androidx.camera.core.ImageCapture.OnImageSavedCallback
import androidx.camera.core.ImageCapture.OutputFileOptions import androidx.camera.core.ImageCapture.OutputFileOptions
import androidx.camera.core.ImageCapture.OutputFileResults import androidx.camera.core.ImageCapture.OutputFileResults
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.core.UseCase
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileDescriptorOutputOptions
import androidx.camera.video.MediaStoreOutputOptions import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.Quality import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector import androidx.camera.video.QualitySelector
@ -35,6 +45,7 @@ import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.core.view.doOnLayout import androidx.core.view.doOnLayout
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@ -42,15 +53,19 @@ import androidx.window.layout.WindowMetricsCalculator
import com.bumptech.glide.load.ImageHeaderParser.UNKNOWN_ORIENTATION import com.bumptech.glide.load.ImageHeaderParser.UNKNOWN_ORIENTATION
import com.simplemobiletools.camera.R import com.simplemobiletools.camera.R
import com.simplemobiletools.camera.extensions.config import com.simplemobiletools.camera.extensions.config
import com.simplemobiletools.camera.extensions.getRandomMediaName
import com.simplemobiletools.camera.extensions.toAppFlashMode import com.simplemobiletools.camera.extensions.toAppFlashMode
import com.simplemobiletools.camera.extensions.toCameraSelector import com.simplemobiletools.camera.extensions.toCameraSelector
import com.simplemobiletools.camera.extensions.toCameraXFlashMode
import com.simplemobiletools.camera.extensions.toLensFacing import com.simplemobiletools.camera.extensions.toLensFacing
import com.simplemobiletools.camera.helpers.CameraErrorHandler
import com.simplemobiletools.camera.helpers.MediaOutputHelper
import com.simplemobiletools.camera.helpers.MediaSoundHelper import com.simplemobiletools.camera.helpers.MediaSoundHelper
import com.simplemobiletools.camera.helpers.PinchToZoomOnScaleGestureListener import com.simplemobiletools.camera.helpers.PinchToZoomOnScaleGestureListener
import com.simplemobiletools.camera.interfaces.MyPreview import com.simplemobiletools.camera.interfaces.MyPreview
import com.simplemobiletools.commons.extensions.showErrorToast import com.simplemobiletools.camera.models.MediaOutput
import com.simplemobiletools.commons.extensions.hasPermission
import com.simplemobiletools.commons.extensions.toast import com.simplemobiletools.commons.extensions.toast
import com.simplemobiletools.commons.helpers.PERMISSION_RECORD_AUDIO
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -61,7 +76,10 @@ import kotlin.math.min
class CameraXPreview( class CameraXPreview(
private val activity: AppCompatActivity, private val activity: AppCompatActivity,
private val previewView: PreviewView, private val previewView: PreviewView,
private val mediaOutputHelper: MediaOutputHelper,
private val cameraErrorHandler: CameraErrorHandler,
private val listener: CameraXPreviewListener, private val listener: CameraXPreviewListener,
initInPhotoMode: Boolean,
) : MyPreview, DefaultLifecycleObserver { ) : MyPreview, DefaultLifecycleObserver {
companion object { companion object {
@ -76,7 +94,7 @@ class CameraXPreview(
private val config = activity.config private val config = activity.config
private val contentResolver = activity.contentResolver private val contentResolver = activity.contentResolver
private val mainExecutor = activity.mainExecutor private val mainExecutor = ContextCompat.getMainExecutor(activity)
private val displayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private val displayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val mediaSoundHelper = MediaSoundHelper() private val mediaSoundHelper = MediaSoundHelper()
private val windowMetricsCalculator = WindowMetricsCalculator.getOrCreate() private val windowMetricsCalculator = WindowMetricsCalculator.getOrCreate()
@ -109,8 +127,10 @@ class CameraXPreview(
private var currentRecording: Recording? = null private var currentRecording: Recording? = null
private var recordingState: VideoRecordEvent? = null private var recordingState: VideoRecordEvent? = null
private var cameraSelector = config.lastUsedCameraLens.toCameraSelector() private var cameraSelector = config.lastUsedCameraLens.toCameraSelector()
private var flashMode = config.flashlightState.toCameraXFlashMode() private var flashMode = FLASH_MODE_OFF
private var isPhotoCapture = config.initPhotoMode private var isPhotoCapture = initInPhotoMode.also {
Log.i(TAG, "initInPhotoMode= $it")
}
init { init {
bindToLifeCycle() bindToLifeCycle()
@ -124,7 +144,7 @@ class CameraXPreview(
activity.lifecycle.addObserver(this) activity.lifecycle.addObserver(this)
} }
private fun startCamera() { private fun startCamera(switching: Boolean = false) {
Log.i(TAG, "startCamera: ") Log.i(TAG, "startCamera: ")
val cameraProviderFuture = ProcessCameraProvider.getInstance(activity) val cameraProviderFuture = ProcessCameraProvider.getInstance(activity)
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({
@ -134,7 +154,8 @@ class CameraXPreview(
setupCameraObservers() setupCameraObservers()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "startCamera: ", e) Log.e(TAG, "startCamera: ", e)
activity.showErrorToast(activity.getString(R.string.camera_open_error)) val errorMessage = if (switching) R.string.camera_switch_error else R.string.camera_open_error
activity.toast(errorMessage)
} }
}, mainExecutor) }, mainExecutor)
} }
@ -176,48 +197,7 @@ class CameraXPreview(
} }
} }
// TODO: Handle errors cameraErrorHandler.handleCameraError(cameraState?.error)
cameraState.error?.let { error ->
listener.setCameraAvailable(false)
when (error.code) {
CameraState.ERROR_STREAM_CONFIG -> {
Log.e(TAG, "ERROR_STREAM_CONFIG")
// Make sure to setup the use cases properly
activity.toast(R.string.camera_unavailable)
}
CameraState.ERROR_CAMERA_IN_USE -> {
Log.e(TAG, "ERROR_CAMERA_IN_USE")
// Close the camera or ask user to close another camera app that's using the
// camera
activity.showErrorToast("Camera is in use by another app, please close")
}
CameraState.ERROR_MAX_CAMERAS_IN_USE -> {
Log.e(TAG, "ERROR_MAX_CAMERAS_IN_USE")
// Close another open camera in the app, or ask the user to close another
// camera app that's using the camera
activity.showErrorToast("Camera is in use by another app, please close")
}
CameraState.ERROR_OTHER_RECOVERABLE_ERROR -> {
Log.e(TAG, "ERROR_OTHER_RECOVERABLE_ERROR")
activity.toast(R.string.camera_open_error)
}
CameraState.ERROR_CAMERA_DISABLED -> {
Log.e(TAG, "ERROR_CAMERA_DISABLED")
// Ask the user to enable the device's cameras
activity.toast(R.string.camera_open_error)
}
CameraState.ERROR_CAMERA_FATAL_ERROR -> {
Log.e(TAG, "ERROR_CAMERA_FATAL_ERROR")
// Ask the user to reboot the device to restore camera function
activity.toast(R.string.camera_open_error)
}
CameraState.ERROR_DO_NOT_DISTURB_MODE_ENABLED -> {
// Ask the user to disable the "Do Not Disturb" mode, then reopen the camera
Log.e(TAG, "ERROR_DO_NOT_DISTURB_MODE_ENABLED")
activity.toast(R.string.camera_open_error)
}
}
}
} }
} }
@ -324,10 +304,6 @@ class CameraXPreview(
orientationEventListener.disable() orientationEventListener.disable()
} }
override fun setTargetUri(uri: Uri) {
}
override fun showChangeResolutionDialog() { override fun showChangeResolutionDialog() {
} }
@ -340,17 +316,26 @@ class CameraXPreview(
} }
cameraSelector = newCameraSelector cameraSelector = newCameraSelector
config.lastUsedCameraLens = newCameraSelector.toLensFacing() config.lastUsedCameraLens = newCameraSelector.toLensFacing()
startCamera() startCamera(switching = true)
} }
override fun toggleFlashlight() { override fun toggleFlashlight() {
val newFlashMode = when (flashMode) { val newFlashMode = if (isPhotoCapture) {
when (flashMode) {
FLASH_MODE_OFF -> FLASH_MODE_ON FLASH_MODE_OFF -> FLASH_MODE_ON
FLASH_MODE_ON -> FLASH_MODE_AUTO FLASH_MODE_ON -> FLASH_MODE_AUTO
FLASH_MODE_AUTO -> FLASH_MODE_OFF FLASH_MODE_AUTO -> FLASH_MODE_OFF
else -> throw IllegalArgumentException("Unknown mode: $flashMode") else -> throw IllegalArgumentException("Unknown mode: $flashMode")
} }
} else {
when (flashMode) {
FLASH_MODE_OFF -> FLASH_MODE_ON
FLASH_MODE_ON -> FLASH_MODE_OFF
else -> throw IllegalArgumentException("Unknown mode: $flashMode")
}.also {
camera?.cameraControl?.enableTorch(it == FLASH_MODE_ON)
}
}
flashMode = newFlashMode flashMode = newFlashMode
imageCapture?.flashMode = newFlashMode imageCapture?.flashMode = newFlashMode
val appFlashMode = flashMode.toAppFlashMode() val appFlashMode = flashMode.toAppFlashMode()
@ -363,30 +348,28 @@ class CameraXPreview(
val imageCapture = imageCapture ?: throw IllegalStateException("Camera initialization failed.") val imageCapture = imageCapture ?: throw IllegalStateException("Camera initialization failed.")
val metadata = Metadata().apply { val metadata = Metadata().apply {
isReversedHorizontal = config.flipPhotos isReversedHorizontal = isFrontCameraInUse() && config.flipPhotos
} }
val contentValues = ContentValues().apply { val mediaOutput = mediaOutputHelper.getImageMediaOutput()
put(MediaStore.MediaColumns.DISPLAY_NAME, getRandomMediaName(true)) val outputOptionsBuilder = when (mediaOutput) {
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") is MediaOutput.MediaStoreOutput -> OutputFileOptions.Builder(contentResolver, mediaOutput.contentUri, mediaOutput.contentValues)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM) is MediaOutput.OutputStreamMediaOutput -> OutputFileOptions.Builder(mediaOutput.outputStream)
else -> throw IllegalArgumentException("Unexpected option for image")
} }
val contentUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val outputOptions = OutputFileOptions.Builder(contentResolver, contentUri, contentValues)
.setMetadata(metadata)
.build()
val outputOptions = outputOptionsBuilder.setMetadata(metadata).build()
imageCapture.takePicture(outputOptions, mainExecutor, object : OnImageSavedCallback { imageCapture.takePicture(outputOptions, mainExecutor, object : OnImageSavedCallback {
override fun onImageSaved(outputFileResults: OutputFileResults) { override fun onImageSaved(outputFileResults: OutputFileResults) {
listener.toggleBottomButtons(false) listener.toggleBottomButtons(false)
listener.onMediaCaptured(outputFileResults.savedUri!!) listener.onMediaCaptured(mediaOutput.uri ?: outputFileResults.savedUri!!)
} }
override fun onError(exception: ImageCaptureException) { override fun onError(exception: ImageCaptureException) {
listener.toggleBottomButtons(false)
activity.showErrorToast("Capture picture $exception")
Log.e(TAG, "Error", exception) Log.e(TAG, "Error", exception)
listener.toggleBottomButtons(false)
cameraErrorHandler.handleImageCaptureError(exception.imageCaptureError)
} }
}) })
playShutterSoundIfEnabled() playShutterSoundIfEnabled()
@ -416,19 +399,21 @@ class CameraXPreview(
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun startRecording() { private fun startRecording() {
val videoCapture = videoCapture ?: throw IllegalStateException("Camera initialization failed.") val videoCapture = videoCapture ?: throw IllegalStateException("Camera initialization failed.")
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, getRandomMediaName(false))
put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
}
val contentUri = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val outputOptions = MediaStoreOutputOptions.Builder(contentResolver, contentUri)
.setContentValues(contentValues)
.build()
currentRecording = videoCapture.output val mediaOutput = mediaOutputHelper.getVideoMediaOutput()
.prepareRecording(activity, outputOptions) val recording = when (mediaOutput) {
.withAudioEnabled() is MediaOutput.FileDescriptorMediaOutput -> {
FileDescriptorOutputOptions.Builder(mediaOutput.fileDescriptor).build()
.let { videoCapture.output.prepareRecording(activity, it) }
}
is MediaOutput.MediaStoreOutput -> {
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()
.start(mainExecutor) { recordEvent -> .start(mainExecutor) { recordEvent ->
Log.d(TAG, "recordEvent=$recordEvent ") Log.d(TAG, "recordEvent=$recordEvent ")
recordingState = recordEvent recordingState = recordEvent
@ -446,9 +431,10 @@ class CameraXPreview(
playStopVideoRecordingSoundIfEnabled() playStopVideoRecordingSoundIfEnabled()
listener.onVideoRecordingStopped() listener.onVideoRecordingStopped()
if (recordEvent.hasError()) { if (recordEvent.hasError()) {
// TODO: Handle errors Log.e(TAG, "recording failed:", recordEvent.cause)
cameraErrorHandler.handleVideoRecordingError(recordEvent.error)
} else { } else {
listener.onMediaCaptured(recordEvent.outputResults.outputUri) listener.onMediaCaptured(mediaOutput.uri ?: recordEvent.outputResults.outputUri)
} }
} }
} }
@ -456,15 +442,6 @@ class CameraXPreview(
Log.d(TAG, "Recording started") Log.d(TAG, "Recording started")
} }
private fun getRandomMediaName(isPhoto: Boolean): String {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
return if (isPhoto) {
"IMG_$timestamp"
} else {
"VID_$timestamp"
}
}
private fun playShutterSoundIfEnabled() { private fun playShutterSoundIfEnabled() {
if (config.isSoundEnabled) { if (config.isSoundEnabled) {
mediaSoundHelper.playShutterSound() mediaSoundHelper.playShutterSound()

View File

@ -8,7 +8,7 @@ interface MyPreview {
fun onPaused() = Unit fun onPaused() = Unit
fun setTargetUri(uri: Uri) fun setTargetUri(uri: Uri) = Unit
fun setIsImageCaptureIntent(isImageCaptureIntent: Boolean) = Unit fun setIsImageCaptureIntent(isImageCaptureIntent: Boolean) = Unit

View File

@ -0,0 +1,25 @@
package com.simplemobiletools.camera.models
import android.content.ContentValues
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.OutputStream
sealed class MediaOutput(
open val uri: Uri?,
) {
data class MediaStoreOutput(
val contentValues: ContentValues,
val contentUri: Uri,
) : MediaOutput(null)
data class OutputStreamMediaOutput(
val outputStream: OutputStream,
override val uri: Uri,
) : MediaOutput(uri)
data class FileDescriptorMediaOutput(
val fileDescriptor: ParcelFileDescriptor,
override val uri: Uri,
) : MediaOutput(uri)
}

View File

@ -6,7 +6,7 @@
android:background="@android:color/black"> android:background="@android:color/black">
<androidx.camera.view.PreviewView <androidx.camera.view.PreviewView
android:id="@+id/view_finder" android:id="@+id/preview_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />

View File

@ -2,6 +2,8 @@
<resources> <resources>
<string name="app_name">Simple Camera</string> <string name="app_name">Simple Camera</string>
<string name="app_launcher_name">Camera</string> <string name="app_launcher_name">Camera</string>
<!--Errors-->
<string name="camera_unavailable">Camera unavailable</string> <string name="camera_unavailable">Camera unavailable</string>
<string name="camera_open_error">An error occurred accessing the camera</string> <string name="camera_open_error">An error occurred accessing the camera</string>
<string name="video_creating_error">An error occurred creating the video file</string> <string name="video_creating_error">An error occurred creating the video file</string>
@ -13,6 +15,15 @@
<string name="setting_resolution_failed">Setting proper resolution failed</string> <string name="setting_resolution_failed">Setting proper resolution failed</string>
<string name="video_recording_failed">Video recording failed, try using a different resolution</string> <string name="video_recording_failed">Video recording failed, try using a different resolution</string>
<!--TODO: Add strings in other locales-->
<string name="camera_in_use_error">Camera is in use by another app, please close the app and try again</string>
<string name="camera_configure_error">An error occurred while configuring the camera</string>
<string name="camera_disabled_by_admin_error">Camera is disabled by the admin</string>
<string name="camera_dnd_error">"Do Not Disturb" mode is enabled. Please disable and try again</string>
<string name="photo_capture_failed">Photo capture failed</string>
<string name="video_capture_insufficient_storage_error">Video recording failed due to insufficient storage</string>
<!-- FAQ --> <!-- FAQ -->
<string name="faq_1_title">What photo compression quality should I set?</string> <string name="faq_1_title">What photo compression quality should I set?</string>
<string name="faq_1_text">It depends on your goal. For generic purposes most people advise using 75%-80%, when the image is still really good quality, but the file size is reduced drastically compared to 100%.</string> <string name="faq_1_text">It depends on your goal. For generic purposes most people advise using 75%-80%, when the image is still really good quality, but the file size is reduced drastically compared to 100%.</string>