diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java index fbba84d920..7e0aff0688 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -15,6 +15,7 @@ import android.util.Size; import android.view.GestureDetector; import android.view.LayoutInflater; import android.view.MotionEvent; +import android.view.OrientationEventListener; import android.view.Surface; import android.view.View; import android.view.ViewGroup; @@ -30,18 +31,12 @@ import androidx.camera.core.CameraSelector; import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageCaptureException; import androidx.camera.core.ImageProxy; -import androidx.camera.video.FallbackStrategy; -import androidx.camera.video.Quality; -import androidx.camera.video.QualitySelector; -import androidx.camera.view.CameraController; -import androidx.camera.view.LifecycleCameraController; import androidx.camera.view.PreviewView; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintSet; import androidx.core.content.ContextCompat; import com.bumptech.glide.Glide; -import com.bumptech.glide.util.Executors; import com.google.android.material.card.MaterialCardView; import org.signal.core.util.Stopwatch; @@ -54,6 +49,7 @@ import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.mediasend.camerax.CameraXFlashToggleView; import org.thoughtcrime.securesms.mediasend.camerax.CameraXModePolicy; import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; +import org.thoughtcrime.securesms.mediasend.camerax.SignalCameraController; import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations; import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; @@ -87,7 +83,8 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { private Controller controller; private View selfieFlash; private MemoryFileDescriptor videoFileDescriptor; - private LifecycleCameraController cameraController; + private SignalCameraController cameraController; + private CameraXOrientationListener orientationListener; private Disposable mostRecentItemDisposable = Disposable.disposed(); private CameraXModePolicy cameraXModePolicy; private CameraScreenBrightnessController cameraScreenBrightnessController; @@ -124,6 +121,8 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { if (controller == null) { throw new IllegalStateException("Parent must implement controller interface."); } + + this.orientationListener = new CameraXOrientationListener(context); } @Override @@ -144,11 +143,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { Log.d(TAG, "Starting CameraX with mode policy " + cameraXModePolicy.getClass().getSimpleName()); - cameraController = new LifecycleCameraController(requireContext()); - cameraController.bindToLifecycle(getViewLifecycleOwner()); - cameraController.setCameraSelector(CameraXUtil.toCameraSelector(TextSecurePreferences.getDirectCaptureCameraId(requireContext()))); - cameraController.setTapToFocusEnabled(true); - cameraController.setImageCaptureMode(CameraXUtil.getOptimalCaptureMode()); + cameraController = new SignalCameraController(requireContext(), getViewLifecycleOwner(), previewView); cameraXModePolicy.initialize(cameraController); cameraScreenBrightnessController = new CameraScreenBrightnessController( @@ -157,9 +152,9 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { ); previewView.setScaleType(PREVIEW_SCALE_TYPE); - previewView.setController(cameraController); onOrientationChanged(); + cameraController.bindToLifecycle(() -> Log.d(TAG, "Camera init complete from onViewCreated")); view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { // Let's assume portrait for now, so 9:16 @@ -175,16 +170,29 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { params.height = (int) height; cameraParent.setLayoutParams(params); - cameraController.setPreviewTargetSize(new CameraController.OutputSize(new Size((int) width, (int) height))); + cameraController.setPreviewTargetSize(new Size((int) width, (int) height)); } }); } + @Override + public void onStart() { + super.onStart(); + orientationListener.enable(); + } + + @Override + public void onStop() { + super.onStop(); + orientationListener.disable(); + } + @Override public void onResume() { super.onResume(); - cameraController.bindToLifecycle(getViewLifecycleOwner()); + cameraController.bindToLifecycle(() -> Log.d(TAG, "Camera init complete from onResume")); + requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } @@ -237,10 +245,8 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { int resolution = CameraXUtil.getIdealResolution(Resources.getSystem().getDisplayMetrics().widthPixels, Resources.getSystem().getDisplayMetrics().heightPixels); Size size = CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, true); - CameraController.OutputSize outputSize = new CameraController.OutputSize(size); - cameraController.setImageCaptureTargetSize(outputSize); - cameraController.setVideoCaptureQualitySelector(QualitySelector.from(Quality.HD, FallbackStrategy.lowerQualityThan(Quality.HD))); + cameraController.setImageCaptureTargetSize(size); controlsContainer.removeAllViews(); controlsContainer.addView(LayoutInflater.from(getContext()).inflate(layout, controlsContainer, false)); @@ -339,8 +345,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { previewView.setScaleType(PREVIEW_SCALE_TYPE); - cameraController.getInitializationFuture() - .addListener(() -> initializeFlipButton(flipButton, flashButton), Executors.mainThreadExecutor()); + cameraController.addInitializationCompletedListener(cameraProvider -> initializeFlipButton(flipButton, flashButton)); flashButton.setAutoFlashEnabled(cameraController.getImageCaptureFlashMode() >= ImageCapture.FLASH_MODE_AUTO); flashButton.setFlash(cameraController.getImageCaptureFlashMode()); @@ -476,7 +481,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { ); flashHelper.onWillTakePicture(); - cameraController.takePicture(Executors.mainThreadExecutor(), new ImageCapture.OnImageCapturedCallback() { + cameraController.takePicture(ContextCompat.getMainExecutor(requireContext()), new ImageCapture.OnImageCapturedCallback() { @Override public void onCaptureSuccess(@NonNull ImageProxy image) { flashHelper.endFlash(); @@ -505,8 +510,8 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { } @Override - public void onError(ImageCaptureException exception) { - Log.w(TAG, "Failed to capture image", exception); + public void onError(@NonNull ImageCaptureException exception) { + Log.w(TAG, "Failed to capture image due to error " + exception.getImageCaptureError(), exception.getCause()); flashHelper.endFlash(); controller.onCameraError(); } @@ -571,9 +576,9 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { private static class CameraStateProvider implements CameraScreenBrightnessController.CameraStateProvider { - private final CameraController cameraController; + private final SignalCameraController cameraController; - private CameraStateProvider(CameraController cameraController) { + private CameraStateProvider(SignalCameraController cameraController) { this.cameraController = cameraController; } @@ -587,4 +592,18 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { return cameraController.getImageCaptureFlashMode() == ImageCapture.FLASH_MODE_ON; } } + + private class CameraXOrientationListener extends OrientationEventListener { + + public CameraXOrientationListener(Context context) { + super(context); + } + + @Override + public void onOrientationChanged(int orientation) { + if (cameraController != null) { + cameraController.setImageRotation(orientation); + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java index b299d45792..fd516e2861 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java @@ -7,23 +7,24 @@ import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.camera.core.CameraSelector; import androidx.camera.core.ImageCapture; -import androidx.camera.view.CameraController; + +import org.thoughtcrime.securesms.mediasend.camerax.SignalCameraController; final class CameraXSelfieFlashHelper { private static final float MAX_SCREEN_BRIGHTNESS = 1f; private static final float MAX_SELFIE_FLASH_ALPHA = 0.9f; - private final Window window; - private final CameraController camera; - private final View selfieFlash; + private final Window window; + private final SignalCameraController camera; + private final View selfieFlash; private float brightnessBeforeFlash; private boolean inFlash; private int flashMode = -1; CameraXSelfieFlashHelper(@NonNull Window window, - @NonNull CameraController camera, + @NonNull SignalCameraController camera, @NonNull View selfieFlash) { this.window = window; @@ -68,7 +69,7 @@ final class CameraXSelfieFlashHelper { } private boolean shouldUseViewBasedFlash() { - CameraSelector cameraSelector = camera.getCameraSelector() ; + CameraSelector cameraSelector = camera.getCameraSelector(); return (camera.getImageCaptureFlashMode() == ImageCapture.FLASH_MODE_ON || flashMode == ImageCapture.FLASH_MODE_ON) && cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java index 77d50bbd1c..b387698931 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java @@ -17,7 +17,6 @@ import androidx.camera.core.ZoomState; import androidx.camera.video.FileDescriptorOutputOptions; import androidx.camera.video.Recording; import androidx.camera.video.VideoRecordEvent; -import androidx.camera.view.CameraController; import androidx.camera.view.PreviewView; import androidx.camera.view.video.AudioConfig; import androidx.core.util.Consumer; @@ -27,6 +26,7 @@ import com.bumptech.glide.util.Executors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mediasend.camerax.SignalCameraController; import org.thoughtcrime.securesms.mediasend.camerax.CameraXModePolicy; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.ContextUtil; @@ -47,14 +47,14 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener private static final String VIDEO_DEBUG_LABEL = "video-capture"; private static final long VIDEO_SIZE = 10 * 1024 * 1024; - private final @NonNull Fragment fragment; - private final @NonNull PreviewView previewView; - private final @NonNull CameraController cameraController; - private final @NonNull Callback callback; - private final @NonNull MemoryFileDescriptor memoryFileDescriptor; - private final @NonNull ValueAnimator updateProgressAnimator; - private final @NonNull Debouncer debouncer; - private final @NonNull CameraXModePolicy cameraXModePolicy; + private final @NonNull Fragment fragment; + private final @NonNull PreviewView previewView; + private final @NonNull SignalCameraController cameraController; + private final @NonNull Callback callback; + private final @NonNull MemoryFileDescriptor memoryFileDescriptor; + private final @NonNull ValueAnimator updateProgressAnimator; + private final @NonNull Debouncer debouncer; + private final @NonNull CameraXModePolicy cameraXModePolicy; private ValueAnimator cameraMetricsAnimator; @@ -88,7 +88,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener CameraXVideoCaptureHelper(@NonNull Fragment fragment, @NonNull CameraButtonView captureButton, - @NonNull CameraController cameraController, + @NonNull SignalCameraController cameraController, @NonNull PreviewView previewView, @NonNull MemoryFileDescriptor memoryFileDescriptor, @NonNull CameraXModePolicy cameraXModePolicy, @@ -150,7 +150,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener FileDescriptorOutputOptions outputOptions = new FileDescriptorOutputOptions.Builder(memoryFileDescriptor.getParcelFileDescriptor()).build(); AudioConfig audioConfig = AudioConfig.create(true); - activeRecording = cameraController.startRecording(outputOptions, audioConfig, Executors.mainThreadExecutor(), videoSavedListener); + activeRecording = cameraController.startRecording(outputOptions, audioConfig, videoSavedListener); updateProgressAnimator.start(); debouncer.publish(this::onVideoCaptureComplete); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt index 35badb798f..cad9cb9bed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt @@ -15,11 +15,11 @@ sealed class CameraXModePolicy { abstract val isVideoSupported: Boolean - abstract fun initialize(cameraController: CameraController) + abstract fun initialize(cameraController: SignalCameraController) - open fun setToImage(cameraController: CameraController) = Unit + open fun setToImage(cameraController: SignalCameraController) = Unit - open fun setToVideo(cameraController: CameraController) = Unit + open fun setToVideo(cameraController: SignalCameraController) = Unit /** * The device supports having Image and Video enabled at the same time @@ -28,7 +28,7 @@ sealed class CameraXModePolicy { override val isVideoSupported: Boolean = true - override fun initialize(cameraController: CameraController) { + override fun initialize(cameraController: SignalCameraController) { cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE or CameraController.VIDEO_CAPTURE) } } @@ -40,15 +40,15 @@ sealed class CameraXModePolicy { override val isVideoSupported: Boolean = true - override fun initialize(cameraController: CameraController) { + override fun initialize(cameraController: SignalCameraController) { setToImage(cameraController) } - override fun setToImage(cameraController: CameraController) { + override fun setToImage(cameraController: SignalCameraController) { cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE) } - override fun setToVideo(cameraController: CameraController) { + override fun setToVideo(cameraController: SignalCameraController) { cameraController.setEnabledUseCases(CameraController.VIDEO_CAPTURE) } } @@ -60,7 +60,7 @@ sealed class CameraXModePolicy { override val isVideoSupported: Boolean = false - override fun initialize(cameraController: CameraController) { + override fun initialize(cameraController: SignalCameraController) { cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt new file mode 100644 index 0000000000..125a95e860 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt @@ -0,0 +1,430 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediasend.camerax + +import android.Manifest +import android.content.Context +import android.util.Size +import android.view.MotionEvent +import android.view.Surface +import android.view.View +import androidx.annotation.MainThread +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import androidx.camera.core.Camera +import androidx.camera.core.CameraProvider +import androidx.camera.core.CameraSelector +import androidx.camera.core.FocusMeteringAction +import androidx.camera.core.FocusMeteringResult +import androidx.camera.core.ImageCapture +import androidx.camera.core.Preview +import androidx.camera.core.UseCase +import androidx.camera.core.UseCaseGroup +import androidx.camera.core.ViewPort +import androidx.camera.core.ZoomState +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.FallbackStrategy +import androidx.camera.video.FileDescriptorOutputOptions +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.CameraController +import androidx.camera.view.PreviewView +import androidx.camera.view.video.AudioConfig +import androidx.core.content.ContextCompat +import androidx.core.util.Consumer +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import com.google.common.util.concurrent.ListenableFuture +import org.signal.core.util.ThreadUtil +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.util.TextSecurePreferences +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor + +/** + * This is a class to manage the camera resource, and relies on the AndroidX CameraX library. + * + * The API is a subset of the [CameraController] class, but with a few additions such as [setImageRotation]. + */ +class SignalCameraController(val context: Context, val lifecycleOwner: LifecycleOwner, private val previewView: PreviewView) { + companion object { + val TAG = Log.tag(SignalCameraController::class.java) + + @JvmStatic + private fun isLandscape(surfaceRotation: Int): Boolean { + return surfaceRotation == Surface.ROTATION_90 || surfaceRotation == Surface.ROTATION_270 + } + } + + private val videoQualitySelector: QualitySelector = QualitySelector.from(Quality.HD, FallbackStrategy.lowerQualityThan(Quality.HD)) + private val imageMode = CameraXUtil.getOptimalCaptureMode() + + private val cameraProviderFuture: ListenableFuture = ProcessCameraProvider.getInstance(context) + private val viewPort: ViewPort? = previewView.getViewPort(Surface.ROTATION_0) + private val initializationCompleteListeners: MutableSet = mutableSetOf() + + private var imageRotation = 0 + private var recording: Recording? = null + private var previewTargetSize: Size? = null + private var imageCaptureTargetSize: Size? = null + private var cameraSelector: CameraSelector = CameraXUtil.toCameraSelector(TextSecurePreferences.getDirectCaptureCameraId(context)) + private var enabledUseCases: Int = CameraController.IMAGE_CAPTURE + + private var previewUseCase: Preview = createPreviewUseCase() + private var imageCaptureUseCase: ImageCapture = createImageCaptureUseCase() + private var videoCaptureUseCase: VideoCapture = createVideoCaptureRecorder() + + private lateinit var cameraProvider: ProcessCameraProvider + private lateinit var camera: Camera + + @RequiresPermission(Manifest.permission.CAMERA) + fun bindToLifecycle(onCameraBoundListener: Runnable) { + ThreadUtil.assertMainThread() + if (this::cameraProvider.isInitialized) { + bindToLifecycleInternal() + onCameraBoundListener.run() + } else { + cameraProviderFuture.addListener({ + cameraProvider = cameraProviderFuture.get() + initializationCompleteListeners.forEach { it.onInitialized(cameraProvider) } + bindToLifecycleInternal() + onCameraBoundListener.run() + }, ContextCompat.getMainExecutor(context)) + } + } + + @MainThread + fun unbind() { + ThreadUtil.assertMainThread() + cameraProvider.unbindAll() + } + + @MainThread + private fun bindToLifecycleInternal() { + ThreadUtil.assertMainThread() + try { + if (!this::cameraProvider.isInitialized) { + Log.d(TAG, "Camera provider not yet initialized.") + return + } + camera = cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + buildUseCaseGroup() + ) + + initializeTapToFocus() + } catch (e: Exception) { + Log.e(TAG, "Use case binding failed", e) + } + } + + @MainThread + fun takePicture(executor: Executor, callback: ImageCapture.OnImageCapturedCallback) { + ThreadUtil.assertMainThread() + assertImageEnabled() + imageCaptureUseCase.takePicture(executor, callback) + } + + @RequiresApi(26) + @MainThread + fun startRecording(outputOptions: FileDescriptorOutputOptions, audioConfig: AudioConfig, videoSavedListener: Consumer): Recording { + ThreadUtil.assertMainThread() + assertVideoEnabled() + + recording?.stop() + recording = null + val startedRecording = videoCaptureUseCase.output + .prepareRecording(context, outputOptions) + .apply { + if (audioConfig.audioEnabled) { + withAudioEnabled() + } + } + .start(ContextCompat.getMainExecutor(context)) { + videoSavedListener.accept(it) + if (it is VideoRecordEvent.Finalize) { + recording = null + } + } + + recording = startedRecording + return startedRecording + } + + @MainThread + fun setEnabledUseCases(useCaseFlags: Int) { + ThreadUtil.assertMainThread() + if (enabledUseCases == useCaseFlags) { + return + } + + val oldEnabledUseCases = enabledUseCases + enabledUseCases = useCaseFlags + if (isRecording()) { + stopRecording() + } + tryToBindCamera { enabledUseCases = oldEnabledUseCases } + } + + @MainThread + fun getImageCaptureFlashMode(): Int { + ThreadUtil.assertMainThread() + return imageCaptureUseCase.flashMode + } + + @MainThread + fun setPreviewTargetSize(size: Size) { + ThreadUtil.assertMainThread() + if (size == previewTargetSize || previewTargetSize?.equals(size) == true) { + return + } + Log.d(TAG, "Setting Preview dimensions to $size") + previewTargetSize = size + if (this::cameraProvider.isInitialized) { + cameraProvider.unbind(previewUseCase) + } + previewUseCase = createPreviewUseCase() + + tryToBindCamera(null) + } + + @MainThread + fun setImageCaptureTargetSize(size: Size) { + ThreadUtil.assertMainThread() + if (size == imageCaptureTargetSize || imageCaptureTargetSize?.equals(size) == true) { + return + } + imageCaptureTargetSize = size + if (this::cameraProvider.isInitialized) { + cameraProvider.unbind(imageCaptureUseCase) + } + imageCaptureUseCase = createImageCaptureUseCase() + tryToBindCamera(null) + } + + @MainThread + fun setImageRotation(rotation: Int) { + ThreadUtil.assertMainThread() + val newRotation = UseCase.snapToSurfaceRotation(rotation.coerceIn(0, 359)) + + if (newRotation == imageRotation) { + return + } + + if (isLandscape(newRotation) != isLandscape(imageRotation)) { + imageCaptureTargetSize = imageCaptureTargetSize?.swap() + } + + videoCaptureUseCase.targetRotation = newRotation + imageCaptureUseCase.targetRotation = newRotation + + imageRotation = newRotation + } + + @MainThread + fun setImageCaptureFlashMode(flashMode: Int) { + ThreadUtil.assertMainThread() + imageCaptureUseCase.flashMode = flashMode + } + + @MainThread + fun setZoomRatio(ratio: Float): ListenableFuture { + ThreadUtil.assertMainThread() + return camera.cameraControl.setZoomRatio(ratio) + } + + @MainThread + fun getZoomState(): LiveData { + ThreadUtil.assertMainThread() + return camera.cameraInfo.zoomState + } + + @MainThread + fun setCameraSelector(selector: CameraSelector) { + ThreadUtil.assertMainThread() + if (selector == cameraSelector) { + return + } + + val oldCameraSelector: CameraSelector = cameraSelector + cameraSelector = selector + if (!this::cameraProvider.isInitialized) { + return + } + cameraProvider.unbindAll() + tryToBindCamera { cameraSelector = oldCameraSelector } + } + + @MainThread + fun getCameraSelector(): CameraSelector { + ThreadUtil.assertMainThread() + return cameraSelector + } + + @MainThread + fun hasCamera(selectedCamera: CameraSelector): Boolean { + ThreadUtil.assertMainThread() + return cameraProvider.hasCamera(selectedCamera) + } + + @MainThread + fun addInitializationCompletedListener(listener: InitializationListener) { + ThreadUtil.assertMainThread() + initializationCompleteListeners.add(listener) + } + + @MainThread + private fun tryToBindCamera(restoreStateRunnable: (() -> Unit)?) { + ThreadUtil.assertMainThread() + try { + bindToLifecycleInternal() + } catch (e: RuntimeException) { + Log.i(TAG, "Could not re-bind camera!", e) + restoreStateRunnable?.invoke() + } + } + + @MainThread + private fun stopRecording() { + ThreadUtil.assertMainThread() + recording?.close() + } + + private fun createVideoCaptureRecorder() = VideoCapture.Builder( + Recorder.Builder() + .setQualitySelector(videoQualitySelector) + .build() + ) + .setTargetRotation(imageRotation) + .build() + + private fun createPreviewUseCase() = Preview.Builder() + .apply { + setTargetRotation(Surface.ROTATION_0) + val size = previewTargetSize + if (size != null) { + setResolutionSelector( + ResolutionSelector.Builder() + .setResolutionStrategy(ResolutionStrategy(size, ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER)) + .build() + ) + } + }.build() + .also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + private fun createImageCaptureUseCase(): ImageCapture = ImageCapture.Builder() + .apply { + setCaptureMode(imageMode) + setTargetRotation(imageRotation) + + val size = imageCaptureTargetSize + if (size != null) { + setResolutionSelector( + ResolutionSelector.Builder() + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY) + .setResolutionStrategy(ResolutionStrategy(size, ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER)) + .build() + ) + } + }.build() + + private fun buildUseCaseGroup() = UseCaseGroup.Builder().apply { + addUseCase(previewUseCase) + if (isUseCaseEnabled(CameraController.IMAGE_CAPTURE)) { + addUseCase(imageCaptureUseCase) + } else { + cameraProvider.unbind(imageCaptureUseCase) + } + if (isUseCaseEnabled(CameraController.VIDEO_CAPTURE)) { + addUseCase(videoCaptureUseCase) + } else { + cameraProvider.unbind(videoCaptureUseCase) + } + if (viewPort != null) { + setViewPort(viewPort) + } else { + Log.d(TAG, "ViewPort was null, not adding to UseCase builder.") + } + }.build() + + @MainThread + private fun initializeTapToFocus() { + ThreadUtil.assertMainThread() + previewView.setOnTouchListener { v: View?, event: MotionEvent -> + if (event.action == MotionEvent.ACTION_DOWN) { + return@setOnTouchListener true + } + if (event.action == MotionEvent.ACTION_UP) { + focusAndMeterOnPoint(event.x, event.y) + v?.performClick() + return@setOnTouchListener true + } + false + } + } + + @MainThread + private fun focusAndMeterOnPoint(x: Float, y: Float) { + ThreadUtil.assertMainThread() + if (this::camera.isInitialized) { + Log.d(TAG, "Can't tap to focus before camera is initialized.") + return + } + val factory = previewView.meteringPointFactory + val point = factory.createPoint(x, y) + val action = FocusMeteringAction.Builder(point).build() + + val future: ListenableFuture = camera.cameraControl.startFocusAndMetering(action) + future.addListener({ + try { + val result = future.get() + Log.d(TAG, "Tap to focus was successful? ${result.isFocusSuccessful}") + } catch (e: ExecutionException) { + Log.d(TAG, "Tap to focus could not be completed due to an exception.", e) + } catch (e: InterruptedException) { + Log.d(TAG, "Tap to focus could not be completed due to an exception.", e) + } + }, ContextCompat.getMainExecutor(context)) + } + + private fun isRecording(): Boolean { + return recording != null + } + + private fun isUseCaseEnabled(mask: Int): Boolean { + return (enabledUseCases and mask) != 0 + } + + private fun assertVideoEnabled() { + if (!isUseCaseEnabled(CameraController.VIDEO_CAPTURE)) { + throw IllegalStateException("VideoCapture disabled.") + } + } + + private fun assertImageEnabled() { + if (!isUseCaseEnabled(CameraController.IMAGE_CAPTURE)) { + throw IllegalStateException("ImageCapture disabled.") + } + } + + private fun Size.swap(): Size { + return Size(this.height, this.width) + } + + interface InitializationListener { + fun onInitialized(cameraProvider: CameraProvider) + } +}