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) } } }