mirror of
https://github.com/SimpleMobileTools/Simple-Camera.git
synced 2025-06-27 09:02:59 +02:00
- add MediaOutputHelper - to create OutputStream for photos - to create FileDescriptor for videos (currently requires API 26+)
508 lines
20 KiB
Kotlin
508 lines
20 KiB
Kotlin
package com.simplemobiletools.camera.implementations
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.content.ContentValues
|
|
import android.content.Context
|
|
import android.hardware.SensorManager
|
|
import android.hardware.display.DisplayManager
|
|
import android.net.Uri
|
|
import android.os.Environment
|
|
import android.provider.MediaStore
|
|
import android.util.Log
|
|
import android.view.Display
|
|
import android.view.GestureDetector
|
|
import android.view.GestureDetector.SimpleOnGestureListener
|
|
import android.view.MotionEvent
|
|
import android.view.OrientationEventListener
|
|
import android.view.ScaleGestureDetector
|
|
import android.view.Surface
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
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
|
|
import androidx.camera.core.ImageCapture.FLASH_MODE_ON
|
|
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
|
|
import androidx.camera.video.Recorder
|
|
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
|
|
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.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
|
|
import com.simplemobiletools.commons.extensions.showErrorToast
|
|
import com.simplemobiletools.commons.extensions.toast
|
|
import java.text.SimpleDateFormat
|
|
import java.util.Date
|
|
import java.util.Locale
|
|
import kotlin.math.abs
|
|
import kotlin.math.max
|
|
import kotlin.math.min
|
|
|
|
class CameraXPreview(
|
|
private val activity: AppCompatActivity,
|
|
private val previewView: PreviewView,
|
|
private val mediaOutputHelper: MediaOutputHelper,
|
|
private val listener: CameraXPreviewListener,
|
|
) : MyPreview, DefaultLifecycleObserver {
|
|
|
|
companion object {
|
|
private const val TAG = "CameraXPreview"
|
|
private const val RATIO_4_3_VALUE = 4.0 / 3.0
|
|
private const val RATIO_16_9_VALUE = 16.0 / 9.0
|
|
|
|
// Auto focus is 1/6 of the area.
|
|
private const val AF_SIZE = 1.0f / 6.0f
|
|
private const val AE_SIZE = AF_SIZE * 1.5f
|
|
}
|
|
|
|
private val config = activity.config
|
|
private val contentResolver = activity.contentResolver
|
|
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()
|
|
|
|
private val orientationEventListener = object : OrientationEventListener(activity, SensorManager.SENSOR_DELAY_NORMAL) {
|
|
@SuppressLint("RestrictedApi")
|
|
override fun onOrientationChanged(orientation: Int) {
|
|
if (orientation == UNKNOWN_ORIENTATION) {
|
|
return
|
|
}
|
|
|
|
val rotation = when (orientation) {
|
|
in 45 until 135 -> Surface.ROTATION_270
|
|
in 135 until 225 -> Surface.ROTATION_180
|
|
in 225 until 315 -> Surface.ROTATION_90
|
|
else -> Surface.ROTATION_0
|
|
}
|
|
|
|
preview?.targetRotation = rotation
|
|
imageCapture?.targetRotation = rotation
|
|
videoCapture?.targetRotation = rotation
|
|
}
|
|
}
|
|
|
|
private var preview: Preview? = null
|
|
private var cameraProvider: ProcessCameraProvider? = null
|
|
private var imageCapture: ImageCapture? = null
|
|
private var videoCapture: VideoCapture<Recorder>? = null
|
|
private var camera: Camera? = null
|
|
private var currentRecording: Recording? = null
|
|
private var recordingState: VideoRecordEvent? = null
|
|
private var cameraSelector = config.lastUsedCameraLens.toCameraSelector()
|
|
private var flashMode = config.flashlightState.toCameraXFlashMode()
|
|
private var isPhotoCapture = config.initPhotoMode
|
|
|
|
init {
|
|
bindToLifeCycle()
|
|
mediaSoundHelper.loadSounds()
|
|
previewView.doOnLayout {
|
|
startCamera()
|
|
}
|
|
}
|
|
|
|
private fun bindToLifeCycle() {
|
|
activity.lifecycle.addObserver(this)
|
|
}
|
|
|
|
private fun startCamera() {
|
|
Log.i(TAG, "startCamera: ")
|
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(activity)
|
|
cameraProviderFuture.addListener({
|
|
try {
|
|
cameraProvider = cameraProviderFuture.get()
|
|
bindCameraUseCases()
|
|
setupCameraObservers()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "startCamera: ", e)
|
|
activity.showErrorToast(activity.getString(R.string.camera_open_error))
|
|
}
|
|
}, mainExecutor)
|
|
}
|
|
|
|
private fun bindCameraUseCases() {
|
|
val cameraProvider = cameraProvider ?: throw IllegalStateException("Camera initialization failed.")
|
|
val metrics = windowMetricsCalculator.computeCurrentWindowMetrics(activity).bounds
|
|
val aspectRatio = aspectRatio(metrics.width(), metrics.height())
|
|
val rotation = previewView.display.rotation
|
|
|
|
preview = buildPreview(aspectRatio, rotation)
|
|
val captureUseCase = getCaptureUseCase(aspectRatio, rotation)
|
|
cameraProvider.unbindAll()
|
|
camera = cameraProvider.bindToLifecycle(
|
|
activity,
|
|
cameraSelector,
|
|
preview,
|
|
captureUseCase,
|
|
)
|
|
preview?.setSurfaceProvider(previewView.surfaceProvider)
|
|
setupZoomAndFocus()
|
|
}
|
|
|
|
private fun setupCameraObservers() {
|
|
listener.setFlashAvailable(camera?.cameraInfo?.hasFlashUnit() ?: false)
|
|
listener.onChangeCamera(isFrontCameraInUse())
|
|
|
|
camera?.cameraInfo?.cameraState?.observe(activity) { cameraState ->
|
|
when (cameraState.type) {
|
|
CameraState.Type.OPEN,
|
|
CameraState.Type.OPENING -> {
|
|
listener.setHasFrontAndBackCamera(hasFrontCamera() && hasBackCamera())
|
|
listener.setCameraAvailable(true)
|
|
}
|
|
CameraState.Type.PENDING_OPEN,
|
|
CameraState.Type.CLOSING,
|
|
CameraState.Type.CLOSED -> {
|
|
listener.setCameraAvailable(false)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun getCaptureUseCase(aspectRatio: Int, rotation: Int): UseCase {
|
|
return if (isPhotoCapture) {
|
|
cameraProvider?.unbind(videoCapture)
|
|
buildImageCapture(aspectRatio, rotation).also {
|
|
imageCapture = it
|
|
}
|
|
} else {
|
|
cameraProvider?.unbind(imageCapture)
|
|
buildVideoCapture().also {
|
|
videoCapture = it
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun buildImageCapture(aspectRatio: Int, rotation: Int): ImageCapture {
|
|
return ImageCapture.Builder()
|
|
.setCaptureMode(CAPTURE_MODE_MAXIMIZE_QUALITY)
|
|
.setFlashMode(flashMode)
|
|
.setJpegQuality(config.photoQuality)
|
|
.setTargetAspectRatio(aspectRatio)
|
|
.setTargetRotation(rotation)
|
|
.build()
|
|
}
|
|
|
|
private fun buildPreview(aspectRatio: Int, rotation: Int): Preview {
|
|
return Preview.Builder()
|
|
.setTargetAspectRatio(aspectRatio)
|
|
.setTargetRotation(rotation)
|
|
.build()
|
|
}
|
|
|
|
private fun buildVideoCapture(): VideoCapture<Recorder> {
|
|
val recorder = Recorder.Builder()
|
|
//TODO: user control for quality
|
|
.setQualitySelector(QualitySelector.from(Quality.FHD))
|
|
.build()
|
|
return VideoCapture.withOutput(recorder)
|
|
}
|
|
|
|
private fun aspectRatio(width: Int, height: Int): Int {
|
|
val previewRatio = max(width, height).toDouble() / min(width, height)
|
|
if (abs(previewRatio - RATIO_4_3_VALUE) <= abs(previewRatio - RATIO_16_9_VALUE)) {
|
|
return AspectRatio.RATIO_4_3
|
|
}
|
|
return AspectRatio.RATIO_16_9
|
|
}
|
|
|
|
private fun hasBackCamera(): Boolean {
|
|
return cameraProvider?.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) ?: false
|
|
}
|
|
|
|
private fun hasFrontCamera(): Boolean {
|
|
return cameraProvider?.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) ?: false
|
|
}
|
|
|
|
private fun isFrontCameraInUse(): Boolean {
|
|
return cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
|
|
}
|
|
|
|
@SuppressLint("ClickableViewAccessibility")
|
|
// source: https://stackoverflow.com/a/60095886/10552591
|
|
private fun setupZoomAndFocus() {
|
|
Log.i(TAG, "camera controller: ${previewView.controller}")
|
|
val scaleGesture = camera?.let { ScaleGestureDetector(activity, PinchToZoomOnScaleGestureListener(it.cameraInfo, it.cameraControl)) }
|
|
val gestureDetector = GestureDetector(activity, object : SimpleOnGestureListener() {
|
|
override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
|
|
return camera?.cameraInfo?.let {
|
|
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
|
val width = previewView.width.toFloat()
|
|
val height = previewView.height.toFloat()
|
|
Log.i(TAG, "onSingleTapConfirmed: width=$width,height=$height")
|
|
val factory = DisplayOrientedMeteringPointFactory(display, it, width, height)
|
|
val xPos = event.x
|
|
val yPos = event.y
|
|
val autoFocusPoint = factory.createPoint(xPos, yPos, AF_SIZE)
|
|
val autoExposurePoint = factory.createPoint(xPos, yPos, AE_SIZE)
|
|
val focusMeteringAction = FocusMeteringAction.Builder(autoFocusPoint, FocusMeteringAction.FLAG_AF)
|
|
.addPoint(autoExposurePoint, FocusMeteringAction.FLAG_AE)
|
|
.disableAutoCancel()
|
|
.build()
|
|
camera?.cameraControl?.startFocusAndMetering(focusMeteringAction)
|
|
listener.onFocusCamera(xPos, yPos)
|
|
Log.i(TAG, "start focus")
|
|
true
|
|
} ?: false
|
|
}
|
|
})
|
|
previewView.setOnTouchListener { _, event ->
|
|
Log.i(TAG, "setOnTouchListener: x=${event.x}, y=${event.y}")
|
|
gestureDetector.onTouchEvent(event)
|
|
scaleGesture?.onTouchEvent(event)
|
|
true
|
|
}
|
|
}
|
|
|
|
override fun onStart(owner: LifecycleOwner) {
|
|
orientationEventListener.enable()
|
|
}
|
|
|
|
override fun onStop(owner: LifecycleOwner) {
|
|
orientationEventListener.disable()
|
|
}
|
|
|
|
override fun setTargetUri(uri: Uri) {
|
|
|
|
}
|
|
|
|
override fun showChangeResolutionDialog() {
|
|
|
|
}
|
|
|
|
override fun toggleFrontBackCamera() {
|
|
val newCameraSelector = if (isFrontCameraInUse()) {
|
|
CameraSelector.DEFAULT_BACK_CAMERA
|
|
} else {
|
|
CameraSelector.DEFAULT_FRONT_CAMERA
|
|
}
|
|
cameraSelector = newCameraSelector
|
|
config.lastUsedCameraLens = newCameraSelector.toLensFacing()
|
|
startCamera()
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
flashMode = newFlashMode
|
|
imageCapture?.flashMode = newFlashMode
|
|
val appFlashMode = flashMode.toAppFlashMode()
|
|
config.flashlightState = appFlashMode
|
|
listener.onChangeFlashMode(appFlashMode)
|
|
}
|
|
|
|
override fun tryTakePicture() {
|
|
Log.i(TAG, "captureImage: ")
|
|
val imageCapture = imageCapture ?: throw IllegalStateException("Camera initialization failed.")
|
|
|
|
val metadata = Metadata().apply {
|
|
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 outputOptions = outputOptionsBuilder.setMetadata(metadata).build()
|
|
|
|
imageCapture.takePicture(outputOptions, mainExecutor, object : OnImageSavedCallback {
|
|
override fun onImageSaved(outputFileResults: OutputFileResults) {
|
|
listener.toggleBottomButtons(false)
|
|
listener.onMediaCaptured(mediaOutput?.uri ?: outputFileResults.savedUri!!)
|
|
}
|
|
|
|
override fun onError(exception: ImageCaptureException) {
|
|
listener.toggleBottomButtons(false)
|
|
activity.showErrorToast("Capture picture $exception")
|
|
Log.e(TAG, "Error", exception)
|
|
}
|
|
})
|
|
playShutterSoundIfEnabled()
|
|
}
|
|
|
|
override fun initPhotoMode() {
|
|
isPhotoCapture = true
|
|
startCamera()
|
|
}
|
|
|
|
override fun initVideoMode() {
|
|
isPhotoCapture = false
|
|
startCamera()
|
|
}
|
|
|
|
override fun toggleRecording() {
|
|
Log.d(TAG, "toggleRecording: currentRecording=$currentRecording, recordingState=$recordingState")
|
|
if (currentRecording == null || recordingState is VideoRecordEvent.Finalize) {
|
|
startRecording()
|
|
} else {
|
|
currentRecording?.stop()
|
|
currentRecording = null
|
|
Log.d(TAG, "Recording stopped")
|
|
}
|
|
}
|
|
|
|
@SuppressLint("MissingPermission")
|
|
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 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
|
|
when (recordEvent) {
|
|
is VideoRecordEvent.Start -> {
|
|
playStartVideoRecordingSoundIfEnabled()
|
|
listener.onVideoRecordingStarted()
|
|
}
|
|
|
|
is VideoRecordEvent.Status -> {
|
|
listener.onVideoDurationChanged(recordEvent.recordingStats.recordedDurationNanos)
|
|
}
|
|
|
|
is VideoRecordEvent.Finalize -> {
|
|
playStopVideoRecordingSoundIfEnabled()
|
|
listener.onVideoRecordingStopped()
|
|
if (recordEvent.hasError()) {
|
|
// TODO: Handle errors
|
|
} else {
|
|
listener.onMediaCaptured(mediaOutput?.uri ?: recordEvent.outputResults.outputUri)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
|
|
private fun playStartVideoRecordingSoundIfEnabled() {
|
|
if (config.isSoundEnabled) {
|
|
mediaSoundHelper.playStartVideoRecordingSound()
|
|
}
|
|
}
|
|
|
|
private fun playStopVideoRecordingSoundIfEnabled() {
|
|
if (config.isSoundEnabled) {
|
|
mediaSoundHelper.playStopVideoRecordingSound()
|
|
}
|
|
}
|
|
}
|