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
This commit is contained in:
darthpaul
2022-06-30 00:23:41 +01:00
parent f43cd4f939
commit 889a384f21
9 changed files with 332 additions and 222 deletions

View File

@ -8,6 +8,7 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.util.Size
import android.view.KeyEvent import android.view.KeyEvent
import android.view.OrientationEventListener import android.view.OrientationEventListener
import android.view.View import android.view.View
@ -23,12 +24,11 @@ import com.simplemobiletools.camera.R
import com.simplemobiletools.camera.extensions.config import com.simplemobiletools.camera.extensions.config
import com.simplemobiletools.camera.helpers.FLASH_OFF import com.simplemobiletools.camera.helpers.FLASH_OFF
import com.simplemobiletools.camera.helpers.FLASH_ON 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_LEFT
import com.simplemobiletools.camera.helpers.ORIENT_LANDSCAPE_RIGHT import com.simplemobiletools.camera.helpers.ORIENT_LANDSCAPE_RIGHT
import com.simplemobiletools.camera.helpers.ORIENT_PORTRAIT import com.simplemobiletools.camera.helpers.ORIENT_PORTRAIT
import com.simplemobiletools.camera.helpers.PhotoProcessor 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.CameraXPreviewListener
import com.simplemobiletools.camera.implementations.MyCameraImpl import com.simplemobiletools.camera.implementations.MyCameraImpl
import com.simplemobiletools.camera.interfaces.MyPreview 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_RECORD_AUDIO
import com.simplemobiletools.commons.helpers.PERMISSION_WRITE_STORAGE import com.simplemobiletools.commons.helpers.PERMISSION_WRITE_STORAGE
import com.simplemobiletools.commons.helpers.REFRESH_PATH import com.simplemobiletools.commons.helpers.REFRESH_PATH
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.commons.models.Release import com.simplemobiletools.commons.models.Release
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.android.synthetic.main.activity_main.btn_holder 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_flash
import kotlinx.android.synthetic.main.activity_main.toggle_photo_video 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.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 import kotlinx.android.synthetic.main.activity_main.view_holder
class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, CameraXPreviewListener { class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, CameraXPreviewListener {
@ -69,7 +70,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
private var mPreviewUri: Uri? = null private var mPreviewUri: Uri? = null
private var mIsInPhotoMode = false private var mIsInPhotoMode = false
private var mIsCameraAvailable = false private var mIsCameraAvailable = false
private var mIsVideoCaptureIntent = false
private var mIsHardwareShutterHandled = false private var mIsHardwareShutterHandled = false
private var mCurrVideoRecTimer = 0 private var mCurrVideoRecTimer = 0
var mLastHandledOrientation = 0 var mLastHandledOrientation = 0
@ -102,7 +102,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
scheduleFadeOut() scheduleFadeOut()
mFocusCircleView.setStrokeColor(getProperPrimaryColor()) mFocusCircleView.setStrokeColor(getProperPrimaryColor())
if (mIsVideoCaptureIntent && mIsInPhotoMode) { if (isVideoCaptureIntent() && mIsInPhotoMode) {
handleTogglePhotoVideo() handleTogglePhotoVideo()
checkButtons() checkButtons()
} }
@ -133,9 +133,17 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
} }
private fun initVariables() { 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 mIsCameraAvailable = false
mIsVideoCaptureIntent = false
mIsHardwareShutterHandled = false mIsHardwareShutterHandled = false
mCurrVideoRecTimer = 0 mCurrVideoRecTimer = 0
mLastHandledOrientation = 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() { private fun checkImageCaptureIntent() {
if (isImageCaptureIntent()) { if (isImageCaptureIntent()) {
Log.i(TAG, "isImageCaptureIntent: ")
hideIntentButtons() hideIntentButtons()
val output = intent.extras?.get(MediaStore.EXTRA_OUTPUT) val output = intent.extras?.get(MediaStore.EXTRA_OUTPUT)
if (output != null && output is Uri) { if (output != null && output is Uri) {
@ -202,7 +213,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
private fun checkVideoCaptureIntent() { private fun checkVideoCaptureIntent() {
if (intent?.action == MediaStore.ACTION_VIDEO_CAPTURE) { if (intent?.action == MediaStore.ACTION_VIDEO_CAPTURE) {
mIsVideoCaptureIntent = true Log.i(TAG, "checkVideoCaptureIntent: ")
mIsInPhotoMode = false mIsInPhotoMode = false
hideIntentButtons() hideIntentButtons()
shutter.setImageResource(R.drawable.ic_video_rec) shutter.setImageResource(R.drawable.ic_video_rec)
@ -221,7 +232,15 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
) )
checkVideoCaptureIntent() 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() checkImageCaptureIntent()
mPreview?.setIsImageCaptureIntent(isImageCaptureIntent()) mPreview?.setIsImageCaptureIntent(isImageCaptureIntent())
@ -313,7 +332,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
togglePhotoVideo() togglePhotoVideo()
} else { } else {
toast(R.string.no_audio_permissions) toast(R.string.no_audio_permissions)
if (mIsVideoCaptureIntent) { if (isVideoCaptureIntent()) {
finish() finish()
} }
} }
@ -325,7 +344,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
return return
} }
if (mIsVideoCaptureIntent) { if (isVideoCaptureIntent()) {
mPreview?.initVideoMode() mPreview?.initVideoMode()
} }
@ -357,7 +376,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
mPreview?.initVideoMode() mPreview?.initVideoMode()
initVideoButtons() initVideoButtons()
} catch (e: Exception) { } catch (e: Exception) {
if (!mIsVideoCaptureIntent) { if (!isVideoCaptureIntent()) {
toast(R.string.video_mode_error) toast(R.string.video_mode_error)
} }
} }
@ -545,6 +564,27 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
override fun onMediaCaptured(uri: Uri) { override fun onMediaCaptured(uri: Uri) {
loadLastTakenMedia(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) { override fun onChangeFlashMode(flashMode: Int) {
@ -588,7 +628,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, Camera
fun videoSaved(uri: Uri) { fun videoSaved(uri: Uri) {
setupPreviewImage(false) setupPreviewImage(false)
if (mIsVideoCaptureIntent) { if (isVideoCaptureIntent()) {
Intent().apply { Intent().apply {
data = uri data = uri
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION flags = Intent.FLAG_GRANT_READ_URI_PERMISSION

View File

@ -17,10 +17,19 @@ fun Context.getOutputMediaFile(isPhoto: Boolean): String {
} }
} }
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) val mediaName = getRandomMediaName(isPhoto)
return if (isPhoto) { return if (isPhoto) {
"${mediaStorageDir.path}/IMG_$timestamp.jpg" "${mediaStorageDir.path}/$mediaName.jpg"
} else { } else {
"${mediaStorageDir.path}/VID_$timestamp.mp4" "${mediaStorageDir.path}/$mediaName.mp4"
}
}
fun getRandomMediaName(isPhoto: Boolean): String {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
return if (isPhoto) {
"IMG_$timestamp"
} else {
"VID_$timestamp"
} }
} }

View File

@ -38,4 +38,8 @@ class CameraErrorHandler(
else -> context.toast(R.string.video_recording_failed) else -> context.toast(R.string.video_recording_failed)
} }
} }
fun showSaveToInternalStorage() {
context.toast(R.string.save_error_internal_storage)
}
} }

View File

@ -1,15 +1,18 @@
package com.simplemobiletools.camera.helpers package com.simplemobiletools.camera.helpers
import android.content.ContentValues
import android.net.Uri import android.net.Uri
import android.os.Environment
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.util.Log import android.util.Log
import com.simplemobiletools.camera.extensions.config import com.simplemobiletools.camera.extensions.config
import com.simplemobiletools.camera.extensions.getOutputMediaFile 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.activities.BaseSimpleActivity
import com.simplemobiletools.commons.extensions.createAndroidSAFFile
import com.simplemobiletools.commons.extensions.createDocumentUriFromRootTree import com.simplemobiletools.commons.extensions.createDocumentUriFromRootTree
import com.simplemobiletools.commons.extensions.createDocumentUriUsingFirstParentTreeUri import com.simplemobiletools.commons.extensions.createDocumentUriUsingFirstParentTreeUri
import com.simplemobiletools.commons.extensions.createSAFFileSdk30
import com.simplemobiletools.commons.extensions.getAndroidSAFUri import com.simplemobiletools.commons.extensions.getAndroidSAFUri
import com.simplemobiletools.commons.extensions.getDocumentFile import com.simplemobiletools.commons.extensions.getDocumentFile
import com.simplemobiletools.commons.extensions.getDoesFilePathExist import com.simplemobiletools.commons.extensions.getDoesFilePathExist
@ -22,78 +25,159 @@ import com.simplemobiletools.commons.extensions.hasProperStoredTreeUri
import com.simplemobiletools.commons.extensions.isAccessibleWithSAFSdk30 import com.simplemobiletools.commons.extensions.isAccessibleWithSAFSdk30
import com.simplemobiletools.commons.extensions.isRestrictedSAFOnlyRoot import com.simplemobiletools.commons.extensions.isRestrictedSAFOnlyRoot
import com.simplemobiletools.commons.extensions.needsStupidWritePermissions import com.simplemobiletools.commons.extensions.needsStupidWritePermissions
import com.simplemobiletools.commons.extensions.showFileCreateError
import java.io.File import java.io.File
import java.io.OutputStream 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 { companion object {
private const val TAG = "MediaOutputHelper" private const val TAG = "MediaOutputHelper"
private const val MODE = "rw" 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 mediaStorageDir = activity.config.savePhotosFolder
private val contentResolver = activity.contentResolver
fun getOutputStreamMediaOutput(): MediaOutput.OutputStreamMediaOutput? { fun getImageMediaOutput(): MediaOutput {
val canWrite = activity.canWrite(mediaStorageDir) 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}") Log.i(TAG, "getMediaOutput: canWrite=${canWrite}")
return if (canWrite) { if (canWrite) {
val path = activity.getOutputMediaFile(true) val path = activity.getOutputMediaFile(true)
val uri = activity.getUri(path) val uri = getUriForFilePath(path)
uri?.let { val outputStream = activity.getFileOutputStreamSync(path, path.getMimeType())
activity.getFileOutputStreamSync(path, path.getMimeType())?.let { if (uri != null && outputStream != null) {
MediaOutput.OutputStreamMediaOutput(it, uri) mediaOutput = MediaOutput.OutputStreamMediaOutput(outputStream, uri)
} }
} }
} else { 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 null
}.also {
Log.i(TAG, "output stream: $it")
} }
} }
fun getFileDescriptorMediaOutput(): MediaOutput.FileDescriptorMediaOutput? { private fun getFileDescriptorMediaOutput(): MediaOutput.FileDescriptorMediaOutput? {
val canWrite = activity.canWrite(mediaStorageDir) var mediaOutput: MediaOutput.FileDescriptorMediaOutput? = null
val canWrite = canWriteToFilePath(mediaStorageDir)
Log.i(TAG, "getMediaOutput: canWrite=${canWrite}") Log.i(TAG, "getMediaOutput: canWrite=${canWrite}")
return if (canWrite) { if (canWrite) {
val path = activity.getOutputMediaFile(false) val path = activity.getOutputMediaFile(false)
val uri = activity.getUri(path) val uri = getUriForFilePath(path)
uri?.let { if (uri != null) {
activity.getFileDescriptorSync(path, path.getMimeType())?.let { val fileDescriptor = contentResolver.openFileDescriptor(uri, MODE)
MediaOutput.FileDescriptorMediaOutput(it, uri) if (fileDescriptor != null) {
mediaOutput = MediaOutput.FileDescriptorMediaOutput(fileDescriptor, uri)
} }
} }
} else { }
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 null
}.also {
Log.i(TAG, "descriptor: $it")
} }
} }
private fun BaseSimpleActivity.canWrite(path: String): Boolean { private fun canWriteToFilePath(path: String): Boolean {
return when { return when {
isRestrictedSAFOnlyRoot(path) -> hasProperStoredAndroidTreeUri(path) activity.isRestrictedSAFOnlyRoot(path) -> activity.hasProperStoredAndroidTreeUri(path)
needsStupidWritePermissions(path) -> hasProperStoredTreeUri(false) activity.needsStupidWritePermissions(path) -> activity.hasProperStoredTreeUri(false)
isAccessibleWithSAFSdk30(path) -> hasProperStoredFirstParentUri(path) activity.isAccessibleWithSAFSdk30(path) -> activity.hasProperStoredFirstParentUri(path)
else -> File(path).canWrite() else -> File(path).canWrite()
} }
} }
private fun BaseSimpleActivity.getUri(path: String): Uri? { private fun getUriForFilePath(path: String): Uri? {
val targetFile = File(path) val targetFile = File(path)
return when { return when {
isRestrictedSAFOnlyRoot(path) -> { activity.isRestrictedSAFOnlyRoot(path) -> activity.getAndroidSAFUri(path)
getAndroidSAFUri(path) activity.needsStupidWritePermissions(path) -> {
} targetFile.parentFile?.let { parentFile ->
needsStupidWritePermissions(path) -> {
val parentFile = targetFile.parentFile ?: return null
val documentFile = val documentFile =
if (getDoesFilePathExist(parentFile.absolutePath ?: return null)) { if (activity.getDoesFilePathExist(parentFile.absolutePath)) {
getDocumentFile(parentFile.path) activity.getDocumentFile(parentFile.path)
} else { } else {
val parentDocumentFile = parentFile.parent?.let { getDocumentFile(it) } val parentDocumentFile = parentFile.parent?.let {
parentDocumentFile?.createDirectory(parentFile.name) ?: getDocumentFile(parentFile.absolutePath) activity.getDocumentFile(it)
}
parentDocumentFile?.createDirectory(parentFile.name)
?: activity.getDocumentFile(parentFile.absolutePath)
} }
if (documentFile == null) { if (documentFile == null) {
@ -101,110 +185,26 @@ class MediaOutputHelper(private val activity: BaseSimpleActivity) {
} }
try { try {
if (getDoesFilePathExist(path)) { if (activity.getDoesFilePathExist(path)) {
createDocumentUriFromRootTree(path) activity.createDocumentUriFromRootTree(path)
} else { } else {
documentFile.createFile(path.getMimeType(), path.getFilenameFromPath())!!.uri documentFile.createFile(path.getMimeType(), path.getFilenameFromPath())?.uri
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
null null
} }
} }
isAccessibleWithSAFSdk30(path) -> { }
activity.isAccessibleWithSAFSdk30(path) -> {
try { try {
createDocumentUriUsingFirstParentTreeUri(path) activity.createDocumentUriUsingFirstParentTreeUri(path)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
null null
} ?: Uri.fromFile(targetFile) } ?: Uri.fromFile(targetFile)
} }
else -> return 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)
}
} }

View File

@ -0,0 +1,46 @@
package com.simplemobiletools.camera.implementations
import android.net.Uri
import androidx.camera.view.PreviewView
import com.simplemobiletools.camera.helpers.CameraErrorHandler
import com.simplemobiletools.camera.helpers.MediaOutputHelper
import com.simplemobiletools.commons.activities.BaseSimpleActivity
class CameraXInitializer(private val activity: BaseSimpleActivity) {
fun createCameraXPreview(
previewView: PreviewView,
listener: CameraXPreviewListener,
outputUri: Uri?,
is3rdPartyIntent: Boolean,
initInPhotoMode: Boolean,
): CameraXPreview {
val cameraErrorHandler = newCameraErrorHandler()
val mediaOutputHelper = newMediaOutputHelper(cameraErrorHandler, outputUri, is3rdPartyIntent)
return CameraXPreview(
activity,
previewView,
mediaOutputHelper,
cameraErrorHandler,
listener,
initInPhotoMode,
)
}
private fun newMediaOutputHelper(
cameraErrorHandler: CameraErrorHandler,
outputUri: Uri?,
is3rdPartyIntent: Boolean,
): MediaOutputHelper {
return MediaOutputHelper(
activity,
cameraErrorHandler,
outputUri,
is3rdPartyIntent,
)
}
private fun newCameraErrorHandler(): CameraErrorHandler {
return CameraErrorHandler(activity)
}
}

View File

@ -53,6 +53,7 @@ import androidx.window.layout.WindowMetricsCalculator
import com.bumptech.glide.load.ImageHeaderParser.UNKNOWN_ORIENTATION import com.bumptech.glide.load.ImageHeaderParser.UNKNOWN_ORIENTATION
import com.simplemobiletools.camera.R import com.simplemobiletools.camera.R
import com.simplemobiletools.camera.extensions.config import com.simplemobiletools.camera.extensions.config
import com.simplemobiletools.camera.extensions.getRandomMediaName
import com.simplemobiletools.camera.extensions.toAppFlashMode import com.simplemobiletools.camera.extensions.toAppFlashMode
import com.simplemobiletools.camera.extensions.toCameraSelector import com.simplemobiletools.camera.extensions.toCameraSelector
import com.simplemobiletools.camera.extensions.toLensFacing 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.MediaSoundHelper
import com.simplemobiletools.camera.helpers.PinchToZoomOnScaleGestureListener import com.simplemobiletools.camera.helpers.PinchToZoomOnScaleGestureListener
import com.simplemobiletools.camera.interfaces.MyPreview 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.extensions.toast
import com.simplemobiletools.commons.helpers.PERMISSION_RECORD_AUDIO
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -73,7 +77,9 @@ class CameraXPreview(
private val activity: AppCompatActivity, private val activity: AppCompatActivity,
private val previewView: PreviewView, private val previewView: PreviewView,
private val mediaOutputHelper: MediaOutputHelper, private val mediaOutputHelper: MediaOutputHelper,
private val cameraErrorHandler: CameraErrorHandler,
private val listener: CameraXPreviewListener, private val listener: CameraXPreviewListener,
initInPhotoMode: Boolean,
) : MyPreview, DefaultLifecycleObserver { ) : MyPreview, DefaultLifecycleObserver {
companion object { companion object {
@ -92,7 +98,6 @@ class CameraXPreview(
private val displayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private val displayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val mediaSoundHelper = MediaSoundHelper() private val mediaSoundHelper = MediaSoundHelper()
private val windowMetricsCalculator = WindowMetricsCalculator.getOrCreate() private val windowMetricsCalculator = WindowMetricsCalculator.getOrCreate()
private val cameraErrorHandler = CameraErrorHandler(activity)
private val orientationEventListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_NORMAL) { private val orientationEventListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_NORMAL) {
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@ -123,7 +128,9 @@ class CameraXPreview(
private var recordingState: VideoRecordEvent? = null private var recordingState: VideoRecordEvent? = null
private var cameraSelector = config.lastUsedCameraLens.toCameraSelector() private var cameraSelector = config.lastUsedCameraLens.toCameraSelector()
private var flashMode = FLASH_MODE_OFF private var flashMode = FLASH_MODE_OFF
private var isPhotoCapture = config.initPhotoMode private var isPhotoCapture = initInPhotoMode.also {
Log.i(TAG, "initInPhotoMode= $it")
}
init { init {
bindToLifeCycle() bindToLifeCycle()
@ -147,7 +154,8 @@ class CameraXPreview(
setupCameraObservers() setupCameraObservers()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "startCamera: ", e) 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) }, mainExecutor)
} }
@ -296,10 +304,6 @@ class CameraXPreview(
orientationEventListener.disable() orientationEventListener.disable()
} }
override fun setTargetUri(uri: Uri) {
}
override fun showChangeResolutionDialog() { override fun showChangeResolutionDialog() {
} }
@ -347,18 +351,11 @@ class CameraXPreview(
isReversedHorizontal = isFrontCameraInUse() && config.flipPhotos isReversedHorizontal = isFrontCameraInUse() && config.flipPhotos
} }
val mediaOutput = mediaOutputHelper.getOutputStreamMediaOutput() val mediaOutput = mediaOutputHelper.getImageMediaOutput()
val outputOptionsBuilder = when (mediaOutput) {
val outputOptionsBuilder = if (mediaOutput != null) { is MediaOutput.MediaStoreOutput -> OutputFileOptions.Builder(contentResolver, mediaOutput.contentUri, mediaOutput.contentValues)
OutputFileOptions.Builder(mediaOutput.outputStream) is MediaOutput.OutputStreamMediaOutput -> OutputFileOptions.Builder(mediaOutput.outputStream)
} else { else -> throw IllegalArgumentException("Unexpected option for image")
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() val outputOptions = outputOptionsBuilder.setMetadata(metadata).build()
@ -366,7 +363,7 @@ class CameraXPreview(
imageCapture.takePicture(outputOptions, mainExecutor, object : OnImageSavedCallback { imageCapture.takePicture(outputOptions, mainExecutor, object : OnImageSavedCallback {
override fun onImageSaved(outputFileResults: OutputFileResults) { override fun onImageSaved(outputFileResults: OutputFileResults) {
listener.toggleBottomButtons(false) listener.toggleBottomButtons(false)
listener.onMediaCaptured(mediaOutput?.uri ?: outputFileResults.savedUri!!) listener.onMediaCaptured(mediaOutput.uri ?: outputFileResults.savedUri!!)
} }
override fun onError(exception: ImageCaptureException) { override fun onError(exception: ImageCaptureException) {
@ -403,20 +400,18 @@ class CameraXPreview(
private fun startRecording() { private fun startRecording() {
val videoCapture = videoCapture ?: throw IllegalStateException("Camera initialization failed.") val videoCapture = videoCapture ?: throw IllegalStateException("Camera initialization failed.")
val mediaOutput = mediaOutputHelper.getFileDescriptorMediaOutput() val mediaOutput = mediaOutputHelper.getVideoMediaOutput()
val recording = if (mediaOutput != null) { val recording = when (mediaOutput) {
is MediaOutput.FileDescriptorMediaOutput -> {
FileDescriptorOutputOptions.Builder(mediaOutput.fileDescriptor).build() FileDescriptorOutputOptions.Builder(mediaOutput.fileDescriptor).build()
.let { videoCapture.output.prepareRecording(activity, it) } .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) is MediaOutput.MediaStoreOutput -> {
MediaStoreOutputOptions.Builder(contentResolver, contentUri).setContentValues(contentValues).build() MediaStoreOutputOptions.Builder(contentResolver, mediaOutput.contentUri).setContentValues(mediaOutput.contentValues).build()
.let { videoCapture.output.prepareRecording(activity, it) } .let { videoCapture.output.prepareRecording(activity, it) }
} }
else -> throw IllegalArgumentException("Unexpected output option for video $mediaOutput")
}
currentRecording = recording.withAudioEnabled() currentRecording = recording.withAudioEnabled()
.start(mainExecutor) { recordEvent -> .start(mainExecutor) { recordEvent ->
@ -439,7 +434,7 @@ class CameraXPreview(
Log.e(TAG, "recording failed:", recordEvent.cause) Log.e(TAG, "recording failed:", recordEvent.cause)
cameraErrorHandler.handleVideoRecordingError(recordEvent.error) cameraErrorHandler.handleVideoRecordingError(recordEvent.error)
} else { } 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") 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() { private fun playShutterSoundIfEnabled() {
if (config.isSoundEnabled) { if (config.isSoundEnabled) {
mediaSoundHelper.playShutterSound() mediaSoundHelper.playShutterSound()

View File

@ -8,7 +8,7 @@ interface MyPreview {
fun onPaused() = Unit fun onPaused() = Unit
fun setTargetUri(uri: Uri) fun setTargetUri(uri: Uri) = Unit
fun setIsImageCaptureIntent(isImageCaptureIntent: Boolean) = Unit fun setIsImageCaptureIntent(isImageCaptureIntent: Boolean) = Unit

View File

@ -0,0 +1,25 @@
package com.simplemobiletools.camera.models
import android.content.ContentValues
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.OutputStream
sealed class MediaOutput(
open val uri: Uri?,
) {
data class MediaStoreOutput(
val contentValues: ContentValues,
val contentUri: Uri,
) : MediaOutput(null)
data class OutputStreamMediaOutput(
val outputStream: OutputStream,
override val uri: Uri,
) : MediaOutput(uri)
data class FileDescriptorMediaOutput(
val fileDescriptor: ParcelFileDescriptor,
override val uri: Uri,
) : MediaOutput(uri)
}

View File

@ -6,7 +6,7 @@
android:background="@android:color/black"> android:background="@android:color/black">
<androidx.camera.view.PreviewView <androidx.camera.view.PreviewView
android:id="@+id/view_finder" android:id="@+id/preview_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />