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.provider.MediaStore
import android.util.Log
import android.util.Size
import android.view.KeyEvent
import android.view.OrientationEventListener
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_PORTRAIT
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.MyCameraImpl
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_WRITE_STORAGE
import com.simplemobiletools.commons.helpers.REFRESH_PATH
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.commons.models.Release
import java.util.concurrent.TimeUnit
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_photo_video
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
class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, CameraXPreviewListener {
@ -68,7 +70,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
private var mPreviewUri: Uri? = null
private var mIsInPhotoMode = false
private var mIsCameraAvailable = false
private var mIsVideoCaptureIntent = false
private var mIsHardwareShutterHandled = false
private var mCurrVideoRecTimer = 0
var mLastHandledOrientation = 0
@ -101,7 +102,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
scheduleFadeOut()
mFocusCircleView.setStrokeColor(getProperPrimaryColor())
if (mIsVideoCaptureIntent && mIsInPhotoMode) {
if (isVideoCaptureIntent() && mIsInPhotoMode) {
handleTogglePhotoVideo()
checkButtons()
}
@ -132,9 +133,17 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
}
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
mIsVideoCaptureIntent = false
mIsHardwareShutterHandled = false
mCurrVideoRecTimer = 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() {
if (isImageCaptureIntent()) {
Log.i(TAG, "isImageCaptureIntent: ")
hideIntentButtons()
val output = intent.extras?.get(MediaStore.EXTRA_OUTPUT)
if (output != null && output is Uri) {
@ -201,7 +213,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
private fun checkVideoCaptureIntent() {
if (intent?.action == MediaStore.ACTION_VIDEO_CAPTURE) {
mIsVideoCaptureIntent = true
Log.i(TAG, "checkVideoCaptureIntent: ")
mIsInPhotoMode = false
hideIntentButtons()
shutter.setImageResource(R.drawable.ic_video_rec)
@ -220,7 +232,15 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
)
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()
mPreview?.setIsImageCaptureIntent(isImageCaptureIntent())
@ -235,7 +255,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
mFadeHandler = Handler()
setupPreviewImage(true)
val initialFlashlightState = config.flashlightState
val initialFlashlightState = FLASH_OFF
mPreview!!.setFlashlightState(initialFlashlightState)
updateFlashlightState(initialFlashlightState)
}
@ -312,7 +332,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
togglePhotoVideo()
} else {
toast(R.string.no_audio_permissions)
if (mIsVideoCaptureIntent) {
if (isVideoCaptureIntent()) {
finish()
}
}
@ -324,7 +344,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
return
}
if (mIsVideoCaptureIntent) {
if (isVideoCaptureIntent()) {
mPreview?.initVideoMode()
}
@ -356,7 +376,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
mPreview?.initVideoMode()
initVideoButtons()
} catch (e: Exception) {
if (!mIsVideoCaptureIntent) {
if (!isVideoCaptureIntent()) {
toast(R.string.video_mode_error)
}
}
@ -544,6 +564,27 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
override fun onMediaCaptured(uri: 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) {
@ -587,7 +628,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
fun videoSaved(uri: Uri) {
setupPreviewImage(false)
if (mIsVideoCaptureIntent) {
if (isVideoCaptureIntent()) {
Intent().apply {
data = uri
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION

View File

@ -17,10 +17,19 @@ fun Context.getOutputMediaFile(isPhoto: Boolean): String {
}
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val mediaName = getRandomMediaName(isPhoto)
return if (isPhoto) {
"${mediaStorageDir.path}/IMG_$timestamp.jpg"
"${mediaStorageDir.path}/$mediaName.jpg"
} else {
"${mediaStorageDir.path}/VID_$timestamp.mp4"
"${mediaStorageDir.path}/$mediaName.mp4"
}
}
fun getRandomMediaName(isPhoto: Boolean): String {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
return if (isPhoto) {
"IMG_$timestamp"
} else {
"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.Surface
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.FLASH_MODE_AUTO
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.OutputFileOptions
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.video.FileDescriptorOutputOptions
import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
@ -35,6 +45,7 @@ import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.core.view.doOnLayout
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@ -42,15 +53,19 @@ import androidx.window.layout.WindowMetricsCalculator
import com.bumptech.glide.load.ImageHeaderParser.UNKNOWN_ORIENTATION
import com.simplemobiletools.camera.R
import com.simplemobiletools.camera.extensions.config
import com.simplemobiletools.camera.extensions.getRandomMediaName
import com.simplemobiletools.camera.extensions.toAppFlashMode
import com.simplemobiletools.camera.extensions.toCameraSelector
import com.simplemobiletools.camera.extensions.toCameraXFlashMode
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.PinchToZoomOnScaleGestureListener
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.helpers.PERMISSION_RECORD_AUDIO
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@ -61,7 +76,10 @@ import kotlin.math.min
class CameraXPreview(
private val activity: AppCompatActivity,
private val previewView: PreviewView,
private val mediaOutputHelper: MediaOutputHelper,
private val cameraErrorHandler: CameraErrorHandler,
private val listener: CameraXPreviewListener,
initInPhotoMode: Boolean,
) : MyPreview, DefaultLifecycleObserver {
companion object {
@ -76,7 +94,7 @@ class CameraXPreview(
private val config = activity.config
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 mediaSoundHelper = MediaSoundHelper()
private val windowMetricsCalculator = WindowMetricsCalculator.getOrCreate()
@ -109,8 +127,10 @@ class CameraXPreview(
private var currentRecording: Recording? = null
private var recordingState: VideoRecordEvent? = null
private var cameraSelector = config.lastUsedCameraLens.toCameraSelector()
private var flashMode = config.flashlightState.toCameraXFlashMode()
private var isPhotoCapture = config.initPhotoMode
private var flashMode = FLASH_MODE_OFF
private var isPhotoCapture = initInPhotoMode.also {
Log.i(TAG, "initInPhotoMode= $it")
}
init {
bindToLifeCycle()
@ -124,7 +144,7 @@ class CameraXPreview(
activity.lifecycle.addObserver(this)
}
private fun startCamera() {
private fun startCamera(switching: Boolean = false) {
Log.i(TAG, "startCamera: ")
val cameraProviderFuture = ProcessCameraProvider.getInstance(activity)
cameraProviderFuture.addListener({
@ -134,7 +154,8 @@ class CameraXPreview(
setupCameraObservers()
} catch (e: Exception) {
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)
}
@ -176,48 +197,7 @@ class CameraXPreview(
}
}
// TODO: Handle errors
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)
}
}
}
cameraErrorHandler.handleCameraError(cameraState?.error)
}
}
@ -324,10 +304,6 @@ class CameraXPreview(
orientationEventListener.disable()
}
override fun setTargetUri(uri: Uri) {
}
override fun showChangeResolutionDialog() {
}
@ -340,17 +316,26 @@ class CameraXPreview(
}
cameraSelector = newCameraSelector
config.lastUsedCameraLens = newCameraSelector.toLensFacing()
startCamera()
startCamera(switching = true)
}
override fun toggleFlashlight() {
val newFlashMode = when (flashMode) {
FLASH_MODE_OFF -> FLASH_MODE_ON
FLASH_MODE_ON -> FLASH_MODE_AUTO
FLASH_MODE_AUTO -> FLASH_MODE_OFF
else -> throw IllegalArgumentException("Unknown mode: $flashMode")
val newFlashMode = if (isPhotoCapture) {
when (flashMode) {
FLASH_MODE_OFF -> FLASH_MODE_ON
FLASH_MODE_ON -> FLASH_MODE_AUTO
FLASH_MODE_AUTO -> FLASH_MODE_OFF
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
imageCapture?.flashMode = newFlashMode
val appFlashMode = flashMode.toAppFlashMode()
@ -363,30 +348,28 @@ class CameraXPreview(
val imageCapture = imageCapture ?: throw IllegalStateException("Camera initialization failed.")
val metadata = Metadata().apply {
isReversedHorizontal = config.flipPhotos
isReversedHorizontal = isFrontCameraInUse() && config.flipPhotos
}
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, getRandomMediaName(true))
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
val mediaOutput = mediaOutputHelper.getImageMediaOutput()
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 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 {
override fun onImageSaved(outputFileResults: OutputFileResults) {
listener.toggleBottomButtons(false)
listener.onMediaCaptured(outputFileResults.savedUri!!)
listener.onMediaCaptured(mediaOutput.uri ?: outputFileResults.savedUri!!)
}
override fun onError(exception: ImageCaptureException) {
listener.toggleBottomButtons(false)
activity.showErrorToast("Capture picture $exception")
Log.e(TAG, "Error", exception)
listener.toggleBottomButtons(false)
cameraErrorHandler.handleImageCaptureError(exception.imageCaptureError)
}
})
playShutterSoundIfEnabled()
@ -416,19 +399,21 @@ class CameraXPreview(
@SuppressLint("MissingPermission")
private fun startRecording() {
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
.prepareRecording(activity, outputOptions)
.withAudioEnabled()
val mediaOutput = mediaOutputHelper.getVideoMediaOutput()
val recording = when (mediaOutput) {
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 ->
Log.d(TAG, "recordEvent=$recordEvent ")
recordingState = recordEvent
@ -446,9 +431,10 @@ class CameraXPreview(
playStopVideoRecordingSoundIfEnabled()
listener.onVideoRecordingStopped()
if (recordEvent.hasError()) {
// TODO: Handle errors
Log.e(TAG, "recording failed:", recordEvent.cause)
cameraErrorHandler.handleVideoRecordingError(recordEvent.error)
} else {
listener.onMediaCaptured(recordEvent.outputResults.outputUri)
listener.onMediaCaptured(mediaOutput.uri ?: recordEvent.outputResults.outputUri)
}
}
}
@ -456,15 +442,6 @@ class CameraXPreview(
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() {
if (config.isSoundEnabled) {
mediaSoundHelper.playShutterSound()

View File

@ -8,7 +8,7 @@ interface MyPreview {
fun onPaused() = Unit
fun setTargetUri(uri: Uri)
fun setTargetUri(uri: Uri) = 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">
<androidx.camera.view.PreviewView
android:id="@+id/view_finder"
android:id="@+id/preview_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -2,6 +2,8 @@
<resources>
<string name="app_name">Simple Camera</string>
<string name="app_launcher_name">Camera</string>
<!--Errors-->
<string name="camera_unavailable">Camera unavailable</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>
@ -13,6 +15,15 @@
<string name="setting_resolution_failed">Setting proper resolution failed</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 -->
<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>