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%.