From 73b8f11b5a45bfe95e202685bc1f066895e74d0a Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 26 Jun 2019 18:10:57 -0400 Subject: [PATCH] Improve camera capture with CameraX. --- AndroidManifest.xml | 2 +- build.gradle | 46 +- res/layout/camerax_fragment.xml | 17 + .../securesms/mediasend/Camera1Fragment.java | 12 +- .../securesms/mediasend/CameraFragment.java | 23 + .../securesms/mediasend/CameraXFragment.java | 199 +++ .../mediasend/MediaSendActivity.java | 38 +- .../mediasend/camerax/CameraXModule.java | 797 ++++++++++++ .../mediasend/camerax/CameraXUtil.java | 122 ++ .../mediasend/camerax/CameraXView.java | 1066 +++++++++++++++++ .../mediasend/camerax/FastCameraModels.java | 30 + .../securesms/util/ServiceUtil.java | 5 + 12 files changed, 2316 insertions(+), 41 deletions(-) create mode 100644 res/layout/camerax_fragment.xml create mode 100644 src/org/thoughtcrime/securesms/mediasend/CameraFragment.java create mode 100644 src/org/thoughtcrime/securesms/mediasend/CameraXFragment.java create mode 100644 src/org/thoughtcrime/securesms/mediasend/camerax/CameraXModule.java create mode 100644 src/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java create mode 100644 src/org/thoughtcrime/securesms/mediasend/camerax/CameraXView.java create mode 100644 src/org/thoughtcrime/securesms/mediasend/camerax/FastCameraModels.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index d3321ca127..3edaa4afd5 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" package="org.thoughtcrime.securesms"> - + + + + + + + + \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java b/src/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java index 82e8161b4c..b6bd93d17e 100644 --- a/src/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java +++ b/src/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java @@ -43,7 +43,11 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.io.ByteArrayOutputStream; -public class Camera1Fragment extends Fragment implements TextureView.SurfaceTextureListener, +/** + * Camera capture implemented with the legacy camera API's. Should only be used if sdk < 21. + */ +public class Camera1Fragment extends Fragment implements CameraFragment, + TextureView.SurfaceTextureListener, Camera1Controller.EventListener { @@ -317,12 +321,6 @@ public class Camera1Fragment extends Fragment implements TextureView.SurfaceText } }; - public interface Controller { - void onCameraError(); - void onImageCaptured(@NonNull byte[] data, int width, int height); - int getDisplayRotation(); - } - private enum Stage { SURFACE_AVAILABLE, CAMERA_PROPERTIES_AVAILABLE } diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraFragment.java b/src/org/thoughtcrime/securesms/mediasend/CameraFragment.java new file mode 100644 index 0000000000..bc518cbc75 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/CameraFragment.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +public interface CameraFragment { + + static Fragment newInstance() { + if (Build.VERSION.SDK_INT >= 21) { + return CameraXFragment.newInstance(); + } else { + return Camera1Fragment.newInstance(); + } + } + + interface Controller { + void onCameraError(); + void onImageCaptured(@NonNull byte[] data, int width, int height); + int getDisplayRotation(); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/src/org/thoughtcrime/securesms/mediasend/CameraXFragment.java new file mode 100644 index 0000000000..8353dd220f --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.RotateAnimation; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.camera.core.CameraX; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageProxy; +import androidx.fragment.app.Fragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXView; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.io.IOException; + +/** + * Camera captured implemented using the CameraX SDK, which uses Camera2 under the hood. Should be + * preferred whenever possible. + */ +@RequiresApi(21) +public class CameraXFragment extends Fragment implements CameraFragment { + + private static final String TAG = Log.tag(CameraXFragment.class); + + private CameraXView camera; + private ViewGroup controlsContainer; + private Controller controller; + + public static CameraXFragment newInstance() { + return new CameraXFragment(); + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement controller interface."); + } + + this.controller = (Controller) getActivity(); + } + + + @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) { + this.camera = view.findViewById(R.id.camerax_camera); + this.controlsContainer = view.findViewById(R.id.camerax_controls_container); + + camera.bindToLifecycle(this); + camera.setCameraLensFacing(CameraXUtil.toLensFacing(TextSecurePreferences.getDirectCaptureCameraId(requireContext()))); + + onOrientationChanged(getResources().getConfiguration().orientation); + } + + @Override + public void onResume() { + super.onResume(); + + requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + CameraX.unbindAll(); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + onOrientationChanged(newConfig.orientation); + } + + private void onOrientationChanged(int orientation) { + int layout = orientation == Configuration.ORIENTATION_PORTRAIT ? R.layout.camera_controls_portrait + : R.layout.camera_controls_landscape; + + controlsContainer.removeAllViews(); + controlsContainer.addView(LayoutInflater.from(getContext()).inflate(layout, controlsContainer, false)); + initControls(); + } + + @SuppressLint({"ClickableViewAccessibility", "MissingPermission"}) + private void initControls() { + View flipButton = requireView().findViewById(R.id.camera_flip_button); + View captureButton = requireView().findViewById(R.id.camera_capture_button); + + captureButton.setOnTouchListener((v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + Animation shrinkAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_shrink); + shrinkAnimation.setFillAfter(true); + shrinkAnimation.setFillEnabled(true); + captureButton.startAnimation(shrinkAnimation); + onCaptureClicked(); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_OUTSIDE: + Animation growAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_grow); + growAnimation.setFillAfter(true); + growAnimation.setFillEnabled(true); + captureButton.startAnimation(growAnimation); + captureButton.setEnabled(false); + break; + } + return true; + }); + + if (camera.hasCameraWithLensFacing(CameraX.LensFacing.FRONT) && camera.hasCameraWithLensFacing(CameraX.LensFacing.BACK)) { + flipButton.setVisibility(View.VISIBLE); + flipButton.setOnClickListener(v -> { + camera.toggleCamera(); + TextSecurePreferences.setDirectCaptureCameraId(getContext(), CameraXUtil.toCameraDirectionInt(camera.getCameraLensFacing())); + + 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); + }); + } else { + flipButton.setVisibility(View.GONE); + } + } + + private void onCaptureClicked() { + Stopwatch stopwatch = new Stopwatch("Capture"); + + camera.takePicture(new ImageCapture.OnImageCapturedListener() { + @Override + public void onCaptureSuccess(ImageProxy image, int rotationDegrees) { + SimpleTask.run(CameraXFragment.this.getLifecycle(), () -> { + stopwatch.split("captured"); + try { + byte[] bytes = CameraXUtil.toJpegBytes(image, rotationDegrees, camera.getCameraLensFacing() == CameraX.LensFacing.FRONT); + return new CaptureResult(bytes, image.getWidth(), image.getHeight()); + } catch (IOException e) { + return null; + } finally { + image.close(); + } + }, result -> { + stopwatch.split("transformed"); + stopwatch.stop(TAG); + + if (result != null) { + controller.onImageCaptured(result.data, result.width, result.height); + } else { + controller.onCameraError(); + } + }); + } + + @Override + public void onError(ImageCapture.UseCaseError useCaseError, String message, @Nullable Throwable cause) { + controller.onCameraError(); + } + }); + } + + private static final class CaptureResult { + public final byte[] data; + public final int width; + public final int height; + + private CaptureResult(byte[] data, int width, int height) { + this.data = data; + this.width = width; + this.height = height; + } + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index ee48279695..5809ccc212 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.mediasend; import android.Manifest; + +import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.ViewModelProviders; import android.content.Context; import android.content.Intent; @@ -50,7 +52,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple MediaPickerItemFragment.Controller, MediaSendFragment.Controller, ImageEditorFragment.Controller, - Camera1Fragment.Controller + CameraFragment.Controller { private static final String TAG = MediaSendActivity.class.getSimpleName(); @@ -144,7 +146,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple boolean isCamera = getIntent().getBooleanExtra(KEY_IS_CAMERA, false); if (isCamera) { - Fragment fragment = Camera1Fragment.newInstance(); + Fragment fragment = CameraFragment.newInstance(); getSupportFragmentManager().beginTransaction() .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) .commit(); @@ -216,7 +218,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple @Override public void onMediaSelected(@NonNull Media media) { viewModel.onSingleMediaSelected(this, media); - navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale()); + navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale(), false); } @Override @@ -305,7 +307,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple Log.i(TAG, "Camera capture stored: " + media.getUri().toString()); viewModel.onImageCaptured(media); - navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale()); + navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale(), true); }); } @@ -323,7 +325,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple animateButtonVisibility(countButton, countButton.getVisibility(), buttonState.isVisible() ? View.VISIBLE : View.GONE); if (buttonState.getCount() > 0) { - countButton.setOnClickListener(v -> navigateToMediaSend(recipient, transport, locale)); + countButton.setOnClickListener(v -> navigateToMediaSend(recipient, transport, locale, false)); if (buttonState.isVisible()) { animateButtonTextChange(countButton); } @@ -356,7 +358,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple }); } - private void navigateToMediaSend(@NonNull Recipient recipient, @NonNull TransportOption transport, @NonNull Locale locale) { + private void navigateToMediaSend(@NonNull Recipient recipient, @NonNull TransportOption transport, @NonNull Locale locale, boolean fade) { MediaSendFragment fragment = MediaSendFragment.newInstance(recipient, transport, locale); String backstackTag = null; @@ -365,11 +367,17 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple backstackTag = TAG_SEND; } - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) - .addToBackStack(backstackTag) - .commit(); + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + + if (fade) { + transaction.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_out, R.anim.fade_in); + } else { + transaction.setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right); + } + + transaction.replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) + .addToBackStack(backstackTag) + .commit(); } private void navigateToCamera() { @@ -379,7 +387,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_photo_camera_white_48dp) .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video)) .onAllGranted(() -> { - Camera1Fragment fragment = getOrCreateCameraFragment(); + Fragment fragment = getOrCreateCameraFragment(); getSupportFragmentManager().beginTransaction() .setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) @@ -390,11 +398,11 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple .execute(); } - private Camera1Fragment getOrCreateCameraFragment() { - Camera1Fragment fragment = (Camera1Fragment) getSupportFragmentManager().findFragmentByTag(TAG_CAMERA); + private Fragment getOrCreateCameraFragment() { + Fragment fragment = getSupportFragmentManager().findFragmentByTag(TAG_CAMERA); return fragment != null ? fragment - : Camera1Fragment.newInstance(); + : CameraFragment.newInstance(); } private void animateButtonVisibility(@NonNull View button, int oldVisibility, int newVisibility) { diff --git a/src/org/thoughtcrime/securesms/mediasend/camerax/CameraXModule.java b/src/org/thoughtcrime/securesms/mediasend/camerax/CameraXModule.java new file mode 100644 index 0000000000..48f1f1b0ff --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/camerax/CameraXModule.java @@ -0,0 +1,797 @@ +package org.thoughtcrime.securesms.mediasend.camerax; + +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.Manifest.permission; +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.SurfaceTexture; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.os.Looper; +import android.util.Log; +import android.util.Rational; +import android.util.Size; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; +import androidx.annotation.UiThread; +import androidx.camera.core.CameraInfo; +import androidx.camera.core.CameraInfoUnavailableException; +import androidx.camera.core.CameraOrientationUtil; +import androidx.camera.core.CameraX; +import androidx.camera.core.CameraX.LensFacing; +import androidx.camera.core.FlashMode; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageCapture.OnImageCapturedListener; +import androidx.camera.core.ImageCapture.OnImageSavedListener; +import androidx.camera.core.ImageCaptureConfig; +import androidx.camera.core.Preview; +import androidx.camera.core.PreviewConfig; +import androidx.camera.core.VideoCapture; +import androidx.camera.core.VideoCapture.OnVideoSavedListener; +import androidx.camera.core.VideoCaptureConfig; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.OnLifecycleEvent; + +import org.thoughtcrime.securesms.mediasend.camerax.CameraXView.CaptureMode; + +import java.io.File; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** CameraX use case operation built on @{link androidx.camera.core}. */ +@RequiresApi(21) +@SuppressLint("RestrictedApi") +final class CameraXModule { + public static final String TAG = "CameraXModule"; + + private static final int MAX_VIEW_DIMENSION = 2000; + private static final float UNITY_ZOOM_SCALE = 1f; + private static final float ZOOM_NOT_SUPPORTED = UNITY_ZOOM_SCALE; + private static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9); + private static final Rational ASPECT_RATIO_4_3 = new Rational(4, 3); + private static final Rational ASPECT_RATIO_9_16 = new Rational(9, 16); + private static final Rational ASPECT_RATIO_3_4 = new Rational(3, 4); + + private final CameraManager mCameraManager; + private final PreviewConfig.Builder mPreviewConfigBuilder; + private final VideoCaptureConfig.Builder mVideoCaptureConfigBuilder; + private final ImageCaptureConfig.Builder mImageCaptureConfigBuilder; + private final CameraXView mCameraXView; + final AtomicBoolean mVideoIsRecording = new AtomicBoolean(false); + private CaptureMode mCaptureMode = CaptureMode.IMAGE; + private long mMaxVideoDuration = CameraXView.INDEFINITE_VIDEO_DURATION; + private long mMaxVideoSize = CameraXView.INDEFINITE_VIDEO_SIZE; + private FlashMode mFlash = FlashMode.OFF; + @Nullable + private ImageCapture mImageCapture; + @Nullable + private VideoCapture mVideoCapture; + @Nullable + Preview mPreview; + @Nullable + LifecycleOwner mCurrentLifecycle; + private final LifecycleObserver mCurrentLifecycleObserver = + new LifecycleObserver() { + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + public void onDestroy(LifecycleOwner owner) { + if (owner == mCurrentLifecycle) { + clearCurrentLifecycle(); + mPreview.removePreviewOutputListener(); + } + } + }; + @Nullable + private LifecycleOwner mNewLifecycle; + private float mZoomLevel = UNITY_ZOOM_SCALE; + @Nullable + private Rect mCropRegion; + @Nullable + private CameraX.LensFacing mCameraLensFacing = LensFacing.BACK; + + CameraXModule(CameraXView view) { + this.mCameraXView = view; + + mCameraManager = (CameraManager) view.getContext().getSystemService(Context.CAMERA_SERVICE); + + mPreviewConfigBuilder = new PreviewConfig.Builder().setTargetName("Preview"); + + mImageCaptureConfigBuilder = + new ImageCaptureConfig.Builder().setTargetName("ImageCapture"); + + mVideoCaptureConfigBuilder = + new VideoCaptureConfig.Builder().setTargetName("VideoCapture"); + } + + /** + * Rescales view rectangle with dimensions in [-1000, 1000] to a corresponding rectangle in the + * sensor coordinate frame. + */ + private static Rect rescaleViewRectToSensorRect(Rect view, Rect sensor) { + // Scale width and height. + int newWidth = Math.round(view.width() * sensor.width() / (float) MAX_VIEW_DIMENSION); + int newHeight = Math.round(view.height() * sensor.height() / (float) MAX_VIEW_DIMENSION); + + // Scale top/left corner. + int halfViewDimension = MAX_VIEW_DIMENSION / 2; + int leftOffset = + Math.round( + (view.left + halfViewDimension) + * sensor.width() + / (float) MAX_VIEW_DIMENSION) + + sensor.left; + int topOffset = + Math.round( + (view.top + halfViewDimension) + * sensor.height() + / (float) MAX_VIEW_DIMENSION) + + sensor.top; + + // Now, produce the scaled rect. + Rect scaled = new Rect(); + scaled.left = leftOffset; + scaled.top = topOffset; + scaled.right = scaled.left + newWidth; + scaled.bottom = scaled.top + newHeight; + return scaled; + } + + @RequiresPermission(permission.CAMERA) + public void bindToLifecycle(LifecycleOwner lifecycleOwner) { + mNewLifecycle = lifecycleOwner; + + if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) { + bindToLifecycleAfterViewMeasured(); + } + } + + @RequiresPermission(permission.CAMERA) + void bindToLifecycleAfterViewMeasured() { + if (mNewLifecycle == null) { + return; + } + + clearCurrentLifecycle(); + mCurrentLifecycle = mNewLifecycle; + mNewLifecycle = null; + if (mCurrentLifecycle.getLifecycle().getCurrentState() == Lifecycle.State.DESTROYED) { + mCurrentLifecycle = null; + throw new IllegalArgumentException("Cannot bind to lifecycle in a destroyed state."); + } + + final int cameraOrientation; + try { + String cameraId; + Set available = getAvailableCameraLensFacing(); + + if (available.isEmpty()) { + Log.w(TAG, "Unable to bindToLifeCycle since no cameras available"); + mCameraLensFacing = null; + } + + // Ensure the current camera exists, or default to another camera + if (mCameraLensFacing != null && !available.contains(mCameraLensFacing)) { + Log.w(TAG, "Camera does not exist with direction " + mCameraLensFacing); + + // Default to the first available camera direction + mCameraLensFacing = available.iterator().next(); + + Log.w(TAG, "Defaulting to primary camera with direction " + mCameraLensFacing); + } + + // Do not attempt to create use cases for a null cameraLensFacing. This could occur if + // the + // user explicitly sets the LensFacing to null, or if we determined there + // were no available cameras, which should be logged in the logic above. + if (mCameraLensFacing == null) { + return; + } + + cameraId = CameraX.getCameraWithLensFacing(mCameraLensFacing); + if (cameraId == null) { + return; + } + CameraInfo cameraInfo = CameraX.getCameraInfo(cameraId); + cameraOrientation = cameraInfo.getSensorRotationDegrees(); + } catch (Exception e) { + throw new IllegalStateException("Unable to bind to lifecycle.", e); + } + + // Set the preferred aspect ratio as 4:3 if it is IMAGE only mode. Set the preferred aspect + // ratio as 16:9 if it is VIDEO or MIXED mode. Then, it will be WYSIWYG when the view finder + // is + // in CENTER_INSIDE mode. + + boolean isDisplayPortrait = getDisplayRotationDegrees() == 0 + || getDisplayRotationDegrees() == 180; + + if (getCaptureMode() == CaptureMode.IMAGE) { + mImageCaptureConfigBuilder.setTargetAspectRatio( + isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3); + mPreviewConfigBuilder.setTargetAspectRatio( + isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3); + } else { + mImageCaptureConfigBuilder.setTargetAspectRatio( + isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9); + mPreviewConfigBuilder.setTargetAspectRatio( + isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9); + } + + mImageCaptureConfigBuilder.setTargetRotation(getDisplaySurfaceRotation()); + mImageCaptureConfigBuilder.setLensFacing(mCameraLensFacing); + mImageCaptureConfigBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode()); + mImageCaptureConfigBuilder.setTargetResolution(new Size(1920, 1080)); + mImageCapture = new ImageCapture(mImageCaptureConfigBuilder.build()); + + mVideoCaptureConfigBuilder.setTargetRotation(getDisplaySurfaceRotation()); + mVideoCaptureConfigBuilder.setLensFacing(mCameraLensFacing); + mVideoCapture = new VideoCapture(mVideoCaptureConfigBuilder.build()); + mPreviewConfigBuilder.setLensFacing(mCameraLensFacing); + + int relativeCameraOrientation = getRelativeCameraOrientation(false); + + if (relativeCameraOrientation == 90 || relativeCameraOrientation == 270) { + mPreviewConfigBuilder.setTargetResolution( + new Size(getMeasuredHeight(), getMeasuredWidth())); + } else { + mPreviewConfigBuilder.setTargetResolution( + new Size(getMeasuredWidth(), getMeasuredHeight())); + } + + mPreview = new Preview(mPreviewConfigBuilder.build()); + mPreview.setOnPreviewOutputUpdateListener( + new Preview.OnPreviewOutputUpdateListener() { + @Override + public void onUpdated(Preview.PreviewOutput output) { + boolean needReverse = cameraOrientation != 0 && cameraOrientation != 180; + int textureWidth = + needReverse + ? output.getTextureSize().getHeight() + : output.getTextureSize().getWidth(); + int textureHeight = + needReverse + ? output.getTextureSize().getWidth() + : output.getTextureSize().getHeight(); + CameraXModule.this.onPreviewSourceDimensUpdated(textureWidth, + textureHeight); + CameraXModule.this.setSurfaceTexture(output.getSurfaceTexture()); + } + }); + + if (getCaptureMode() == CaptureMode.IMAGE) { + CameraX.bindToLifecycle(mCurrentLifecycle, mImageCapture, mPreview); + } else if (getCaptureMode() == CaptureMode.VIDEO) { + CameraX.bindToLifecycle(mCurrentLifecycle, mVideoCapture, mPreview); + } else { + CameraX.bindToLifecycle(mCurrentLifecycle, mImageCapture, mVideoCapture, mPreview); + } + setZoomLevel(mZoomLevel); + mCurrentLifecycle.getLifecycle().addObserver(mCurrentLifecycleObserver); + // Enable flash setting in ImageCapture after use cases are created and binded. + setFlash(getFlash()); + } + + public void open() { + throw new UnsupportedOperationException( + "Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead."); + } + + public void close() { + throw new UnsupportedOperationException( + "Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead."); + } + + public void stopPreview() { + if (mPreview != null) { + mPreview.clear(); + } + } + + public void takePicture(OnImageCapturedListener listener) { + if (mImageCapture == null) { + return; + } + + if (getCaptureMode() == CaptureMode.VIDEO) { + throw new IllegalStateException("Can not take picture under VIDEO capture mode."); + } + + if (listener == null) { + throw new IllegalArgumentException("OnImageCapturedListener should not be empty"); + } + + mImageCapture.takePicture(listener); + } + + public void takePicture(File saveLocation, OnImageSavedListener listener) { + if (mImageCapture == null) { + return; + } + + if (getCaptureMode() == CaptureMode.VIDEO) { + throw new IllegalStateException("Can not take picture under VIDEO capture mode."); + } + + if (listener == null) { + throw new IllegalArgumentException("OnImageSavedListener should not be empty"); + } + + ImageCapture.Metadata metadata = new ImageCapture.Metadata(); + metadata.isReversedHorizontal = mCameraLensFacing == LensFacing.FRONT; + mImageCapture.takePicture(saveLocation, listener, metadata); + } + + public void startRecording(File file, final OnVideoSavedListener listener) { + if (mVideoCapture == null) { + return; + } + + if (getCaptureMode() == CaptureMode.IMAGE) { + throw new IllegalStateException("Can not record video under IMAGE capture mode."); + } + + if (listener == null) { + throw new IllegalArgumentException("OnVideoSavedListener should not be empty"); + } + + mVideoIsRecording.set(true); + mVideoCapture.startRecording( + file, + new VideoCapture.OnVideoSavedListener() { + @Override + public void onVideoSaved(File savedFile) { + mVideoIsRecording.set(false); + listener.onVideoSaved(savedFile); + } + + @Override + public void onError( + VideoCapture.UseCaseError useCaseError, + String message, + @Nullable Throwable cause) { + mVideoIsRecording.set(false); + Log.e(TAG, message, cause); + listener.onError(useCaseError, message, cause); + } + }); + } + + public void stopRecording() { + if (mVideoCapture == null) { + return; + } + + mVideoCapture.stopRecording(); + } + + public boolean isRecording() { + return mVideoIsRecording.get(); + } + + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + public void setCameraLensFacing(@Nullable LensFacing lensFacing) { + // Setting same lens facing is a no-op, so check for that first + if (mCameraLensFacing != lensFacing) { + // If we're not bound to a lifecycle, just update the camera that will be opened when we + // attach to a lifecycle. + mCameraLensFacing = lensFacing; + + if (mCurrentLifecycle != null) { + // Re-bind to lifecycle with new camera + bindToLifecycle(mCurrentLifecycle); + } + } + } + + @RequiresPermission(permission.CAMERA) + public boolean hasCameraWithLensFacing(LensFacing lensFacing) { + String cameraId; + try { + cameraId = CameraX.getCameraWithLensFacing(lensFacing); + } catch (Exception e) { + throw new IllegalStateException("Unable to query lens facing.", e); + } + + return cameraId != null; + } + + @Nullable + public LensFacing getLensFacing() { + return mCameraLensFacing; + } + + public void toggleCamera() { + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + Set availableCameraLensFacing = getAvailableCameraLensFacing(); + + if (availableCameraLensFacing.isEmpty()) { + return; + } + + if (mCameraLensFacing == null) { + setCameraLensFacing(availableCameraLensFacing.iterator().next()); + return; + } + + if (mCameraLensFacing == LensFacing.BACK + && availableCameraLensFacing.contains(LensFacing.FRONT)) { + setCameraLensFacing(LensFacing.FRONT); + return; + } + + if (mCameraLensFacing == LensFacing.FRONT + && availableCameraLensFacing.contains(LensFacing.BACK)) { + setCameraLensFacing(LensFacing.BACK); + return; + } + } + + public void focus(Rect focus, Rect metering) { + if (mPreview == null) { + // Nothing to focus on since we don't yet have a preview + return; + } + + Rect rescaledFocus; + Rect rescaledMetering; + try { + Rect sensorRegion; + if (mCropRegion != null) { + sensorRegion = mCropRegion; + } else { + sensorRegion = getSensorSize(getActiveCamera()); + } + rescaledFocus = rescaleViewRectToSensorRect(focus, sensorRegion); + rescaledMetering = rescaleViewRectToSensorRect(metering, sensorRegion); + } catch (Exception e) { + Log.e(TAG, "Failed to rescale the focus and metering rectangles.", e); + return; + } + + mPreview.focus(rescaledFocus, rescaledMetering); + } + + public float getZoomLevel() { + return mZoomLevel; + } + + public void setZoomLevel(float zoomLevel) { + // Set the zoom level in case it is set before binding to a lifecycle + this.mZoomLevel = zoomLevel; + + if (mPreview == null) { + // Nothing to zoom on yet since we don't have a preview. Defer calculating crop + // region. + return; + } + + Rect sensorSize; + try { + sensorSize = getSensorSize(getActiveCamera()); + if (sensorSize == null) { + Log.e(TAG, "Failed to get the sensor size."); + return; + } + } catch (Exception e) { + Log.e(TAG, "Failed to get the sensor size.", e); + return; + } + + float minZoom = getMinZoomLevel(); + float maxZoom = getMaxZoomLevel(); + + if (this.mZoomLevel < minZoom) { + Log.e(TAG, "Requested zoom level is less than minimum zoom level."); + } + if (this.mZoomLevel > maxZoom) { + Log.e(TAG, "Requested zoom level is greater than maximum zoom level."); + } + this.mZoomLevel = Math.max(minZoom, Math.min(maxZoom, this.mZoomLevel)); + + float zoomScaleFactor = + (maxZoom == minZoom) ? minZoom : (this.mZoomLevel - minZoom) / (maxZoom - minZoom); + int minWidth = Math.round(sensorSize.width() / maxZoom); + int minHeight = Math.round(sensorSize.height() / maxZoom); + int diffWidth = sensorSize.width() - minWidth; + int diffHeight = sensorSize.height() - minHeight; + float cropWidth = diffWidth * zoomScaleFactor; + float cropHeight = diffHeight * zoomScaleFactor; + + Rect cropRegion = + new Rect( + /*left=*/ (int) Math.ceil(cropWidth / 2 - 0.5f), + /*top=*/ (int) Math.ceil(cropHeight / 2 - 0.5f), + /*right=*/ (int) Math.floor(sensorSize.width() - cropWidth / 2 + 0.5f), + /*bottom=*/ (int) Math.floor(sensorSize.height() - cropHeight / 2 + 0.5f)); + + if (cropRegion.width() < 50 || cropRegion.height() < 50) { + Log.e(TAG, "Crop region is too small to compute 3A stats, so ignoring further zoom."); + return; + } + this.mCropRegion = cropRegion; + + mPreview.zoom(cropRegion); + } + + public float getMinZoomLevel() { + return UNITY_ZOOM_SCALE; + } + + public float getMaxZoomLevel() { + try { + CameraCharacteristics characteristics = + mCameraManager.getCameraCharacteristics(getActiveCamera()); + Float maxZoom = + characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM); + if (maxZoom == null) { + return ZOOM_NOT_SUPPORTED; + } + if (maxZoom == ZOOM_NOT_SUPPORTED) { + return ZOOM_NOT_SUPPORTED; + } + return maxZoom; + } catch (Exception e) { + Log.e(TAG, "Failed to get SCALER_AVAILABLE_MAX_DIGITAL_ZOOM.", e); + } + return ZOOM_NOT_SUPPORTED; + } + + public boolean isZoomSupported() { + return getMaxZoomLevel() != ZOOM_NOT_SUPPORTED; + } + + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + private void rebindToLifecycle() { + if (mCurrentLifecycle != null) { + bindToLifecycle(mCurrentLifecycle); + } + } + + int getRelativeCameraOrientation(boolean compensateForMirroring) { + int rotationDegrees; + try { + String cameraId = CameraX.getCameraWithLensFacing(getLensFacing()); + CameraInfo cameraInfo = CameraX.getCameraInfo(cameraId); + rotationDegrees = cameraInfo.getSensorRotationDegrees(getDisplaySurfaceRotation()); + if (compensateForMirroring) { + rotationDegrees = (360 - rotationDegrees) % 360; + } + } catch (Exception e) { + Log.e(TAG, "Failed to query camera", e); + rotationDegrees = 0; + } + + return rotationDegrees; + } + + public void invalidateView() { + transformPreview(); + updateViewInfo(); + } + + void clearCurrentLifecycle() { + if (mCurrentLifecycle != null) { + // Remove previous use cases + CameraX.unbind(mImageCapture, mVideoCapture, mPreview); + } + + mCurrentLifecycle = null; + } + + private Rect getSensorSize(String cameraId) throws CameraAccessException { + CameraCharacteristics characteristics = mCameraManager.getCameraCharacteristics(cameraId); + return characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); + } + + String getActiveCamera() throws CameraInfoUnavailableException { + return CameraX.getCameraWithLensFacing(mCameraLensFacing); + } + + @UiThread + private void transformPreview() { + int previewWidth = getPreviewWidth(); + int previewHeight = getPreviewHeight(); + int displayOrientation = getDisplayRotationDegrees(); + + Matrix matrix = new Matrix(); + + // Apply rotation of the display + int rotation = -displayOrientation; + + int px = (int) Math.round(previewWidth / 2d); + int py = (int) Math.round(previewHeight / 2d); + + matrix.postRotate(rotation, px, py); + + if (displayOrientation == 90 || displayOrientation == 270) { + // Swap width and height + float xScale = previewWidth / (float) previewHeight; + float yScale = previewHeight / (float) previewWidth; + + matrix.postScale(xScale, yScale, px, py); + } + + setTransform(matrix); + } + + // Update view related information used in use cases + private void updateViewInfo() { + if (mImageCapture != null) { + mImageCapture.setTargetAspectRatio(new Rational(getWidth(), getHeight())); + mImageCapture.setTargetRotation(getDisplaySurfaceRotation()); + } + + if (mVideoCapture != null) { + mVideoCapture.setTargetRotation(getDisplaySurfaceRotation()); + } + } + + @RequiresPermission(permission.CAMERA) + private Set getAvailableCameraLensFacing() { + // Start with all camera directions + Set available = new LinkedHashSet<>(Arrays.asList(LensFacing.values())); + + // If we're bound to a lifecycle, remove unavailable cameras + if (mCurrentLifecycle != null) { + if (!hasCameraWithLensFacing(LensFacing.BACK)) { + available.remove(LensFacing.BACK); + } + + if (!hasCameraWithLensFacing(LensFacing.FRONT)) { + available.remove(LensFacing.FRONT); + } + } + + return available; + } + + public FlashMode getFlash() { + return mFlash; + } + + public void setFlash(FlashMode flash) { + this.mFlash = flash; + + if (mImageCapture == null) { + // Do nothing if there is no imageCapture + return; + } + + mImageCapture.setFlashMode(flash); + } + + public void enableTorch(boolean torch) { + if (mPreview == null) { + return; + } + mPreview.enableTorch(torch); + } + + public boolean isTorchOn() { + if (mPreview == null) { + return false; + } + return mPreview.isTorchOn(); + } + + public Context getContext() { + return mCameraXView.getContext(); + } + + public int getWidth() { + return mCameraXView.getWidth(); + } + + public int getHeight() { + return mCameraXView.getHeight(); + } + + public int getDisplayRotationDegrees() { + return CameraOrientationUtil.surfaceRotationToDegrees(getDisplaySurfaceRotation()); + } + + protected int getDisplaySurfaceRotation() { + return mCameraXView.getDisplaySurfaceRotation(); + } + + public void setSurfaceTexture(SurfaceTexture st) { + mCameraXView.setSurfaceTexture(st); + } + + private int getPreviewWidth() { + return mCameraXView.getPreviewWidth(); + } + + private int getPreviewHeight() { + return mCameraXView.getPreviewHeight(); + } + + private int getMeasuredWidth() { + return mCameraXView.getMeasuredWidth(); + } + + private int getMeasuredHeight() { + return mCameraXView.getMeasuredHeight(); + } + + void setTransform(final Matrix matrix) { + if (Looper.myLooper() != Looper.getMainLooper()) { + mCameraXView.post( + new Runnable() { + @Override + public void run() { + setTransform(matrix); + } + }); + } else { + mCameraXView.setTransform(matrix); + } + } + + /** + * Notify the view that the source dimensions have changed. + * + *

This will allow the view to layout the preview to display the correct aspect ratio. + * + * @param width width of camera source buffers. + * @param height height of camera source buffers. + */ + void onPreviewSourceDimensUpdated(int width, int height) { + mCameraXView.onPreviewSourceDimensUpdated(width, height); + } + + public CaptureMode getCaptureMode() { + return mCaptureMode; + } + + public void setCaptureMode(CaptureMode captureMode) { + this.mCaptureMode = captureMode; + rebindToLifecycle(); + } + + public long getMaxVideoDuration() { + return mMaxVideoDuration; + } + + public void setMaxVideoDuration(long duration) { + mMaxVideoDuration = duration; + } + + public long getMaxVideoSize() { + return mMaxVideoSize; + } + + public void setMaxVideoSize(long size) { + mMaxVideoSize = size; + } + + public boolean isPaused() { + return false; + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java b/src/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java new file mode 100644 index 0000000000..36905e5cd2 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.mediasend.camerax; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.hardware.Camera; +import android.os.Build; +import android.util.Size; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.camera.core.CameraX; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageProxy; + +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.Stopwatch; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +public class CameraXUtil { + + private static final String TAG = Log.tag(CameraXUtil.class); + + @RequiresApi(21) + public static byte[] toJpegBytes(@NonNull ImageProxy image, int rotation, boolean flip) throws IOException { + ImageProxy.PlaneProxy[] planes = image.getPlanes(); + ByteBuffer buffer = planes[0].getBuffer(); + Rect cropRect = shouldCropImage(image) ? image.getCropRect() : null; + byte[] data = new byte[buffer.capacity()]; + + buffer.get(data); + + if (cropRect != null || rotation != 0 || flip) { + data = transformByteArray(data, cropRect, rotation, flip); + } + + return data; + } + + public static int toCameraDirectionInt(@Nullable CameraX.LensFacing facing) { + if (facing == CameraX.LensFacing.FRONT) { + return Camera.CameraInfo.CAMERA_FACING_FRONT; + } else { + return Camera.CameraInfo.CAMERA_FACING_BACK; + } + } + + public static @NonNull CameraX.LensFacing toLensFacing(int cameraDirectionInt) { + if (cameraDirectionInt == Camera.CameraInfo.CAMERA_FACING_FRONT) { + return CameraX.LensFacing.FRONT; + } else { + return CameraX.LensFacing.BACK; + } + } + + public static @NonNull ImageCapture.CaptureMode getOptimalCaptureMode() { + return FastCameraModels.contains(Build.MODEL) ? ImageCapture.CaptureMode.MAX_QUALITY + : ImageCapture.CaptureMode.MIN_LATENCY; + } + + private static byte[] transformByteArray(@NonNull byte[] data, @Nullable Rect cropRect, int rotation, boolean flip) throws IOException { + Stopwatch stopwatch = new Stopwatch("transform"); + Bitmap in; + + if (cropRect != null) { + BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(data, 0, data.length, false); + in = decoder.decodeRegion(cropRect, new BitmapFactory.Options()); + decoder.recycle(); + stopwatch.split("crop"); + } else { + in = BitmapFactory.decodeByteArray(data, 0, data.length); + } + + Bitmap out = in; + + if (rotation != 0 || flip) { + Matrix matrix = new Matrix(); + matrix.postRotate(rotation); + + if (flip) { + matrix.postScale(-1, 1); + matrix.postTranslate(in.getWidth(), 0); + } + + out = Bitmap.createBitmap(in, 0, 0, in.getWidth(), in.getHeight(), matrix, true); + } + + byte[] transformedData = toJpegBytes(out); + stopwatch.split("transcode"); + + in.recycle(); + out.recycle(); + + stopwatch.stop(TAG); + + return transformedData; + } + + @RequiresApi(21) + private static boolean shouldCropImage(@NonNull ImageProxy image) { + Size sourceSize = new Size(image.getWidth(), image.getHeight()); + Size targetSize = new Size(image.getCropRect().width(), image.getCropRect().height()); + + return !targetSize.equals(sourceSize); + } + + private static byte[] toJpegBytes(@NonNull Bitmap bitmap) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)) { + throw new IOException("Failed to compress bitmap."); + } + + return out.toByteArray(); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/camerax/CameraXView.java b/src/org/thoughtcrime/securesms/mediasend/camerax/CameraXView.java new file mode 100644 index 0000000000..6f32871de5 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/camerax/CameraXView.java @@ -0,0 +1,1066 @@ +package org.thoughtcrime.securesms.mediasend.camerax; + +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.Manifest.permission; +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.SurfaceTexture; +import android.hardware.display.DisplayManager; +import android.hardware.display.DisplayManager.DisplayListener; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Size; +import android.view.Display; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.Surface; +import android.view.TextureView; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.BaseInterpolator; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; +import androidx.annotation.RestrictTo; +import androidx.annotation.RestrictTo.Scope; +import androidx.annotation.UiThread; +import androidx.camera.core.CameraX.LensFacing; +import androidx.camera.core.FlashMode; +import androidx.camera.core.ImageCapture.OnImageCapturedListener; +import androidx.camera.core.ImageCapture.OnImageSavedListener; +import androidx.camera.core.ImageProxy; +import androidx.camera.core.VideoCapture.OnVideoSavedListener; +import androidx.lifecycle.LifecycleOwner; + +import org.thoughtcrime.securesms.logging.Log; + +import java.io.File; + +/** + * A {@link View} that displays a preview of the camera with methods {@link + * #takePicture(OnImageCapturedListener)}, {@link #takePicture(File, OnImageSavedListener)}, {@link + * #startRecording(File, OnVideoSavedListener)} and {@link #stopRecording()}. + * + *

Because the Camera is a limited resource and consumes a high amount of power, CameraView must + * be opened/closed. CameraView will handle opening/closing automatically through use of a {@link + * LifecycleOwner}. Use {@link #bindToLifecycle(LifecycleOwner)} to start the camera. + */ +@RequiresApi(21) +public final class CameraXView extends ViewGroup { + static final String TAG = CameraXView.class.getSimpleName(); + static final boolean DEBUG = false; + + static final int INDEFINITE_VIDEO_DURATION = -1; + static final int INDEFINITE_VIDEO_SIZE = -1; + + private static final String EXTRA_SUPER = "super"; + private static final String EXTRA_ZOOM_LEVEL = "zoom_level"; + private static final String EXTRA_PINCH_TO_ZOOM_ENABLED = "pinch_to_zoom_enabled"; + private static final String EXTRA_FLASH = "flash"; + private static final String EXTRA_MAX_VIDEO_DURATION = "max_video_duration"; + private static final String EXTRA_MAX_VIDEO_SIZE = "max_video_size"; + private static final String EXTRA_SCALE_TYPE = "scale_type"; + private static final String EXTRA_CAMERA_DIRECTION = "camera_direction"; + private static final String EXTRA_CAPTURE_MODE = "captureMode"; + + private final Rect mFocusingRect = new Rect(); + private final Rect mMeteringRect = new Rect(); + // For tap-to-focus + private long mDownEventTimestamp; + // For pinch-to-zoom + private PinchToZoomGestureDetector mPinchToZoomGestureDetector; + private boolean mIsPinchToZoomEnabled = true; + CameraXModule mCameraModule; + private final DisplayManager.DisplayListener mDisplayListener = + new DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + } + + @Override + public void onDisplayRemoved(int displayId) { + } + + @Override + public void onDisplayChanged(int displayId) { + mCameraModule.invalidateView(); + } + }; + private TextureView mCameraTextureView; + private Size mPreviewSrcSize = new Size(0, 0); + private ScaleType mScaleType = ScaleType.CENTER_CROP; + // For accessibility event + private MotionEvent mUpEvent; + private @Nullable + Paint mLayerPaint; + + public CameraXView(Context context) { + this(context, null); + } + + public CameraXView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CameraXView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context, attrs); + } + + @RequiresApi(21) + public CameraXView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs); + } + + /** Debug logging that can be enabled. */ + private static void log(String msg) { + if (DEBUG) { + Log.i(TAG, msg); + } + } + + /** Utility method for converting an displayRotation int into a human readable string. */ + private static String displayRotationToString(int displayRotation) { + if (displayRotation == Surface.ROTATION_0 || displayRotation == Surface.ROTATION_180) { + return "Portrait-" + (displayRotation * 90); + } else if (displayRotation == Surface.ROTATION_90 + || displayRotation == Surface.ROTATION_270) { + return "Landscape-" + (displayRotation * 90); + } else { + return "Unknown"; + } + } + + /** + * Binds control of the camera used by this view to the given lifecycle. + * + *

This links opening/closing the camera to the given lifecycle. The camera will not operate + * unless this method is called with a valid {@link LifecycleOwner} that is not in the {@link + * androidx.lifecycle.Lifecycle.State#DESTROYED} state. Call this method only once camera + * permissions have been obtained. + * + *

Once the provided lifecycle has transitioned to a {@link + * androidx.lifecycle.Lifecycle.State#DESTROYED} state, CameraView must be bound to a new + * lifecycle through this method in order to operate the camera. + * + * @param lifecycleOwner The lifecycle that will control this view's camera + * @throws IllegalArgumentException if provided lifecycle is in a {@link + * androidx.lifecycle.Lifecycle.State#DESTROYED} state. + * @throws IllegalStateException if camera permissions are not granted. + */ + @RequiresPermission(permission.CAMERA) + public void bindToLifecycle(LifecycleOwner lifecycleOwner) { + mCameraModule.bindToLifecycle(lifecycleOwner); + } + + private void init(Context context, @Nullable AttributeSet attrs) { + addView(mCameraTextureView = new TextureView(getContext()), 0 /* view position */); + mCameraTextureView.setLayerPaint(mLayerPaint); + mCameraModule = new CameraXModule(this); + + if (isInEditMode()) { + onPreviewSourceDimensUpdated(640, 480); + } + + setScaleType(ScaleType.CENTER_CROP); + setPinchToZoomEnabled(true); + setCaptureMode(CaptureMode.IMAGE); + setCameraLensFacing(LensFacing.FRONT); + setFlash(FlashMode.OFF); + + if (getBackground() == null) { + setBackgroundColor(0xFF111111); + } + + mPinchToZoomGestureDetector = new PinchToZoomGestureDetector(context); + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + return new LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } + + @Override + protected Parcelable onSaveInstanceState() { + // TODO(b/113884082): Decide what belongs here or what should be invalidated on + // configuration + // change + Bundle state = new Bundle(); + state.putParcelable(EXTRA_SUPER, super.onSaveInstanceState()); + state.putInt(EXTRA_SCALE_TYPE, getScaleType().getId()); + state.putFloat(EXTRA_ZOOM_LEVEL, getZoomLevel()); + state.putBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED, isPinchToZoomEnabled()); + state.putString(EXTRA_FLASH, getFlash().name()); + state.putLong(EXTRA_MAX_VIDEO_DURATION, getMaxVideoDuration()); + state.putLong(EXTRA_MAX_VIDEO_SIZE, getMaxVideoSize()); + if (getCameraLensFacing() != null) { + state.putString(EXTRA_CAMERA_DIRECTION, getCameraLensFacing().name()); + } + state.putInt(EXTRA_CAPTURE_MODE, getCaptureMode().getId()); + return state; + } + + @Override + protected void onRestoreInstanceState(Parcelable savedState) { + // TODO(b/113884082): Decide what belongs here or what should be invalidated on + // configuration + // change + if (savedState instanceof Bundle) { + Bundle state = (Bundle) savedState; + super.onRestoreInstanceState(state.getParcelable(EXTRA_SUPER)); + setScaleType(ScaleType.fromId(state.getInt(EXTRA_SCALE_TYPE))); + setZoomLevel(state.getFloat(EXTRA_ZOOM_LEVEL)); + setPinchToZoomEnabled(state.getBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED)); + setFlash(FlashMode.valueOf(state.getString(EXTRA_FLASH))); + setMaxVideoDuration(state.getLong(EXTRA_MAX_VIDEO_DURATION)); + setMaxVideoSize(state.getLong(EXTRA_MAX_VIDEO_SIZE)); + String lensFacingString = state.getString(EXTRA_CAMERA_DIRECTION); + setCameraLensFacing( + TextUtils.isEmpty(lensFacingString) + ? null + : LensFacing.valueOf(lensFacingString)); + setCaptureMode(CaptureMode.fromId(state.getInt(EXTRA_CAPTURE_MODE))); + } else { + super.onRestoreInstanceState(savedState); + } + } + + /** + * Sets the paint on the preview. + * + *

This only affects the preview, and does not affect captured images/video. + * + * @param paint The paint object to apply to the preview. + * @hide This may not work once {@link android.view.SurfaceView} is supported along with {@link + * TextureView}. + */ + @Override + @RestrictTo(Scope.LIBRARY_GROUP) + public void setLayerPaint(@Nullable Paint paint) { + super.setLayerPaint(paint); + mLayerPaint = paint; + mCameraTextureView.setLayerPaint(paint); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + DisplayManager dpyMgr = + (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + dpyMgr.registerDisplayListener(mDisplayListener, new Handler(Looper.getMainLooper())); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + DisplayManager dpyMgr = + (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + dpyMgr.unregisterDisplayListener(mDisplayListener); + } + + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int viewWidth = MeasureSpec.getSize(widthMeasureSpec); + int viewHeight = MeasureSpec.getSize(heightMeasureSpec); + + int displayRotation = getDisplay().getRotation(); + + if (mPreviewSrcSize.getHeight() == 0 || mPreviewSrcSize.getWidth() == 0) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + mCameraTextureView.measure(viewWidth, viewHeight); + } else { + Size scaled = + calculatePreviewViewDimens( + mPreviewSrcSize, viewWidth, viewHeight, displayRotation, mScaleType); + super.setMeasuredDimension( + Math.min(scaled.getWidth(), viewWidth), + Math.min(scaled.getHeight(), viewHeight)); + mCameraTextureView.measure(scaled.getWidth(), scaled.getHeight()); + } + + // Since bindToLifecycle will depend on the measured dimension, only call it when measured + // dimension is not 0x0 + if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) { + mCameraModule.bindToLifecycleAfterViewMeasured(); + } + } + + // TODO(b/124269166): Rethink how we can handle permissions here. + @SuppressLint("MissingPermission") + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + // In case that the CameraView size is always set as 0x0, we still need to trigger to force + // binding to lifecycle + mCameraModule.bindToLifecycleAfterViewMeasured(); + + // If we don't know the src buffer size yet, set the preview to be the parent size + if (mPreviewSrcSize.getWidth() == 0 || mPreviewSrcSize.getHeight() == 0) { + mCameraTextureView.layout(left, top, right, bottom); + return; + } + + // Compute the preview ui size based on the available width, height, and ui orientation. + int viewWidth = (right - left); + int viewHeight = (bottom - top); + int displayRotation = getDisplay().getRotation(); + Size scaled = + calculatePreviewViewDimens( + mPreviewSrcSize, viewWidth, viewHeight, displayRotation, mScaleType); + + // Compute the center of the view. + int centerX = viewWidth / 2; + int centerY = viewHeight / 2; + + // Compute the left / top / right / bottom values such that preview is centered. + int layoutL = centerX - (scaled.getWidth() / 2); + int layoutT = centerY - (scaled.getHeight() / 2); + int layoutR = layoutL + scaled.getWidth(); + int layoutB = layoutT + scaled.getHeight(); + + // Layout debugging + log("layout: viewWidth: " + viewWidth); + log("layout: viewHeight: " + viewHeight); + log("layout: viewRatio: " + (viewWidth / (float) viewHeight)); + log("layout: sizeWidth: " + mPreviewSrcSize.getWidth()); + log("layout: sizeHeight: " + mPreviewSrcSize.getHeight()); + log( + "layout: sizeRatio: " + + (mPreviewSrcSize.getWidth() / (float) mPreviewSrcSize.getHeight())); + log("layout: scaledWidth: " + scaled.getWidth()); + log("layout: scaledHeight: " + scaled.getHeight()); + log("layout: scaledRatio: " + (scaled.getWidth() / (float) scaled.getHeight())); + log( + "layout: size: " + + scaled + + " (" + + (scaled.getWidth() / (float) scaled.getHeight()) + + " - " + + mScaleType + + "-" + + displayRotationToString(displayRotation) + + ")"); + log("layout: final " + layoutL + ", " + layoutT + ", " + layoutR + ", " + layoutB); + + mCameraTextureView.layout(layoutL, layoutT, layoutR, layoutB); + + mCameraModule.invalidateView(); + } + + /** Records the size of the preview's buffers. */ + @UiThread + void onPreviewSourceDimensUpdated(int srcWidth, int srcHeight) { + if (srcWidth != mPreviewSrcSize.getWidth() + || srcHeight != mPreviewSrcSize.getHeight()) { + mPreviewSrcSize = new Size(srcWidth, srcHeight); + requestLayout(); + } + } + + private Size calculatePreviewViewDimens( + Size srcSize, + int parentWidth, + int parentHeight, + int displayRotation, + ScaleType scaleType) { + int inWidth = srcSize.getWidth(); + int inHeight = srcSize.getHeight(); + if (displayRotation == Surface.ROTATION_90 || displayRotation == Surface.ROTATION_270) { + // Need to reverse the width and height since we're in landscape orientation. + inWidth = srcSize.getHeight(); + inHeight = srcSize.getWidth(); + } + + int outWidth = parentWidth; + int outHeight = parentHeight; + if (inWidth != 0 && inHeight != 0) { + float vfRatio = inWidth / (float) inHeight; + float parentRatio = parentWidth / (float) parentHeight; + + switch (scaleType) { + case CENTER_INSIDE: + // Match longest sides together. + if (vfRatio > parentRatio) { + outWidth = parentWidth; + outHeight = Math.round(parentWidth / vfRatio); + } else { + outWidth = Math.round(parentHeight * vfRatio); + outHeight = parentHeight; + } + break; + case CENTER_CROP: + // Match shortest sides together. + if (vfRatio < parentRatio) { + outWidth = parentWidth; + outHeight = Math.round(parentWidth / vfRatio); + } else { + outWidth = Math.round(parentHeight * vfRatio); + outHeight = parentHeight; + } + break; + } + } + + return new Size(outWidth, outHeight); + } + + /** + * @return One of {@link Surface#ROTATION_0}, {@link Surface#ROTATION_90}, {@link + * Surface#ROTATION_180}, {@link Surface#ROTATION_270}. + */ + int getDisplaySurfaceRotation() { + Display display = getDisplay(); + + // Null when the View is detached. If we were in the middle of a background operation, + // better to not NPE. When the background operation finishes, it'll realize that the camera + // was closed. + if (display == null) { + return 0; + } + + return display.getRotation(); + } + + @UiThread + SurfaceTexture getSurfaceTexture() { + if (mCameraTextureView != null) { + return mCameraTextureView.getSurfaceTexture(); + } + + return null; + } + + @UiThread + void setSurfaceTexture(SurfaceTexture surfaceTexture) { + if (mCameraTextureView.getSurfaceTexture() != surfaceTexture) { + if (mCameraTextureView.isAvailable()) { + // Remove the old TextureView to properly detach the old SurfaceTexture from the GL + // Context. + removeView(mCameraTextureView); + addView(mCameraTextureView = new TextureView(getContext()), 0); + mCameraTextureView.setLayerPaint(mLayerPaint); + requestLayout(); + } + + mCameraTextureView.setSurfaceTexture(surfaceTexture); + } + } + + @UiThread + Matrix getTransform(Matrix matrix) { + return mCameraTextureView.getTransform(matrix); + } + + @UiThread + int getPreviewWidth() { + return mCameraTextureView.getWidth(); + } + + @UiThread + int getPreviewHeight() { + return mCameraTextureView.getHeight(); + } + + @UiThread + void setTransform(final Matrix matrix) { + if (mCameraTextureView != null) { + mCameraTextureView.setTransform(matrix); + } + } + + /** + * Returns the scale type used to scale the preview. + * + * @return The current {@link ScaleType}. + */ + public ScaleType getScaleType() { + return mScaleType; + } + + /** + * Sets the view finder scale type. + * + *

This controls how the view finder should be scaled and positioned within the view. + * + * @param scaleType The desired {@link ScaleType}. + */ + public void setScaleType(ScaleType scaleType) { + if (scaleType != mScaleType) { + mScaleType = scaleType; + requestLayout(); + } + } + + /** + * Returns the scale type used to scale the preview. + * + * @return The current {@link CaptureMode}. + */ + public CaptureMode getCaptureMode() { + return mCameraModule.getCaptureMode(); + } + + /** + * Sets the CameraView capture mode + * + *

This controls only image or video capture function is enabled or both are enabled. + * + * @param captureMode The desired {@link CaptureMode}. + */ + public void setCaptureMode(CaptureMode captureMode) { + mCameraModule.setCaptureMode(captureMode); + } + + /** + * Returns the maximum duration of videos, or {@link #INDEFINITE_VIDEO_DURATION} if there is no + * timeout. + * + * @hide Not currently implemented. + */ + @RestrictTo(Scope.LIBRARY_GROUP) + public long getMaxVideoDuration() { + return mCameraModule.getMaxVideoDuration(); + } + + /** + * Sets the maximum video duration before {@link OnVideoSavedListener#onVideoSaved(File)} is + * called automatically. Use {@link #INDEFINITE_VIDEO_DURATION} to disable the timeout. + */ + private void setMaxVideoDuration(long duration) { + mCameraModule.setMaxVideoDuration(duration); + } + + /** + * Returns the maximum size of videos in bytes, or {@link #INDEFINITE_VIDEO_SIZE} if there is no + * timeout. + */ + private long getMaxVideoSize() { + return mCameraModule.getMaxVideoSize(); + } + + /** + * Sets the maximum video size in bytes before {@link OnVideoSavedListener#onVideoSaved(File)} + * is called automatically. Use {@link #INDEFINITE_VIDEO_SIZE} to disable the size restriction. + */ + private void setMaxVideoSize(long size) { + mCameraModule.setMaxVideoSize(size); + } + + /** + * Takes a picture, and calls {@link OnImageCapturedListener#onCaptureSuccess(ImageProxy, int)} + * once when done. + * + * @param listener Listener which will receive success or failure callbacks. + */ + public void takePicture(OnImageCapturedListener listener) { + mCameraModule.takePicture(listener); + } + + /** + * Takes a picture and calls {@link OnImageSavedListener#onImageSaved(File)} when done. + * + * @param file The destination. + * @param listener Listener which will receive success or failure callbacks. + */ + public void takePicture(File file, OnImageSavedListener listener) { + mCameraModule.takePicture(file, listener); + } + + /** + * Takes a video and calls the OnVideoSavedListener when done. + * + * @param file The destination. + */ + public void startRecording(File file, OnVideoSavedListener listener) { + mCameraModule.startRecording(file, listener); + } + + /** Stops an in progress video. */ + public void stopRecording() { + mCameraModule.stopRecording(); + } + + /** @return True if currently recording. */ + public boolean isRecording() { + return mCameraModule.isRecording(); + } + + /** + * Queries whether the current device has a camera with the specified direction. + * + * @return True if the device supports the direction. + * @throws IllegalStateException if the CAMERA permission is not currently granted. + */ + @RequiresPermission(permission.CAMERA) + public boolean hasCameraWithLensFacing(LensFacing lensFacing) { + return mCameraModule.hasCameraWithLensFacing(lensFacing); + } + + /** + * Toggles between the primary front facing camera and the primary back facing camera. + * + *

This will have no effect if not already bound to a lifecycle via {@link + * #bindToLifecycle(LifecycleOwner)}. + */ + public void toggleCamera() { + mCameraModule.toggleCamera(); + } + + /** + * Sets the desired camera by specifying desired lensFacing. + * + *

This will choose the primary camera with the specified camera lensFacing. + * + *

If called before {@link #bindToLifecycle(LifecycleOwner)}, this will set the camera to be + * used when first bound to the lifecycle. If the specified lensFacing is not supported by the + * device, as determined by {@link #hasCameraWithLensFacing(LensFacing)}, the first supported + * lensFacing will be chosen when {@link #bindToLifecycle(LifecycleOwner)} is called. + * + *

If called with {@code null} AFTER binding to the lifecycle, the behavior would be + * equivalent to unbind the use cases without the lifecycle having to be destroyed. + * + * @param lensFacing The desired camera lensFacing. + */ + public void setCameraLensFacing(@Nullable LensFacing lensFacing) { + mCameraModule.setCameraLensFacing(lensFacing); + } + + /** Returns the currently selected {@link LensFacing}. */ + @Nullable + public LensFacing getCameraLensFacing() { + return mCameraModule.getLensFacing(); + } + + /** + * Focuses the camera on the given area. + * + *

Sets the focus and exposure metering rectangles. Coordinates for both X and Y dimensions + * are Limited from -1000 to 1000, where (0, 0) is the center of the image and the width/height + * represent the values from -1000 to 1000. + * + * @param focus Area used to focus the camera. + * @param metering Area used for exposure metering. + */ + public void focus(Rect focus, Rect metering) { + mCameraModule.focus(focus, metering); + } + + /** Gets the active flash strategy. */ + public FlashMode getFlash() { + return mCameraModule.getFlash(); + } + + /** Sets the active flash strategy. */ + public void setFlash(FlashMode flashMode) { + mCameraModule.setFlash(flashMode); + } + + private int getRelativeCameraOrientation(boolean compensateForMirroring) { + return mCameraModule.getRelativeCameraOrientation(compensateForMirroring); + } + + private long delta() { + return System.currentTimeMillis() - mDownEventTimestamp; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + // Disable pinch-to-zoom and tap-to-focus while the camera module is paused. + if (mCameraModule.isPaused()) { + return false; + } + // Only forward the event to the pinch-to-zoom gesture detector when pinch-to-zoom is + // enabled. + if (isPinchToZoomEnabled()) { + mPinchToZoomGestureDetector.onTouchEvent(event); + } + if (event.getPointerCount() == 2 && isPinchToZoomEnabled() && isZoomSupported()) { + return true; + } + + // Camera focus + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mDownEventTimestamp = System.currentTimeMillis(); + break; + case MotionEvent.ACTION_UP: + if (delta() < ViewConfiguration.getLongPressTimeout()) { + mUpEvent = event; + performClick(); + } + break; + default: + // Unhandled event. + return false; + } + return true; + } + + /** + * Focus the position of the touch event, or focus the center of the preview for + * accessibility events + */ + @Override + public boolean performClick() { + super.performClick(); + + final float x = (mUpEvent != null) ? mUpEvent.getX() : getX() + getWidth() / 2f; + final float y = (mUpEvent != null) ? mUpEvent.getY() : getY() + getHeight() / 2f; + mUpEvent = null; + calculateTapArea(mFocusingRect, x, y, 1f); + calculateTapArea(mMeteringRect, x, y, 1.5f); + if (area(mFocusingRect) > 0 && area(mMeteringRect) > 0) { + focus(mFocusingRect, mMeteringRect); + } + + return true; + } + + /** Returns the width * height of the given rect */ + private int area(Rect rect) { + return rect.width() * rect.height(); + } + + /** The area must be between -1000,-1000 and 1000,1000 */ + private void calculateTapArea(Rect rect, float x, float y, float coefficient) { + int max = 1000; + int min = -1000; + + // Default to 300 (1/6th the total area) and scale by the coefficient + int areaSize = (int) (300 * coefficient); + + // Rotate the coordinates if the camera orientation is different + int width = getWidth(); + int height = getHeight(); + + // Compensate orientation as it's mirrored on preview for forward facing cameras + boolean compensateForMirroring = (getCameraLensFacing() == LensFacing.FRONT); + int relativeCameraOrientation = getRelativeCameraOrientation(compensateForMirroring); + int temp; + float tempf; + switch (relativeCameraOrientation) { + case 90: + // Fall-through + case 270: + // We're horizontal. Swap width/height. Swap x/y. + temp = width; + //noinspection SuspiciousNameCombination + width = height; + height = temp; + + tempf = x; + //noinspection SuspiciousNameCombination + x = y; + y = tempf; + break; + default: + break; + } + + switch (relativeCameraOrientation) { + // Map to correct coordinates according to relativeCameraOrientation + case 90: + y = height - y; + break; + case 180: + x = width - x; + y = height - y; + break; + case 270: + x = width - x; + break; + default: + break; + } + + // Swap x if it's a mirrored preview + if (compensateForMirroring) { + x = width - x; + } + + // Grab the x, y position from within the View and normalize it to -1000 to 1000 + x = min + distance(max, min) * (x / width); + y = min + distance(max, min) * (y / height); + + // Modify the rect to the bounding area + rect.top = (int) y - areaSize / 2; + rect.left = (int) x - areaSize / 2; + rect.bottom = rect.top + areaSize; + rect.right = rect.left + areaSize; + + // Cap at -1000 to 1000 + rect.top = rangeLimit(rect.top, max, min); + rect.left = rangeLimit(rect.left, max, min); + rect.bottom = rangeLimit(rect.bottom, max, min); + rect.right = rangeLimit(rect.right, max, min); + } + + private int rangeLimit(int val, int max, int min) { + return Math.min(Math.max(val, min), max); + } + + float rangeLimit(float val, float max, float min) { + return Math.min(Math.max(val, min), max); + } + + private int distance(int a, int b) { + return Math.abs(a - b); + } + + /** + * Returns whether the view allows pinch-to-zoom. + * + * @return True if pinch to zoom is enabled. + */ + public boolean isPinchToZoomEnabled() { + return mIsPinchToZoomEnabled; + } + + /** + * Sets whether the view should allow pinch-to-zoom. + * + *

When enabled, the user can pinch the camera to zoom in/out. This only has an effect if the + * bound camera supports zoom. + * + * @param enabled True to enable pinch-to-zoom. + */ + public void setPinchToZoomEnabled(boolean enabled) { + mIsPinchToZoomEnabled = enabled; + } + + /** + * Returns the current zoom level. + * + * @return The current zoom level. + */ + public float getZoomLevel() { + return mCameraModule.getZoomLevel(); + } + + /** + * Sets the current zoom level. + * + *

Valid zoom values range from 1 to {@link #getMaxZoomLevel()}. + * + * @param zoomLevel The requested zoom level. + */ + public void setZoomLevel(float zoomLevel) { + mCameraModule.setZoomLevel(zoomLevel); + } + + /** + * Returns the minimum zoom level. + * + *

For most cameras this should return a zoom level of 1. A zoom level of 1 corresponds to a + * non-zoomed image. + * + * @return The minimum zoom level. + */ + public float getMinZoomLevel() { + return mCameraModule.getMinZoomLevel(); + } + + /** + * Returns the maximum zoom level. + * + *

The zoom level corresponds to the ratio between both the widths and heights of a + * non-zoomed image and a maximally zoomed image for the selected camera. + * + * @return The maximum zoom level. + */ + public float getMaxZoomLevel() { + return mCameraModule.getMaxZoomLevel(); + } + + /** + * Returns whether the bound camera supports zooming. + * + * @return True if the camera supports zooming. + */ + public boolean isZoomSupported() { + return mCameraModule.isZoomSupported(); + } + + /** + * Turns on/off torch. + * + * @param torch True to turn on torch, false to turn off torch. + */ + public void enableTorch(boolean torch) { + mCameraModule.enableTorch(torch); + } + + /** + * Returns current torch status. + * + * @return true if torch is on , otherwise false + */ + public boolean isTorchOn() { + return mCameraModule.isTorchOn(); + } + + /** Options for scaling the bounds of the view finder to the bounds of this view. */ + public enum ScaleType { + /** + * Scale the view finder, maintaining the source aspect ratio, so the view finder fills the + * entire view. This will cause the view finder to crop the source image if the camera + * aspect ratio does not match the view aspect ratio. + */ + CENTER_CROP(0), + /** + * Scale the view finder, maintaining the source aspect ratio, so the view finder is + * entirely contained within the view. + */ + CENTER_INSIDE(1); + + private int mId; + + int getId() { + return mId; + } + + ScaleType(int id) { + mId = id; + } + + static ScaleType fromId(int id) { + for (ScaleType st : values()) { + if (st.mId == id) { + return st; + } + } + throw new IllegalArgumentException(); + } + } + + /** + * The capture mode used by CameraView. + * + *

This enum can be used to determine which capture mode will be enabled for {@link + * CameraXView}. + */ + public enum CaptureMode { + /** A mode where image capture is enabled. */ + IMAGE(0), + /** A mode where video capture is enabled. */ + VIDEO(1), + /** + * A mode where both image capture and video capture are simultaneously enabled. Note that + * this mode may not be available on every device. + */ + MIXED(2); + + private int mId; + + int getId() { + return mId; + } + + CaptureMode(int id) { + mId = id; + } + + static CaptureMode fromId(int id) { + for (CaptureMode f : values()) { + if (f.mId == id) { + return f; + } + } + throw new IllegalArgumentException(); + } + } + + static class S extends ScaleGestureDetector.SimpleOnScaleGestureListener { + private ScaleGestureDetector.OnScaleGestureListener mListener; + + void setRealGestureDetector(ScaleGestureDetector.OnScaleGestureListener l) { + mListener = l; + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + return mListener.onScale(detector); + } + } + + private class PinchToZoomGestureDetector extends ScaleGestureDetector + implements ScaleGestureDetector.OnScaleGestureListener { + private static final float SCALE_MULTIPIER = 0.75f; + private final BaseInterpolator mInterpolator = new DecelerateInterpolator(2f); + private float mNormalizedScaleFactor = 0; + + PinchToZoomGestureDetector(Context context) { + this(context, new S()); + } + + PinchToZoomGestureDetector(Context context, S s) { + super(context, s); + s.setRealGestureDetector(this); + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + mNormalizedScaleFactor += (detector.getScaleFactor() - 1f) * SCALE_MULTIPIER; + // Since the scale factor is normalized, it should always be in the range [0, 1] + mNormalizedScaleFactor = rangeLimit(mNormalizedScaleFactor, 1f, 0); + + // Apply decelerate interpolation. This will cause the differences to seem less + // pronounced + // at higher zoom levels. + float transformedScale = mInterpolator.getInterpolation(mNormalizedScaleFactor); + + // Transform back from normalized coordinates to the zoom scale + float zoomLevel = + (getMaxZoomLevel() == getMinZoomLevel()) + ? getMinZoomLevel() + : getMinZoomLevel() + + transformedScale * (getMaxZoomLevel() - getMinZoomLevel()); + + setZoomLevel(rangeLimit(zoomLevel, getMaxZoomLevel(), getMinZoomLevel())); + return true; + } + + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + float initialZoomLevel = getZoomLevel(); + mNormalizedScaleFactor = + (getMaxZoomLevel() == getMinZoomLevel()) + ? 0 + : (initialZoomLevel - getMinZoomLevel()) + / (getMaxZoomLevel() - getMinZoomLevel()); + return true; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + } + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/camerax/FastCameraModels.java b/src/org/thoughtcrime/securesms/mediasend/camerax/FastCameraModels.java new file mode 100644 index 0000000000..d0bb6e39f2 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/camerax/FastCameraModels.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.mediasend.camerax; + +import androidx.annotation.NonNull; + +import java.util.HashSet; +import java.util.Set; + +/** + * A set of {@link android.os.Build#MODEL} that are known to both benefit from + * {@link androidx.camera.core.ImageCapture.CaptureMode#MAX_QUALITY} and execute it quickly. + * + */ +public class FastCameraModels { + + private static final Set MODELS = new HashSet() {{ + add("Pixel 2"); + add("Pixel 2 XL"); + add("Pixel 3"); + add("Pixel 3 XL"); + add("Pixel 3a"); + add("Pixel 3a XL"); + }}; + + /** + * @param model Should be a {@link android.os.Build#MODEL}. + */ + public static boolean contains(@NonNull String model) { + return MODELS.contains(model); + } +} diff --git a/src/org/thoughtcrime/securesms/util/ServiceUtil.java b/src/org/thoughtcrime/securesms/util/ServiceUtil.java index a6cd2d1920..76a413b7fe 100644 --- a/src/org/thoughtcrime/securesms/util/ServiceUtil.java +++ b/src/org/thoughtcrime/securesms/util/ServiceUtil.java @@ -5,6 +5,7 @@ import android.app.AlarmManager; import android.app.NotificationManager; import android.app.job.JobScheduler; import android.content.Context; +import android.hardware.display.DisplayManager; import android.media.AudioManager; import android.net.ConnectivityManager; import android.os.Build; @@ -55,6 +56,10 @@ public class ServiceUtil { return (Vibrator)context.getSystemService(Context.VIBRATOR_SERVICE); } + public static DisplayManager getDisplayManager(@NonNull Context context) { + return (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + } + @RequiresApi(26) public static JobScheduler getJobScheduler(Context context) { return (JobScheduler) context.getSystemService(JobScheduler.class);