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