diff --git a/app/build.gradle b/app/build.gradle index a66fcaaf..fbc8af34 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" } diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index 37ff84c6..e7e9ee39 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,4 +1,5 @@ - Camera_debug + + CameraX_debug 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 adebc9d9..5f701f58 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/activities/MainActivity.kt @@ -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 { diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/extensions/CameraSelector.kt b/app/src/main/kotlin/com/simplemobiletools/camera/extensions/CameraSelector.kt new file mode 100644 index 00000000..0b98bca4 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/camera/extensions/CameraSelector.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/extensions/Int.kt b/app/src/main/kotlin/com/simplemobiletools/camera/extensions/Int.kt new file mode 100644 index 00000000..4cf4caf7 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/camera/extensions/Int.kt @@ -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 + } +} + diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/Config.kt b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/Config.kt index ca317988..5ede2c12 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/Config.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/Config.kt @@ -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() diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/Constants.kt b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/Constants.kt index b68dff71..0b543b7c 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/Constants.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/Constants.kt @@ -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" diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaSoundHelper.kt b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaSoundHelper.kt new file mode 100644 index 00000000..abc2e71a --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/MediaSoundHelper.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/PinchToZoomOnScaleGestureListener.kt b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/PinchToZoomOnScaleGestureListener.kt new file mode 100644 index 00000000..3289e433 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/PinchToZoomOnScaleGestureListener.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/helpers/ZoomCalculator.kt b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/ZoomCalculator.kt new file mode 100644 index 00000000..1e2aa294 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/camera/helpers/ZoomCalculator.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt new file mode 100644 index 00000000..580acecf --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreview.kt @@ -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? = 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 { + 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() + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreviewListener.kt b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreviewListener.kt new file mode 100644 index 00000000..b5758137 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/camera/implementations/CameraXPreviewListener.kt @@ -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) +} diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/interfaces/MyPreview.kt b/app/src/main/kotlin/com/simplemobiletools/camera/interfaces/MyPreview.kt index f1437148..232b936f 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/interfaces/MyPreview.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/interfaces/MyPreview.kt @@ -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 } diff --git a/app/src/main/kotlin/com/simplemobiletools/camera/views/CameraPreview.kt b/app/src/main/kotlin/com/simplemobiletools/camera/views/CameraPreview.kt index 5ffea60d..adf039ed 100644 --- a/app/src/main/kotlin/com/simplemobiletools/camera/views/CameraPreview.kt +++ b/app/src/main/kotlin/com/simplemobiletools/camera/views/CameraPreview.kt @@ -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() { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 71ec8765..94059da6 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -5,10 +5,10 @@ android:layout_height="match_parent" android:background="@android:color/black"> - +