From 79f926738302f590af26f0cc8b071a3c517c9425 Mon Sep 17 00:00:00 2001 From: darthpaul Date: Sun, 26 Jun 2022 10:58:17 +0100 Subject: [PATCH 1/4] handle storage location - add MediaOutputHelper - to create OutputStream for photos - to create FileDescriptor for videos (currently requires API 26+) --- .../camera/activities/MainActivity.kt | 3 +- .../camera/helpers/MediaOutputHelper.kt | 210 ++++++++++++++++++ .../camera/implementations/CameraXPreview.kt | 74 +++--- 3 files changed, 260 insertions(+), 27 deletions(-) create mode 100644 app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaOutputHelper.kt 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..f55a2a93 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt @@ -23,6 +23,7 @@ import com.simplemobiletools.camera.R import com.simplemobiletools.camera.extensions.config import com.simplemobiletools.camera.helpers.FLASH_OFF import com.simplemobiletools.camera.helpers.FLASH_ON +import com.simplemobiletools.camera.helpers.MediaOutputHelper import com.simplemobiletools.camera.helpers.ORIENT_LANDSCAPE_LEFT import com.simplemobiletools.camera.helpers.ORIENT_LANDSCAPE_RIGHT import com.simplemobiletools.camera.helpers.ORIENT_PORTRAIT @@ -220,7 +221,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera ) checkVideoCaptureIntent() - mPreview = CameraXPreview(this, view_finder, this) + mPreview = CameraXPreview(this, view_finder, MediaOutputHelper(this), this) checkImageCaptureIntent() mPreview?.setIsImageCaptureIntent(isImageCaptureIntent()) 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..cdc41fe0 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaOutputHelper.kt @@ -0,0 +1,210 @@ +package com.simplemobiletools.camera.helpers + +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.util.Log +import com.simplemobiletools.camera.extensions.config +import com.simplemobiletools.camera.extensions.getOutputMediaFile +import com.simplemobiletools.commons.activities.BaseSimpleActivity +import com.simplemobiletools.commons.extensions.createAndroidSAFFile +import com.simplemobiletools.commons.extensions.createDocumentUriFromRootTree +import com.simplemobiletools.commons.extensions.createDocumentUriUsingFirstParentTreeUri +import com.simplemobiletools.commons.extensions.createSAFFileSdk30 +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 com.simplemobiletools.commons.extensions.showFileCreateError +import java.io.File +import java.io.OutputStream + +class MediaOutputHelper(private val activity: BaseSimpleActivity) { + + companion object { + private const val TAG = "MediaOutputHelper" + private const val MODE = "rw" + } + + private val mediaStorageDir = activity.config.savePhotosFolder + + fun getOutputStreamMediaOutput(): MediaOutput.OutputStreamMediaOutput? { + val canWrite = activity.canWrite(mediaStorageDir) + Log.i(TAG, "getMediaOutput: canWrite=${canWrite}") + return if (canWrite) { + val path = activity.getOutputMediaFile(true) + val uri = activity.getUri(path) + uri?.let { + activity.getFileOutputStreamSync(path, path.getMimeType())?.let { + MediaOutput.OutputStreamMediaOutput(it, uri) + } + } + } else { + null + }.also { + Log.i(TAG, "output stream: $it") + } + } + + fun getFileDescriptorMediaOutput(): MediaOutput.FileDescriptorMediaOutput? { + val canWrite = activity.canWrite(mediaStorageDir) + Log.i(TAG, "getMediaOutput: canWrite=${canWrite}") + return if (canWrite) { + val path = activity.getOutputMediaFile(false) + val uri = activity.getUri(path) + uri?.let { + activity.getFileDescriptorSync(path, path.getMimeType())?.let { + MediaOutput.FileDescriptorMediaOutput(it, uri) + } + } + } else { + null + }.also { + Log.i(TAG, "descriptor: $it") + } + } + + private fun BaseSimpleActivity.canWrite(path: String): Boolean { + return when { + isRestrictedSAFOnlyRoot(path) -> hasProperStoredAndroidTreeUri(path) + needsStupidWritePermissions(path) -> hasProperStoredTreeUri(false) + isAccessibleWithSAFSdk30(path) -> hasProperStoredFirstParentUri(path) + else -> File(path).canWrite() + } + } + + private fun BaseSimpleActivity.getUri(path: String): Uri? { + val targetFile = File(path) + return when { + isRestrictedSAFOnlyRoot(path) -> { + getAndroidSAFUri(path) + } + needsStupidWritePermissions(path) -> { + val parentFile = targetFile.parentFile ?: return null + val documentFile = + if (getDoesFilePathExist(parentFile.absolutePath ?: return null)) { + getDocumentFile(parentFile.path) + } else { + val parentDocumentFile = parentFile.parent?.let { getDocumentFile(it) } + parentDocumentFile?.createDirectory(parentFile.name) ?: getDocumentFile(parentFile.absolutePath) + } + + if (documentFile == null) { + return Uri.fromFile(targetFile) + } + + try { + if (getDoesFilePathExist(path)) { + createDocumentUriFromRootTree(path) + } else { + documentFile.createFile(path.getMimeType(), path.getFilenameFromPath())!!.uri + } + } catch (e: Exception) { + null + } + } + isAccessibleWithSAFSdk30(path) -> { + try { + createDocumentUriUsingFirstParentTreeUri(path) + } catch (e: Exception) { + null + } ?: Uri.fromFile(targetFile) + } + else -> return Uri.fromFile(targetFile) + } + } + + private fun BaseSimpleActivity.getFileDescriptorSync(path: String, mimeType: String): ParcelFileDescriptor? { + val targetFile = File(path) + + return when { + isRestrictedSAFOnlyRoot(path) -> { + val uri = getAndroidSAFUri(path) + if (!getDoesFilePathExist(path)) { + createAndroidSAFFile(path) + } + applicationContext.contentResolver.openFileDescriptor(uri, MODE) + } + needsStupidWritePermissions(path) -> { + val parentFile = targetFile.parentFile ?: return null + val documentFile = + if (getDoesFilePathExist(parentFile.absolutePath ?: return null)) { + getDocumentFile(parentFile.path) + } else { + val parentDocumentFile = parentFile.parent?.let { getDocumentFile(it) } + parentDocumentFile?.createDirectory(parentFile.name) ?: getDocumentFile(parentFile.absolutePath) + } + + + if (documentFile == null) { + val casualOutputStream = createCasualFileDescriptor(targetFile) + return if (casualOutputStream == null) { + showFileCreateError(parentFile.path) + null + } else { + casualOutputStream + } + } + + try { + val uri = if (getDoesFilePathExist(path)) { + createDocumentUriFromRootTree(path) + } else { + documentFile.createFile(mimeType, path.getFilenameFromPath())!!.uri + } + applicationContext.contentResolver.openFileDescriptor(uri, MODE) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + isAccessibleWithSAFSdk30(path) -> { + try { + val uri = createDocumentUriUsingFirstParentTreeUri(path) + if (!getDoesFilePathExist(path)) { + createSAFFileSdk30(path) + } + applicationContext.contentResolver.openFileDescriptor(uri, MODE) + } catch (e: Exception) { + e.printStackTrace() + null + } ?: createCasualFileDescriptor(targetFile) + } + else -> return createCasualFileDescriptor(targetFile) + } + } + + private fun BaseSimpleActivity.createCasualFileDescriptor(targetFile: File): ParcelFileDescriptor? { + if (targetFile.parentFile?.exists() == false) { + targetFile.parentFile?.mkdirs() + } + + return try { + contentResolver.openFileDescriptor(Uri.fromFile(targetFile), MODE) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + sealed class MediaOutput( + open val uri: Uri, + ) { + 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/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt index 580acecf..bfcd16f7 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 @@ -46,6 +57,7 @@ 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.MediaOutputHelper import com.simplemobiletools.camera.helpers.MediaSoundHelper import com.simplemobiletools.camera.helpers.PinchToZoomOnScaleGestureListener import com.simplemobiletools.camera.interfaces.MyPreview @@ -61,6 +73,7 @@ import kotlin.math.min class CameraXPreview( private val activity: AppCompatActivity, private val previewView: PreviewView, + private val mediaOutputHelper: MediaOutputHelper, private val listener: CameraXPreviewListener, ) : MyPreview, DefaultLifecycleObserver { @@ -76,7 +89,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() @@ -363,24 +376,29 @@ 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 contentUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val outputOptions = OutputFileOptions.Builder(contentResolver, contentUri, contentValues) - .setMetadata(metadata) - .build() + val mediaOutput = mediaOutputHelper.getOutputStreamMediaOutput() + val outputOptionsBuilder = if (mediaOutput != null) { + OutputFileOptions.Builder(mediaOutput.outputStream) + } else { + 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 contentUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + OutputFileOptions.Builder(contentResolver, contentUri, contentValues) + } + + 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) { @@ -416,19 +434,23 @@ 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.getFileDescriptorMediaOutput() + val recording = if (mediaOutput != null) { + FileDescriptorOutputOptions.Builder(mediaOutput.fileDescriptor).build() + .let { videoCapture.output.prepareRecording(activity, it) } + } else { + 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) + MediaStoreOutputOptions.Builder(contentResolver, contentUri).setContentValues(contentValues).build() + .let { videoCapture.output.prepareRecording(activity, it) } + } + + currentRecording = recording.withAudioEnabled() .start(mainExecutor) { recordEvent -> Log.d(TAG, "recordEvent=$recordEvent ") recordingState = recordEvent @@ -448,7 +470,7 @@ class CameraXPreview( if (recordEvent.hasError()) { // TODO: Handle errors } else { - listener.onMediaCaptured(recordEvent.outputResults.outputUri) + listener.onMediaCaptured(mediaOutput?.uri ?: recordEvent.outputResults.outputUri) } } } From b10d8639fd923703b58a88441a26c80316e24d4f Mon Sep 17 00:00:00 2001 From: darthpaul Date: Sun, 26 Jun 2022 11:12:43 +0100 Subject: [PATCH 2/4] handle torch state in video capture - set initial torch state to off - enable/disable the torch when flash mode is on/off in video mode --- .../camera/activities/MainActivity.kt | 2 +- .../camera/implementations/CameraXPreview.kt | 23 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) 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 f55a2a93..fe6d8d3c 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt @@ -236,7 +236,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera mFadeHandler = Handler() setupPreviewImage(true) - val initialFlashlightState = config.flashlightState + val initialFlashlightState = FLASH_OFF mPreview!!.setFlashlightState(initialFlashlightState) updateFlashlightState(initialFlashlightState) } 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 bfcd16f7..60b0aa71 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt @@ -122,7 +122,7 @@ 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 flashMode = FLASH_MODE_OFF private var isPhotoCapture = config.initPhotoMode init { @@ -357,13 +357,22 @@ class CameraXPreview( } 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() From f43cd4f93927cb243779fc9ff5eccbdc2f81eeb1 Mon Sep 17 00:00:00 2001 From: darthpaul Date: Sun, 26 Jun 2022 21:54:32 +0100 Subject: [PATCH 3/4] handle some camera errors - add CameraErrorHandler to handle - errors during camera lifecycle - when capturing images - when recording videos --- .../camera/helpers/CameraErrorHandler.kt | 41 +++++++++++++ .../camera/implementations/CameraXPreview.kt | 60 ++++--------------- app/src/main/res/values/strings.xml | 11 ++++ 3 files changed, 62 insertions(+), 50 deletions(-) create mode 100644 app/src/main/kotlin/com/simplemobiletools/camera/helpers/CameraErrorHandler.kt 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..069694fd --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/CameraErrorHandler.kt @@ -0,0 +1,41 @@ +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) + } + } +} 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 60b0aa71..94cc1c74 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt @@ -55,13 +55,12 @@ import com.simplemobiletools.camera.R import com.simplemobiletools.camera.extensions.config 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.commons.extensions.toast import java.text.SimpleDateFormat import java.util.Date @@ -93,6 +92,7 @@ class CameraXPreview( private val displayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private val mediaSoundHelper = MediaSoundHelper() private val windowMetricsCalculator = WindowMetricsCalculator.getOrCreate() + private val cameraErrorHandler = CameraErrorHandler(activity) private val orientationEventListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_NORMAL) { @SuppressLint("RestrictedApi") @@ -137,7 +137,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({ @@ -147,7 +147,7 @@ class CameraXPreview( setupCameraObservers() } catch (e: Exception) { Log.e(TAG, "startCamera: ", e) - activity.showErrorToast(activity.getString(R.string.camera_open_error)) + activity.toast(if (switching) R.string.camera_switch_error else R.string.camera_open_error) } }, mainExecutor) } @@ -189,48 +189,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) } } @@ -353,7 +312,7 @@ class CameraXPreview( } cameraSelector = newCameraSelector config.lastUsedCameraLens = newCameraSelector.toLensFacing() - startCamera() + startCamera(switching = true) } override fun toggleFlashlight() { @@ -411,9 +370,9 @@ class CameraXPreview( } 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() @@ -477,7 +436,8 @@ 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(mediaOutput?.uri ?: recordEvent.outputResults.outputUri) } 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%. From 889a384f21149c631198715aacfb82f8e61aa226 Mon Sep 17 00:00:00 2001 From: darthpaul Date: Thu, 30 Jun 2022 00:23:41 +0100 Subject: [PATCH 4/4] handle 3rd party image/video capture intents - in MediaOutputHelper, - add support for specifying the output URI if present in the intent - when the output URI is specified, - for Image Capture, we return a `Bitmap` as a `data` extra and also the URI as the Intent data - for Video Capture we return the `Uri` as the Intent data - if no output URI is specified in the capture intent or if there is an error while trying to access the URI, use the default location with MediaStore, so we do not return inconsistent URIs (eg, SAF tree URIs) - add CameraXInitializer to abstract CameraXPreview initialisation logic --- .../camera/activities/MainActivity.kt | 68 +++- .../camera/extensions/Context.kt | 15 +- .../camera/helpers/CameraErrorHandler.kt | 4 + .../camera/helpers/MediaOutputHelper.kt | 322 +++++++++--------- .../implementations/CameraXInitializer.kt | 46 +++ .../camera/implementations/CameraXPreview.kt | 70 ++-- .../camera/interfaces/MyPreview.kt | 2 +- .../camera/models/MediaOutput.kt | 25 ++ app/src/main/res/layout/activity_main.xml | 2 +- 9 files changed, 332 insertions(+), 222 deletions(-) create mode 100644 app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXInitializer.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/camera/models/MediaOutput.kt 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 fe6d8d3c..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 @@ -23,12 +24,11 @@ import com.simplemobiletools.camera.R import com.simplemobiletools.camera.extensions.config import com.simplemobiletools.camera.helpers.FLASH_OFF import com.simplemobiletools.camera.helpers.FLASH_ON -import com.simplemobiletools.camera.helpers.MediaOutputHelper 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 @@ -39,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 @@ -51,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 { @@ -69,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 @@ -102,7 +102,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera scheduleFadeOut() mFocusCircleView.setStrokeColor(getProperPrimaryColor()) - if (mIsVideoCaptureIntent && mIsInPhotoMode) { + if (isVideoCaptureIntent() && mIsInPhotoMode) { handleTogglePhotoVideo() checkButtons() } @@ -133,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 @@ -188,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) { @@ -202,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) @@ -221,7 +232,15 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera ) checkVideoCaptureIntent() - mPreview = CameraXPreview(this, view_finder, MediaOutputHelper(this), 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()) @@ -313,7 +332,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera togglePhotoVideo() } else { toast(R.string.no_audio_permissions) - if (mIsVideoCaptureIntent) { + if (isVideoCaptureIntent()) { finish() } } @@ -325,7 +344,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera return } - if (mIsVideoCaptureIntent) { + if (isVideoCaptureIntent()) { mPreview?.initVideoMode() } @@ -357,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) } } @@ -545,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) { @@ -588,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 index 069694fd..8b3139dc 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/CameraErrorHandler.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/CameraErrorHandler.kt @@ -38,4 +38,8 @@ class CameraErrorHandler( 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 index cdc41fe0..08471abd 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaOutputHelper.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaOutputHelper.kt @@ -1,15 +1,18 @@ 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.createAndroidSAFFile import com.simplemobiletools.commons.extensions.createDocumentUriFromRootTree import com.simplemobiletools.commons.extensions.createDocumentUriUsingFirstParentTreeUri -import com.simplemobiletools.commons.extensions.createSAFFileSdk30 import com.simplemobiletools.commons.extensions.getAndroidSAFUri import com.simplemobiletools.commons.extensions.getDocumentFile import com.simplemobiletools.commons.extensions.getDoesFilePathExist @@ -22,189 +25,186 @@ 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 com.simplemobiletools.commons.extensions.showFileCreateError import java.io.File import java.io.OutputStream -class MediaOutputHelper(private val activity: BaseSimpleActivity) { +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 getOutputStreamMediaOutput(): MediaOutput.OutputStreamMediaOutput? { - val canWrite = activity.canWrite(mediaStorageDir) + 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}") - return if (canWrite) { + if (canWrite) { val path = activity.getOutputMediaFile(true) - val uri = activity.getUri(path) - uri?.let { - activity.getFileOutputStreamSync(path, path.getMimeType())?.let { - MediaOutput.OutputStreamMediaOutput(it, uri) - } + val uri = getUriForFilePath(path) + val outputStream = activity.getFileOutputStreamSync(path, path.getMimeType()) + if (uri != null && outputStream != null) { + mediaOutput = MediaOutput.OutputStreamMediaOutput(outputStream, uri) } - } else { - null - }.also { - Log.i(TAG, "output stream: $it") } + Log.i(TAG, "OutputStreamMediaOutput: $mediaOutput") + return mediaOutput } - fun getFileDescriptorMediaOutput(): MediaOutput.FileDescriptorMediaOutput? { - val canWrite = activity.canWrite(mediaStorageDir) - Log.i(TAG, "getMediaOutput: canWrite=${canWrite}") - return if (canWrite) { - val path = activity.getOutputMediaFile(false) - val uri = activity.getUri(path) - uri?.let { - activity.getFileDescriptorSync(path, path.getMimeType())?.let { - MediaOutput.FileDescriptorMediaOutput(it, uri) - } - } - } else { - null - }.also { - Log.i(TAG, "descriptor: $it") - } - } - - private fun BaseSimpleActivity.canWrite(path: String): Boolean { - return when { - isRestrictedSAFOnlyRoot(path) -> hasProperStoredAndroidTreeUri(path) - needsStupidWritePermissions(path) -> hasProperStoredTreeUri(false) - isAccessibleWithSAFSdk30(path) -> hasProperStoredFirstParentUri(path) - else -> File(path).canWrite() - } - } - - private fun BaseSimpleActivity.getUri(path: String): Uri? { - val targetFile = File(path) - return when { - isRestrictedSAFOnlyRoot(path) -> { - getAndroidSAFUri(path) - } - needsStupidWritePermissions(path) -> { - val parentFile = targetFile.parentFile ?: return null - val documentFile = - if (getDoesFilePathExist(parentFile.absolutePath ?: return null)) { - getDocumentFile(parentFile.path) - } else { - val parentDocumentFile = parentFile.parent?.let { getDocumentFile(it) } - parentDocumentFile?.createDirectory(parentFile.name) ?: getDocumentFile(parentFile.absolutePath) - } - - if (documentFile == null) { - return Uri.fromFile(targetFile) - } - - try { - if (getDoesFilePathExist(path)) { - createDocumentUriFromRootTree(path) - } else { - documentFile.createFile(path.getMimeType(), path.getFilenameFromPath())!!.uri - } - } catch (e: Exception) { - null - } - } - isAccessibleWithSAFSdk30(path) -> { - try { - createDocumentUriUsingFirstParentTreeUri(path) - } catch (e: Exception) { - null - } ?: Uri.fromFile(targetFile) - } - else -> return Uri.fromFile(targetFile) - } - } - - private fun BaseSimpleActivity.getFileDescriptorSync(path: String, mimeType: String): ParcelFileDescriptor? { - val targetFile = File(path) - - return when { - isRestrictedSAFOnlyRoot(path) -> { - val uri = getAndroidSAFUri(path) - if (!getDoesFilePathExist(path)) { - createAndroidSAFFile(path) - } - applicationContext.contentResolver.openFileDescriptor(uri, MODE) - } - needsStupidWritePermissions(path) -> { - val parentFile = targetFile.parentFile ?: return null - val documentFile = - if (getDoesFilePathExist(parentFile.absolutePath ?: return null)) { - getDocumentFile(parentFile.path) - } else { - val parentDocumentFile = parentFile.parent?.let { getDocumentFile(it) } - parentDocumentFile?.createDirectory(parentFile.name) ?: getDocumentFile(parentFile.absolutePath) - } - - - if (documentFile == null) { - val casualOutputStream = createCasualFileDescriptor(targetFile) - return if (casualOutputStream == null) { - showFileCreateError(parentFile.path) - null - } else { - casualOutputStream - } - } - - try { - val uri = if (getDoesFilePathExist(path)) { - createDocumentUriFromRootTree(path) - } else { - documentFile.createFile(mimeType, path.getFilenameFromPath())!!.uri - } - applicationContext.contentResolver.openFileDescriptor(uri, MODE) - } catch (e: Exception) { - e.printStackTrace() - null - } - } - isAccessibleWithSAFSdk30(path) -> { - try { - val uri = createDocumentUriUsingFirstParentTreeUri(path) - if (!getDoesFilePathExist(path)) { - createSAFFileSdk30(path) - } - applicationContext.contentResolver.openFileDescriptor(uri, MODE) - } catch (e: Exception) { - e.printStackTrace() - null - } ?: createCasualFileDescriptor(targetFile) - } - else -> return createCasualFileDescriptor(targetFile) - } - } - - private fun BaseSimpleActivity.createCasualFileDescriptor(targetFile: File): ParcelFileDescriptor? { - if (targetFile.parentFile?.exists() == false) { - targetFile.parentFile?.mkdirs() - } - + private fun openOutputStream(uri: Uri): OutputStream? { return try { - contentResolver.openFileDescriptor(Uri.fromFile(targetFile), MODE) + Log.i(TAG, "uri: $uri") + contentResolver.openOutputStream(uri) } catch (e: Exception) { e.printStackTrace() null } } - sealed class MediaOutput( - open val uri: Uri, - ) { - data class OutputStreamMediaOutput( - val outputStream: OutputStream, - override val uri: Uri, - ) : MediaOutput(uri) + 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 + } - data class FileDescriptorMediaOutput( - val fileDescriptor: ParcelFileDescriptor, - override val uri: Uri, - ) : MediaOutput(uri) + 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 94cc1c74..5207a8f6 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt @@ -53,6 +53,7 @@ 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.toLensFacing @@ -61,7 +62,10 @@ 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.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 @@ -73,7 +77,9 @@ 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 { @@ -92,7 +98,6 @@ class CameraXPreview( private val displayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private val mediaSoundHelper = MediaSoundHelper() private val windowMetricsCalculator = WindowMetricsCalculator.getOrCreate() - private val cameraErrorHandler = CameraErrorHandler(activity) private val orientationEventListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_NORMAL) { @SuppressLint("RestrictedApi") @@ -123,7 +128,9 @@ class CameraXPreview( private var recordingState: VideoRecordEvent? = null private var cameraSelector = config.lastUsedCameraLens.toCameraSelector() private var flashMode = FLASH_MODE_OFF - private var isPhotoCapture = config.initPhotoMode + private var isPhotoCapture = initInPhotoMode.also { + Log.i(TAG, "initInPhotoMode= $it") + } init { bindToLifeCycle() @@ -147,7 +154,8 @@ class CameraXPreview( setupCameraObservers() } catch (e: Exception) { Log.e(TAG, "startCamera: ", e) - activity.toast(if (switching) R.string.camera_switch_error else R.string.camera_open_error) + val errorMessage = if (switching) R.string.camera_switch_error else R.string.camera_open_error + activity.toast(errorMessage) } }, mainExecutor) } @@ -296,10 +304,6 @@ class CameraXPreview( orientationEventListener.disable() } - override fun setTargetUri(uri: Uri) { - - } - override fun showChangeResolutionDialog() { } @@ -347,18 +351,11 @@ class CameraXPreview( isReversedHorizontal = isFrontCameraInUse() && config.flipPhotos } - val mediaOutput = mediaOutputHelper.getOutputStreamMediaOutput() - - val outputOptionsBuilder = if (mediaOutput != null) { - OutputFileOptions.Builder(mediaOutput.outputStream) - } else { - 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 contentUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - OutputFileOptions.Builder(contentResolver, contentUri, contentValues) + 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 outputOptions = outputOptionsBuilder.setMetadata(metadata).build() @@ -366,7 +363,7 @@ class CameraXPreview( imageCapture.takePicture(outputOptions, mainExecutor, object : OnImageSavedCallback { override fun onImageSaved(outputFileResults: OutputFileResults) { listener.toggleBottomButtons(false) - listener.onMediaCaptured(mediaOutput?.uri ?: outputFileResults.savedUri!!) + listener.onMediaCaptured(mediaOutput.uri ?: outputFileResults.savedUri!!) } override fun onError(exception: ImageCaptureException) { @@ -403,19 +400,17 @@ class CameraXPreview( private fun startRecording() { val videoCapture = videoCapture ?: throw IllegalStateException("Camera initialization failed.") - val mediaOutput = mediaOutputHelper.getFileDescriptorMediaOutput() - val recording = if (mediaOutput != null) { - FileDescriptorOutputOptions.Builder(mediaOutput.fileDescriptor).build() - .let { videoCapture.output.prepareRecording(activity, it) } - } else { - 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 mediaOutput = mediaOutputHelper.getVideoMediaOutput() + val recording = when (mediaOutput) { + is MediaOutput.FileDescriptorMediaOutput -> { + FileDescriptorOutputOptions.Builder(mediaOutput.fileDescriptor).build() + .let { videoCapture.output.prepareRecording(activity, it) } } - val contentUri = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - MediaStoreOutputOptions.Builder(contentResolver, contentUri).setContentValues(contentValues).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() @@ -439,7 +434,7 @@ class CameraXPreview( Log.e(TAG, "recording failed:", recordEvent.cause) cameraErrorHandler.handleVideoRecordingError(recordEvent.error) } else { - listener.onMediaCaptured(mediaOutput?.uri ?: recordEvent.outputResults.outputUri) + listener.onMediaCaptured(mediaOutput.uri ?: recordEvent.outputResults.outputUri) } } } @@ -447,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">