diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt b/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt
index 5f701f58..2d1452e4 100644
--- a/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt
+++ b/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt
@@ -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
diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/camera/extensions/Context.kt
index f8b35bca..0094f3a3 100644
--- a/app/src/main/kotlin/com/simplemobiletools/camera/extensions/Context.kt
+++ b/app/src/main/kotlin/com/simplemobiletools/camera/extensions/Context.kt
@@ -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"
}
}
diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/CameraErrorHandler.kt b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/CameraErrorHandler.kt
new file mode 100644
index 00000000..8b3139dc
--- /dev/null
+++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/CameraErrorHandler.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaOutputHelper.kt b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaOutputHelper.kt
new file mode 100644
index 00000000..08471abd
--- /dev/null
+++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaOutputHelper.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXInitializer.kt b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXInitializer.kt
new file mode 100644
index 00000000..cd47df64
--- /dev/null
+++ b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXInitializer.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt
index 580acecf..5207a8f6 100644
--- a/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt
+++ b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt
@@ -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()
diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/interfaces/MyPreview.kt b/app/src/main/kotlin/com/simplemobiletools/camera/interfaces/MyPreview.kt
index 232b936f..81125acb 100644
--- a/app/src/main/kotlin/com/simplemobiletools/camera/interfaces/MyPreview.kt
+++ b/app/src/main/kotlin/com/simplemobiletools/camera/interfaces/MyPreview.kt
@@ -8,7 +8,7 @@ interface MyPreview {
fun onPaused() = Unit
- fun setTargetUri(uri: Uri)
+ fun setTargetUri(uri: Uri) = Unit
fun setIsImageCaptureIntent(isImageCaptureIntent: Boolean) = Unit
diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/models/MediaOutput.kt b/app/src/main/kotlin/com/simplemobiletools/camera/models/MediaOutput.kt
new file mode 100644
index 00000000..d05a74ec
--- /dev/null
+++ b/app/src/main/kotlin/com/simplemobiletools/camera/models/MediaOutput.kt
@@ -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)
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 94059da6..690e9001 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -6,7 +6,7 @@
android:background="@android:color/black">
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 632afa72..a8d052e9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2,6 +2,8 @@
Simple Camera
Camera
+
+
Camera unavailable
An error occurred accessing the camera
An error occurred creating the video file
@@ -13,6 +15,15 @@
Setting proper resolution failed
Video recording failed, try using a different resolution
+
+ Camera is in use by another app, please close the app and try again
+ An error occurred while configuring the camera
+ Camera is disabled by the admin
+ "Do Not Disturb" mode is enabled. Please disable and try again
+
+ Photo capture failed
+ Video recording failed due to insufficient storage
+
What photo compression quality should I set?
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%.