Merge pull request #318 from KryptKode/feat/camera-x

Rewrite to use CameraX
This commit is contained in:
Tibor Kaputa 2022-06-26 22:54:25 +02:00 committed by GitHub
commit 749c86da14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 756 additions and 86 deletions

View File

@ -34,7 +34,7 @@ android {
buildTypes {
debug {
applicationIdSuffix ".debug"
applicationIdSuffix ".debugcamerax"
}
release {
minifyEnabled true
@ -65,4 +65,14 @@ dependencies {
implementation 'com.github.SimpleMobileTools:Simple-Commons:d1d5402388'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation "androidx.exifinterface:exifinterface:1.3.3"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
implementation 'androidx.window:window:1.1.0-alpha02'
def camerax_version = '1.1.0-rc02'
implementation "androidx.camera:camera-core:$camerax_version"
implementation "androidx.camera:camera-camera2:$camerax_version"
implementation "androidx.camera:camera-video:$camerax_version"
implementation "androidx.camera:camera-extensions:$camerax_version"
implementation "androidx.camera:camera-lifecycle:$camerax_version"
implementation "androidx.camera:camera-view:$camerax_version"
}

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_launcher_name">Camera_debug</string>
<!--TODO Revert to Camera_debug -->
<string name="app_launcher_name">CameraX_debug</string>
</resources>

View File

@ -7,7 +7,12 @@ import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.provider.MediaStore
import android.view.*
import android.util.Log
import android.view.KeyEvent
import android.view.OrientationEventListener
import android.view.View
import android.view.Window
import android.view.WindowManager
import android.widget.RelativeLayout
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -16,17 +21,40 @@ import com.bumptech.glide.request.RequestOptions
import com.simplemobiletools.camera.BuildConfig
import com.simplemobiletools.camera.R
import com.simplemobiletools.camera.extensions.config
import com.simplemobiletools.camera.helpers.*
import com.simplemobiletools.camera.helpers.FLASH_OFF
import com.simplemobiletools.camera.helpers.FLASH_ON
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.CameraXPreviewListener
import com.simplemobiletools.camera.implementations.MyCameraImpl
import com.simplemobiletools.camera.interfaces.MyPreview
import com.simplemobiletools.camera.views.CameraPreview
import com.simplemobiletools.camera.views.FocusCircleView
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.*
import com.simplemobiletools.commons.helpers.BROADCAST_REFRESH_MEDIA
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.models.Release
import kotlinx.android.synthetic.main.activity_main.*
import java.util.concurrent.TimeUnit
import kotlinx.android.synthetic.main.activity_main.btn_holder
import kotlinx.android.synthetic.main.activity_main.capture_black_screen
import kotlinx.android.synthetic.main.activity_main.change_resolution
import kotlinx.android.synthetic.main.activity_main.last_photo_video_preview
import kotlinx.android.synthetic.main.activity_main.settings
import kotlinx.android.synthetic.main.activity_main.shutter
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.view_holder
class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener, CameraXPreviewListener {
private val TAG = "MainActivity"
private val FADE_DELAY = 5000L
private val CAPTURE_ANIMATION_DURATION = 100L
@ -68,7 +96,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
override fun onResume() {
super.onResume()
if (hasStorageAndCameraPermissions()) {
mPreview?.onResumed()
resumeCameraItems()
setupPreviewImage(mIsInPhotoMode)
scheduleFadeOut()
@ -97,14 +124,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
hideTimer()
mOrientationEventListener.disable()
if (mPreview?.getCameraState() == STATE_PICTURE_TAKEN) {
toast(R.string.photo_not_saved)
}
ensureBackgroundThread {
mPreview?.onPaused()
}
}
override fun onDestroy() {
@ -201,8 +220,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
)
checkVideoCaptureIntent()
mPreview = CameraPreview(this, camera_texture_view, mIsInPhotoMode)
view_holder.addView(mPreview as ViewGroup)
mPreview = CameraXPreview(this, view_finder, this)
checkImageCaptureIntent()
mPreview?.setIsImageCaptureIntent(isImageCaptureIntent())
@ -217,7 +235,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
mFadeHandler = Handler()
setupPreviewImage(true)
val initialFlashlightState = FLASH_OFF
val initialFlashlightState = config.flashlightState
mPreview!!.setFlashlightState(initialFlashlightState)
updateFlashlightState(initialFlashlightState)
}
@ -261,10 +279,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
toggle_flash.setImageResource(flashDrawable)
}
fun updateCameraIcon(isUsingFrontCamera: Boolean) {
toggle_camera.setImageResource(if (isUsingFrontCamera) R.drawable.ic_camera_rear_vector else R.drawable.ic_camera_front_vector)
}
private fun shutterPressed() {
if (checkCameraAvailable()) {
handleShutter()
@ -283,19 +297,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
}
}
fun toggleBottomButtons(hide: Boolean) {
runOnUiThread {
val alpha = if (hide) 0f else 1f
shutter.animate().alpha(alpha).start()
toggle_camera.animate().alpha(alpha).start()
toggle_flash.animate().alpha(alpha).start()
shutter.isClickable = !hide
toggle_camera.isClickable = !hide
toggle_flash.isClickable = !hide
}
}
private fun launchSettings() {
if (settings.alpha == 1f) {
val intent = Intent(applicationContext, SettingsActivity::class.java)
@ -324,14 +325,13 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
}
if (mIsVideoCaptureIntent) {
mPreview?.tryInitVideoMode()
mPreview?.initVideoMode()
}
mPreview?.setFlashlightState(FLASH_OFF)
hideTimer()
mIsInPhotoMode = !mIsInPhotoMode
config.initPhotoMode = mIsInPhotoMode
showToggleCameraIfNeeded()
checkButtons()
toggleBottomButtons(false)
}
@ -352,9 +352,10 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
}
private fun tryInitVideoMode() {
if (mPreview?.initVideoMode() == true) {
try {
mPreview?.initVideoMode()
initVideoButtons()
} else {
} catch (e: Exception) {
if (!mIsVideoCaptureIntent) {
toast(R.string.video_mode_error)
}
@ -363,7 +364,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
private fun initVideoButtons() {
toggle_photo_video.setImageResource(R.drawable.ic_camera_vector)
showToggleCameraIfNeeded()
shutter.setImageResource(R.drawable.ic_video_rec)
setupPreviewImage(false)
mPreview?.checkFlashlight()
@ -378,6 +378,13 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
mPreviewUri = Uri.withAppendedPath(uri, lastMediaId.toString())
Log.e(TAG, "mPreviewUri= $mPreviewUri")
loadLastTakenMedia(mPreviewUri)
}
private fun loadLastTakenMedia(uri: Uri?) {
mPreviewUri = uri
runOnUiThread {
if (!isDestroyed) {
val options = RequestOptions()
@ -385,7 +392,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
.diskCacheStrategy(DiskCacheStrategy.NONE)
Glide.with(this)
.load(mPreviewUri)
.load(uri)
.apply(options)
.transition(DrawableTransitionOptions.withCrossFade())
.into(last_photo_video_preview)
@ -447,7 +454,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
}
private fun resumeCameraItems() {
showToggleCameraIfNeeded()
hideNavigationBarIcons()
if (!mIsInPhotoMode) {
@ -455,10 +461,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
}
}
private fun showToggleCameraIfNeeded() {
toggle_camera?.beInvisibleIf(mCameraImpl.getCountOfCameras() ?: 1 <= 1)
}
private fun hasStorageAndCameraPermissions() = hasPermission(PERMISSION_WRITE_STORAGE) && hasPermission(PERMISSION_CAMERA)
private fun setupOrientationEventListener() {
@ -505,7 +507,15 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
return mIsCameraAvailable
}
fun setFlashAvailable(available: Boolean) {
override fun setCameraAvailable(available: Boolean) {
mIsCameraAvailable = available
}
override fun setHasFrontAndBackCamera(hasFrontAndBack: Boolean) {
toggle_camera?.beVisibleIf(hasFrontAndBack)
}
override fun setFlashAvailable(available: Boolean) {
if (available) {
toggle_flash.beVisible()
} else {
@ -515,8 +525,51 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
}
}
fun setIsCameraAvailable(available: Boolean) {
mIsCameraAvailable = available
override fun onChangeCamera(frontCamera: Boolean) {
toggle_camera.setImageResource(if (frontCamera) R.drawable.ic_camera_rear_vector else R.drawable.ic_camera_front_vector)
}
override fun toggleBottomButtons(hide: Boolean) {
runOnUiThread {
val alpha = if (hide) 0f else 1f
shutter.animate().alpha(alpha).start()
toggle_camera.animate().alpha(alpha).start()
toggle_flash.animate().alpha(alpha).start()
shutter.isClickable = !hide
toggle_camera.isClickable = !hide
toggle_flash.isClickable = !hide
}
}
override fun onMediaCaptured(uri: Uri) {
loadLastTakenMedia(uri)
}
override fun onChangeFlashMode(flashMode: Int) {
updateFlashlightState(flashMode)
}
override fun onVideoRecordingStarted() {
shutter.setImageResource(R.drawable.ic_video_stop)
toggle_camera.beInvisible()
video_rec_curr_timer.beVisible()
}
override fun onVideoRecordingStopped() {
shutter.setImageResource(R.drawable.ic_video_rec)
video_rec_curr_timer.text = 0.getFormattedDuration()
video_rec_curr_timer.beGone()
toggle_camera.beVisible()
}
override fun onVideoDurationChanged(durationNanos: Long) {
val seconds = TimeUnit.NANOSECONDS.toSeconds(durationNanos).toInt()
video_rec_curr_timer.text = seconds.getFormattedDuration()
}
override fun onFocusCamera(xPos: Float, yPos: Float) {
mFocusCircleView.drawFocusCircle(xPos, yPos)
}
fun setRecordingState(isRecording: Boolean) {
@ -527,7 +580,6 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
showTimer()
} else {
shutter.setImageResource(R.drawable.ic_video_rec)
showToggleCameraIfNeeded()
hideTimer()
}
}
@ -548,6 +600,7 @@ class MainActivity : SimpleActivity(), PhotoProcessor.MediaSavedListener {
fun drawFocusCircle(x: Float, y: Float) = mFocusCircleView.drawFocusCircle(x, y)
override fun mediaSaved(path: String) {
Log.e(TAG, "mediaSaved: $path")
rescanPaths(arrayListOf(path)) {
setupPreviewImage(true)
Intent(BROADCAST_REFRESH_MEDIA).apply {

View File

@ -0,0 +1,11 @@
package com.simplemobiletools.camera.extensions
import androidx.camera.core.CameraSelector
fun CameraSelector.toLensFacing(): Int {
return if (this == CameraSelector.DEFAULT_FRONT_CAMERA) {
CameraSelector.LENS_FACING_FRONT
} else {
CameraSelector.LENS_FACING_BACK
}
}

View File

@ -0,0 +1,35 @@
package com.simplemobiletools.camera.extensions
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import com.simplemobiletools.camera.helpers.FLASH_AUTO
import com.simplemobiletools.camera.helpers.FLASH_OFF
import com.simplemobiletools.camera.helpers.FLASH_ON
import java.lang.IllegalArgumentException
fun Int.toCameraXFlashMode(): Int {
return when (this) {
FLASH_ON -> ImageCapture.FLASH_MODE_ON
FLASH_OFF -> ImageCapture.FLASH_MODE_OFF
FLASH_AUTO -> ImageCapture.FLASH_MODE_AUTO
else -> throw IllegalArgumentException("Unknown mode: $this")
}
}
fun Int.toAppFlashMode(): Int {
return when (this) {
ImageCapture.FLASH_MODE_ON -> FLASH_ON
ImageCapture.FLASH_MODE_OFF -> FLASH_OFF
ImageCapture.FLASH_MODE_AUTO -> FLASH_AUTO
else -> throw IllegalArgumentException("Unknown mode: $this")
}
}
fun Int.toCameraSelector(): CameraSelector {
return if (this == CameraSelector.LENS_FACING_FRONT) {
CameraSelector.DEFAULT_FRONT_CAMERA
} else {
CameraSelector.DEFAULT_BACK_CAMERA
}
}

View File

@ -2,6 +2,7 @@ package com.simplemobiletools.camera.helpers
import android.content.Context
import android.os.Environment
import androidx.camera.core.CameraSelector
import com.simplemobiletools.commons.helpers.BaseConfig
import java.io.File
@ -37,6 +38,10 @@ class Config(context: Context) : BaseConfig(context) {
get() = prefs.getString(LAST_USED_CAMERA, "0")!!
set(cameraId) = prefs.edit().putString(LAST_USED_CAMERA, cameraId).apply()
var lastUsedCameraLens: Int
get() = prefs.getInt(LAST_USED_CAMERA_LENS, CameraSelector.LENS_FACING_BACK)
set(lens) = prefs.edit().putInt(LAST_USED_CAMERA_LENS, lens).apply()
var initPhotoMode: Boolean
get() = prefs.getBoolean(INIT_PHOTO_MODE, true)
set(initPhotoMode) = prefs.edit().putBoolean(INIT_PHOTO_MODE, initPhotoMode).apply()

View File

@ -10,6 +10,7 @@ const val SOUND = "sound"
const val VOLUME_BUTTONS_AS_SHUTTER = "volume_buttons_as_shutter"
const val FLIP_PHOTOS = "flip_photos"
const val LAST_USED_CAMERA = "last_used_camera_2"
const val LAST_USED_CAMERA_LENS = "last_used_camera_lens"
const val FLASHLIGHT_STATE = "flashlight_state"
const val INIT_PHOTO_MODE = "init_photo_mode"
const val BACK_PHOTO_RESOLUTION_INDEX = "back_photo_resolution_index_2"

View File

@ -0,0 +1,25 @@
package com.simplemobiletools.camera.helpers
import android.media.MediaActionSound
class MediaSoundHelper {
private val mediaActionSound = MediaActionSound()
fun loadSounds() {
mediaActionSound.load(MediaActionSound.START_VIDEO_RECORDING)
mediaActionSound.load(MediaActionSound.STOP_VIDEO_RECORDING)
mediaActionSound.load(MediaActionSound.SHUTTER_CLICK)
}
fun playShutterSound() {
mediaActionSound.play(MediaActionSound.SHUTTER_CLICK)
}
fun playStartVideoRecordingSound() {
mediaActionSound.play(MediaActionSound.START_VIDEO_RECORDING)
}
fun playStopVideoRecordingSound() {
mediaActionSound.play(MediaActionSound.STOP_VIDEO_RECORDING)
}
}

View File

@ -0,0 +1,19 @@
package com.simplemobiletools.camera.helpers
import android.view.ScaleGestureDetector
import androidx.camera.core.CameraControl
import androidx.camera.core.CameraInfo
class PinchToZoomOnScaleGestureListener(
private val cameraInfo: CameraInfo,
private val cameraControl: CameraControl,
) : ScaleGestureDetector.SimpleOnScaleGestureListener() {
private val zoomCalculator = ZoomCalculator()
override fun onScale(detector: ScaleGestureDetector): Boolean {
val zoomState = cameraInfo.zoomState.value ?: return false
val zoomRatio = zoomCalculator.calculateZoomRatio(zoomState, detector.scaleFactor)
cameraControl.setZoomRatio(zoomRatio)
return true
}
}

View File

@ -0,0 +1,20 @@
package com.simplemobiletools.camera.helpers
import androidx.camera.core.ZoomState
class ZoomCalculator {
fun calculateZoomRatio(zoomState: ZoomState, pinchToZoomScale: Float): Float {
val clampedRatio = zoomState.zoomRatio * speedUpZoomBy2X(pinchToZoomScale)
// Clamp the ratio with the zoom range.
return clampedRatio.coerceAtLeast(zoomState.minZoomRatio).coerceAtMost(zoomState.maxZoomRatio)
}
private fun speedUpZoomBy2X(scaleFactor: Float): Float {
return if (scaleFactor > 1f) {
1.0f + (scaleFactor - 1.0f) * 2
} else {
1.0f - (1.0f - scaleFactor) * 2
}
}
}

View File

@ -0,0 +1,485 @@
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.*
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.lifecycle.ProcessCameraProvider
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.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.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 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 = activity.mainExecutor
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 = 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()
imageCapture.takePicture(outputOptions, mainExecutor, object : OnImageSavedCallback {
override fun onImageSaved(outputFileResults: OutputFileResults) {
listener.toggleBottomButtons(false)
listener.onMediaCaptured(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 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()
.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(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()
}
}
}

View File

@ -0,0 +1,17 @@
package com.simplemobiletools.camera.implementations
import android.net.Uri
interface CameraXPreviewListener {
fun setCameraAvailable(available: Boolean)
fun setHasFrontAndBackCamera(hasFrontAndBack:Boolean)
fun setFlashAvailable(available: Boolean)
fun onChangeCamera(frontCamera: Boolean)
fun toggleBottomButtons(hide:Boolean)
fun onMediaCaptured(uri: Uri)
fun onChangeFlashMode(flashMode: Int)
fun onVideoRecordingStarted()
fun onVideoRecordingStopped()
fun onVideoDurationChanged(durationNanos: Long)
fun onFocusCamera(xPos: Float, yPos: Float)
}

View File

@ -3,17 +3,18 @@ package com.simplemobiletools.camera.interfaces
import android.net.Uri
interface MyPreview {
fun onResumed()
fun onPaused()
fun onResumed() = Unit
fun onPaused() = Unit
fun setTargetUri(uri: Uri)
fun setIsImageCaptureIntent(isImageCaptureIntent: Boolean)
fun setIsImageCaptureIntent(isImageCaptureIntent: Boolean) = Unit
fun setFlashlightState(state: Int)
fun setFlashlightState(state: Int) = Unit
fun getCameraState(): Int
fun getCameraState(): Int = 0
fun showChangeResolutionDialog()
@ -25,11 +26,9 @@ interface MyPreview {
fun toggleRecording()
fun tryInitVideoMode()
fun initPhotoMode()
fun initVideoMode(): Boolean
fun initVideoMode()
fun checkFlashlight()
fun checkFlashlight() = Unit
}

View File

@ -96,7 +96,7 @@ class CameraPreview : ViewGroup, TextureView.SurfaceTextureListener, MyPreview {
private val mCameraToPreviewMatrix = Matrix()
private val mPreviewToCameraMatrix = Matrix()
private val mCameraOpenCloseLock = Semaphore(1)
private val mMediaActionSound = MediaActionSound()
private val mediaSoundHelper = MediaSoundHelper()
private var mZoomRect: Rect? = null
constructor(context: Context) : super(context)
@ -114,7 +114,7 @@ class CameraPreview : ViewGroup, TextureView.SurfaceTextureListener, MyPreview {
mUseFrontCamera = false
mIsInVideoMode = !initPhotoMode
loadSounds()
mediaSoundHelper.loadSounds()
val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
@ -182,12 +182,6 @@ class CameraPreview : ViewGroup, TextureView.SurfaceTextureListener, MyPreview {
}
}
private fun loadSounds() {
mMediaActionSound.load(MediaActionSound.START_VIDEO_RECORDING)
mMediaActionSound.load(MediaActionSound.STOP_VIDEO_RECORDING)
mMediaActionSound.load(MediaActionSound.SHUTTER_CLICK)
}
@SuppressLint("MissingPermission")
private fun openCamera(width: Int, height: Int) {
try {
@ -369,7 +363,7 @@ class CameraPreview : ViewGroup, TextureView.SurfaceTextureListener, MyPreview {
mIsFocusSupported = get(CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES)!!.size > 1
}
mActivity.setFlashAvailable(mIsFlashSupported)
mActivity.updateCameraIcon(mUseFrontCamera)
mActivity.onChangeCamera(mUseFrontCamera)
return
}
} catch (e: Exception) {
@ -429,21 +423,21 @@ class CameraPreview : ViewGroup, TextureView.SurfaceTextureListener, MyPreview {
mCameraOpenCloseLock.release()
mCameraDevice = cameraDevice
createCameraPreviewSession()
mActivity.setIsCameraAvailable(true)
mActivity.setCameraAvailable(true)
}
override fun onDisconnected(cameraDevice: CameraDevice) {
mCameraOpenCloseLock.release()
cameraDevice.close()
mCameraDevice = null
mActivity.setIsCameraAvailable(false)
mActivity.setCameraAvailable(false)
}
override fun onError(cameraDevice: CameraDevice, error: Int) {
mCameraOpenCloseLock.release()
cameraDevice.close()
mCameraDevice = null
mActivity.setIsCameraAvailable(false)
mActivity.setCameraAvailable(false)
}
}
@ -576,7 +570,7 @@ class CameraPreview : ViewGroup, TextureView.SurfaceTextureListener, MyPreview {
}
if (mActivity.config.isSoundEnabled) {
mMediaActionSound.play(MediaActionSound.SHUTTER_CLICK)
mediaSoundHelper.playShutterSound()
}
mCameraState = STATE_PICTURE_TAKEN
@ -824,7 +818,7 @@ class CameraPreview : ViewGroup, TextureView.SurfaceTextureListener, MyPreview {
closeCaptureSession()
setupMediaRecorder()
if (mActivity.config.isSoundEnabled) {
mMediaActionSound.play(MediaActionSound.START_VIDEO_RECORDING)
mediaSoundHelper.playStartVideoRecordingSound()
}
try {
@ -853,7 +847,7 @@ class CameraPreview : ViewGroup, TextureView.SurfaceTextureListener, MyPreview {
private fun stopRecording() {
mCameraState = STATE_STOPING_RECORDING
if (mActivity.config.isSoundEnabled) {
mMediaActionSound.play(MediaActionSound.STOP_VIDEO_RECORDING)
mediaSoundHelper.playStopVideoRecordingSound()
}
mIsRecording = false
@ -981,23 +975,18 @@ class CameraPreview : ViewGroup, TextureView.SurfaceTextureListener, MyPreview {
}
}
override fun tryInitVideoMode() {
initVideoMode()
}
override fun initPhotoMode() {
mIsInVideoMode = false
closeCamera()
openCamera(mTextureView.width, mTextureView.height)
}
override fun initVideoMode(): Boolean {
override fun initVideoMode() {
mLastFocusX = 0f
mLastFocusY = 0f
mIsInVideoMode = true
closeCamera()
openCamera(mTextureView.width, mTextureView.height)
return true
}
override fun checkFlashlight() {

View File

@ -5,10 +5,10 @@
android:layout_height="match_parent"
android:background="@android:color/black">
<com.simplemobiletools.camera.views.AutoFitTextureView
android:id="@+id/camera_texture_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<androidx.camera.view.PreviewView
android:id="@+id/view_finder"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ImageView
android:id="@+id/capture_black_screen"