package org.thoughtcrime.securesms.mediasend; import android.animation.Animator; import android.annotation.SuppressLint; import android.content.Context; import android.content.pm.ActivityInfo; import android.content.res.Configuration; import android.content.res.Resources; import android.os.Build; import android.os.Bundle; import android.util.Rational; import android.util.Size; import android.view.GestureDetector; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.Surface; import android.view.View; import android.view.ViewGroup; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import android.view.animation.DecelerateInterpolator; import android.view.animation.RotateAnimation; import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.camera.core.CameraSelector; import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageCaptureException; import androidx.camera.core.ImageProxy; import androidx.camera.view.CameraController; import androidx.camera.view.LifecycleCameraController; import androidx.camera.view.PreviewView; import androidx.camera.view.video.ExperimentalVideo; import androidx.cardview.widget.CardView; 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 org.signal.core.util.Stopwatch; import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.animation.AnimationCompleteListener; import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.mediasend.camerax.CameraXFlashToggleView; import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations; import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.util.MemoryFileDescriptor; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.video.VideoUtil; import java.io.FileDescriptor; import java.io.IOException; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.Disposable; /** * Camera captured implemented using the CameraX SDK, which uses Camera2 under the hood. Should be * preferred whenever possible. */ @ExperimentalVideo @RequiresApi(21) public class CameraXFragment extends LoggingFragment implements CameraFragment { private static final String TAG = Log.tag(CameraXFragment.class); private static final String IS_VIDEO_ENABLED = "is_video_enabled"; private static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9); private static final PreviewView.ScaleType PREVIEW_SCALE_TYPE = PreviewView.ScaleType.FILL_CENTER; private PreviewView previewView; private ViewGroup controlsContainer; private Controller controller; private View selfieFlash; private MemoryFileDescriptor videoFileDescriptor; private LifecycleCameraController cameraController; private Disposable mostRecentItemDisposable = Disposable.disposed(); private boolean isThumbAvailable; private boolean isMediaSelected; public static CameraXFragment newInstanceForAvatarCapture() { CameraXFragment fragment = new CameraXFragment(); Bundle args = new Bundle(); args.putBoolean(IS_VIDEO_ENABLED, false); fragment.setArguments(args); return fragment; } public static CameraXFragment newInstance() { CameraXFragment fragment = new CameraXFragment(); fragment.setArguments(new Bundle()); return fragment; } @Override public void onAttach(@NonNull Context context) { super.onAttach(context); if (getActivity() instanceof Controller) { this.controller = (Controller) getActivity(); } else if (getParentFragment() instanceof Controller) { this.controller = (Controller) getParentFragment(); } if (controller == null) { throw new IllegalStateException("Parent must implement controller interface."); } } @Override public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.camerax_fragment, container, false); } @SuppressLint("MissingPermission") @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { ViewGroup cameraParent = view.findViewById(R.id.camerax_camera_parent); this.previewView = view.findViewById(R.id.camerax_camera); this.controlsContainer = view.findViewById(R.id.camerax_controls_container); cameraController = new LifecycleCameraController(requireContext()); cameraController.bindToLifecycle(getViewLifecycleOwner()); cameraController.setCameraSelector(CameraXUtil.toCameraSelector(TextSecurePreferences.getDirectCaptureCameraId(requireContext()))); cameraController.setTapToFocusEnabled(true); cameraController.setImageCaptureMode(CameraXUtil.getOptimalCaptureMode()); cameraController.setEnabledUseCases(getSupportedUseCases()); previewView.setScaleType(PREVIEW_SCALE_TYPE); previewView.setController(cameraController); onOrientationChanged(); view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { // Let's assume portrait for now, so 9:16 float aspectRatio = CameraFragment.getAspectRatioForOrientation(Configuration.ORIENTATION_PORTRAIT); float width = right - left; float height = Math.min((1f / aspectRatio) * width, bottom - top); ViewGroup.LayoutParams params = cameraParent.getLayoutParams(); // If there's a mismatch... if (params.height != (int) height) { params.width = (int) width; params.height = (int) height; cameraParent.setLayoutParams(params); cameraController.setPreviewTargetSize(new CameraController.OutputSize(new Size((int) width, (int) height))); } }); } @Override public void onResume() { super.onResume(); cameraController.bindToLifecycle(getViewLifecycleOwner()); requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } @Override public void onPause() { super.onPause(); } @Override public void onDestroyView() { super.onDestroyView(); mostRecentItemDisposable.dispose(); closeVideoFileDescriptor(); requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); } @Override public void fadeOutControls(@NonNull Runnable onEndAction) { controlsContainer.setEnabled(false); controlsContainer.animate() .setDuration(250) .alpha(0f) .setInterpolator(MediaAnimations.getInterpolator()) .setListener(new AnimationCompleteListener() { @Override public void onAnimationEnd(Animator animation) { controlsContainer.setEnabled(true); onEndAction.run(); } }); } @Override public void fadeInControls() { controlsContainer.setEnabled(false); controlsContainer.animate() .setDuration(250) .alpha(1f) .setInterpolator(MediaAnimations.getInterpolator()) .setListener(new AnimationCompleteListener() { @Override public void onAnimationEnd(Animator animation) { controlsContainer.setEnabled(true); } }); } private void onOrientationChanged() { int layout = R.layout.camera_controls_portrait; 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.setVideoCaptureTargetSize(new CameraController.OutputSize(VideoUtil.getVideoRecordingSize())); controlsContainer.removeAllViews(); controlsContainer.addView(LayoutInflater.from(getContext()).inflate(layout, controlsContainer, false)); initControls(); } private void presentRecentItemThumbnail(@Nullable Media media) { ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button); if (media != null) { thumbnail.setVisibility(View.VISIBLE); Glide.with(this) .load(new DecryptableUri(media.getUri())) .centerCrop() .into(thumbnail); } else { thumbnail.setVisibility(View.GONE); thumbnail.setImageResource(0); } isThumbAvailable = media != null; updateGalleryVisibility(); } @Override public void presentHud(int selectedMediaCount) { MediaCountIndicatorButton countButton = controlsContainer.findViewById(R.id.camera_review_button); if (selectedMediaCount > 0) { countButton.setVisibility(View.VISIBLE); countButton.setCount(selectedMediaCount); } else { countButton.setVisibility(View.GONE); } isMediaSelected = selectedMediaCount > 0; updateGalleryVisibility(); } private void updateGalleryVisibility() { View cameraGalleryContainer = controlsContainer.findViewById(R.id.camera_gallery_button_background); if (isMediaSelected || !isThumbAvailable) { cameraGalleryContainer.setVisibility(View.GONE); } else { cameraGalleryContainer.setVisibility(View.VISIBLE); } } private void initializeViewFinderAndControlsPositioning() { CardView cameraCard = requireView().findViewById(R.id.camerax_camera_parent); View controls = requireView().findViewById(R.id.camerax_controls_container); CameraDisplay cameraDisplay = CameraDisplay.getDisplay(requireActivity()); if (!cameraDisplay.getRoundViewFinderCorners()) { cameraCard.setRadius(0f); } ViewUtil.setBottomMargin(controls, cameraDisplay.getCameraCaptureMarginBottom(getResources())); if (cameraDisplay.getCameraViewportGravity() == CameraDisplay.CameraViewportGravity.CENTER) { ConstraintSet constraintSet = new ConstraintSet(); constraintSet.clone((ConstraintLayout) requireView()); constraintSet.connect(R.id.camerax_camera_parent, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP); constraintSet.applyTo((ConstraintLayout) requireView()); } else { ViewUtil.setBottomMargin(cameraCard, cameraDisplay.getCameraViewportMarginBottom()); } } @SuppressLint({ "ClickableViewAccessibility", "MissingPermission" }) private void initControls() { View flipButton = requireView().findViewById(R.id.camera_flip_button); CameraButtonView captureButton = requireView().findViewById(R.id.camera_capture_button); View galleryButton = requireView().findViewById(R.id.camera_gallery_button); View countButton = requireView().findViewById(R.id.camera_review_button); CameraXFlashToggleView flashButton = requireView().findViewById(R.id.camera_flash_button); initializeViewFinderAndControlsPositioning(); mostRecentItemDisposable.dispose(); mostRecentItemDisposable = controller.getMostRecentMediaItem() .observeOn(AndroidSchedulers.mainThread()) .subscribe(item -> presentRecentItemThumbnail(item.orElse(null))); selfieFlash = requireView().findViewById(R.id.camera_selfie_flash); captureButton.setOnClickListener(v -> { captureButton.setEnabled(false); flipButton.setEnabled(false); flashButton.setEnabled(false); onCaptureClicked(); }); previewView.setScaleType(PREVIEW_SCALE_TYPE); cameraController.getInitializationFuture() .addListener(() -> initializeFlipButton(flipButton, flashButton), Executors.mainThreadExecutor()); flashButton.setAutoFlashEnabled(cameraController.getImageCaptureFlashMode() >= ImageCapture.FLASH_MODE_AUTO); flashButton.setFlash(cameraController.getImageCaptureFlashMode()); flashButton.setOnFlashModeChangedListener(cameraController::setImageCaptureFlashMode); galleryButton.setOnClickListener(v -> controller.onGalleryClicked()); countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked()); if (isVideoRecordingSupported(requireContext())) { try { closeVideoFileDescriptor(); videoFileDescriptor = CameraXVideoCaptureHelper.createFileDescriptor(requireContext()); Animation inAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in); Animation outAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_out); int maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller.getMediaConstraints()); if (controller.getMaxVideoDuration() > 0) { maxDuration = controller.getMaxVideoDuration(); } Log.d(TAG, "Max duration: " + maxDuration + " sec"); captureButton.setVideoCaptureListener(new CameraXVideoCaptureHelper( this, captureButton, cameraController, previewView, videoFileDescriptor, maxDuration, new CameraXVideoCaptureHelper.Callback() { @Override public void onVideoRecordStarted() { hideAndDisableControlsForVideoRecording(captureButton, flashButton, flipButton, outAnimation); } @Override public void onVideoSaved(@NonNull FileDescriptor fd) { showAndEnableControlsAfterVideoRecording(captureButton, flashButton, flipButton, inAnimation); controller.onVideoCaptured(fd); } @Override public void onVideoError(@Nullable Throwable cause) { showAndEnableControlsAfterVideoRecording(captureButton, flashButton, flipButton, inAnimation); controller.onVideoCaptureError(); } } )); displayVideoRecordingTooltipIfNecessary(captureButton); } catch (IOException e) { Log.w(TAG, "Video capture is not supported on this device.", e); } } else { Log.i(TAG, "Video capture not supported. " + "API: " + Build.VERSION.SDK_INT + ", " + "MFD: " + MemoryFileDescriptor.supported() + ", " + "Camera: " + CameraXUtil.getLowestSupportedHardwareLevel(requireContext()) + ", " + "MaxDuration: " + VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller.getMediaConstraints()) + " sec"); } } @CameraController.UseCases private int getSupportedUseCases() { if (isVideoRecordingSupported(requireContext())) { return CameraController.IMAGE_CAPTURE | CameraController.VIDEO_CAPTURE; } else { return CameraController.IMAGE_CAPTURE; } } private boolean isVideoRecordingSupported(@NonNull Context context) { return Build.VERSION.SDK_INT >= 26 && requireArguments().getBoolean(IS_VIDEO_ENABLED, true) && MediaConstraints.isVideoTranscodeAvailable() && CameraXUtil.isMixedModeSupported(context) && VideoUtil.getMaxVideoRecordDurationInSeconds(context, controller.getMediaConstraints()) > 0; } private void displayVideoRecordingTooltipIfNecessary(CameraButtonView captureButton) { if (shouldDisplayVideoRecordingTooltip()) { int displayRotation = requireActivity().getWindowManager().getDefaultDisplay().getRotation(); TooltipPopup.forTarget(captureButton) .setOnDismissListener(this::neverDisplayVideoRecordingTooltipAgain) .setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.core_ultramarine)) .setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_toolbar_title)) .setText(R.string.CameraXFragment_tap_for_photo_hold_for_video) .show(displayRotation == Surface.ROTATION_0 || displayRotation == Surface.ROTATION_180 ? TooltipPopup.POSITION_ABOVE : TooltipPopup.POSITION_START); } } private boolean shouldDisplayVideoRecordingTooltip() { return !TextSecurePreferences.hasSeenVideoRecordingTooltip(requireContext()) && MediaConstraints.isVideoTranscodeAvailable(); } private void neverDisplayVideoRecordingTooltipAgain() { Context context = getContext(); if (context != null) { TextSecurePreferences.setHasSeenVideoRecordingTooltip(requireContext(), true); } } private void hideAndDisableControlsForVideoRecording(@NonNull View captureButton, @NonNull View flashButton, @NonNull View flipButton, @NonNull Animation outAnimation) { captureButton.setEnabled(false); flashButton.startAnimation(outAnimation); flashButton.setVisibility(View.INVISIBLE); flipButton.startAnimation(outAnimation); flipButton.setVisibility(View.INVISIBLE); } private void showAndEnableControlsAfterVideoRecording(@NonNull View captureButton, @NonNull View flashButton, @NonNull View flipButton, @NonNull Animation inAnimation) { requireActivity().runOnUiThread(() -> { captureButton.setEnabled(true); flashButton.startAnimation(inAnimation); flashButton.setVisibility(View.VISIBLE); flipButton.startAnimation(inAnimation); flipButton.setVisibility(View.VISIBLE); }); } private void onCaptureClicked() { Stopwatch stopwatch = new Stopwatch("Capture"); CameraXSelfieFlashHelper flashHelper = new CameraXSelfieFlashHelper( requireActivity().getWindow(), cameraController, selfieFlash ); flashHelper.onWillTakePicture(); cameraController.takePicture(Executors.mainThreadExecutor(), new ImageCapture.OnImageCapturedCallback() { @Override public void onCaptureSuccess(@NonNull ImageProxy image) { flashHelper.endFlash(); final boolean flip = cameraController.getCameraSelector() == CameraSelector.DEFAULT_FRONT_CAMERA; SimpleTask.run(CameraXFragment.this.getViewLifecycleOwner().getLifecycle(), () -> { stopwatch.split("captured"); try { return CameraXUtil.toJpeg(image, flip); } catch (IOException e) { Log.w(TAG, "Failed to encode captured image.", e); return null; } finally { image.close(); } }, result -> { stopwatch.split("transformed"); stopwatch.stop(TAG); if (result != null) { controller.onImageCaptured(result.getData(), result.getWidth(), result.getHeight()); } else { controller.onCameraError(); } }); } @Override public void onError(ImageCaptureException exception) { Log.w(TAG, "Failed to capture image", exception); flashHelper.endFlash(); controller.onCameraError(); } }); flashHelper.startFlash(); } private void closeVideoFileDescriptor() { if (videoFileDescriptor != null) { try { videoFileDescriptor.close(); videoFileDescriptor = null; } catch (IOException e) { Log.w(TAG, "Failed to close video file descriptor", e); } } } @SuppressLint({ "MissingPermission" }) private void initializeFlipButton(@NonNull View flipButton, @NonNull CameraXFlashToggleView flashButton) { if (getContext() == null) { Log.w(TAG, "initializeFlipButton called either before or after fragment was attached."); return; } if (cameraController.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) && cameraController.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) { flipButton.setVisibility(View.VISIBLE); flipButton.setOnClickListener(v -> { cameraController.setCameraSelector(cameraController.getCameraSelector() == CameraSelector.DEFAULT_FRONT_CAMERA ? CameraSelector.DEFAULT_BACK_CAMERA : CameraSelector.DEFAULT_FRONT_CAMERA); TextSecurePreferences.setDirectCaptureCameraId(getContext(), CameraXUtil.toCameraDirectionInt(cameraController.getCameraSelector())); Animation animation = new RotateAnimation(0, -180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); animation.setDuration(200); animation.setInterpolator(new DecelerateInterpolator()); flipButton.startAnimation(animation); flashButton.setAutoFlashEnabled(cameraController.getImageCaptureFlashMode() >= ImageCapture.FLASH_MODE_AUTO); flashButton.setFlash(cameraController.getImageCaptureFlashMode()); }); GestureDetector gestureDetector = new GestureDetector(requireContext(), new GestureDetector.SimpleOnGestureListener() { @Override public boolean onDoubleTap(MotionEvent e) { if (flipButton.isEnabled()) { flipButton.performClick(); } return true; } }); previewView.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event)); } else { flipButton.setVisibility(View.GONE); } } }