Move all files to natural position.

This commit is contained in:
Alan Evans
2020-01-06 10:52:48 -05:00
parent 0df36047e7
commit 9ebe920195
3016 changed files with 6 additions and 36 deletions

View File

@@ -0,0 +1,259 @@
package org.thoughtcrime.securesms.mediasend;
import android.graphics.SurfaceTexture;
import android.hardware.Camera;
import androidx.annotation.NonNull;
import android.view.Surface;
import org.thoughtcrime.securesms.logging.Log;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
class Camera1Controller {
private static final String TAG = Camera1Controller.class.getSimpleName();
private final int screenWidth;
private final int screenHeight;
private final OrderEnforcer<Stage> enforcer;
private final EventListener eventListener;
private Camera camera;
private int cameraId;
private SurfaceTexture previewSurface;
private int screenRotation;
Camera1Controller(int preferredDirection, int screenWidth, int screenHeight, @NonNull EventListener eventListener) {
this.eventListener = eventListener;
this.enforcer = new OrderEnforcer<>(Stage.INITIALIZED, Stage.PREVIEW_STARTED);
this.cameraId = Camera.getNumberOfCameras() > 1 ? preferredDirection : Camera.CameraInfo.CAMERA_FACING_BACK;
this.screenWidth = screenWidth;
this.screenHeight = screenHeight;
}
void initialize() {
Log.d(TAG, "initialize()");
if (Camera.getNumberOfCameras() <= 0) {
Log.w(TAG, "Device doesn't have any cameras.");
onCameraUnavailable();
return;
}
try {
camera = Camera.open(cameraId);
} catch (Exception e) {
Log.w(TAG, "Failed to open camera.", e);
onCameraUnavailable();
return;
}
if (camera == null) {
Log.w(TAG, "Null camera instance.");
onCameraUnavailable();
return;
}
Camera.Parameters params = camera.getParameters();
Camera.Size previewSize = getClosestSize(camera.getParameters().getSupportedPreviewSizes(), screenWidth, screenHeight);
Camera.Size pictureSize = getClosestSize(camera.getParameters().getSupportedPictureSizes(), screenWidth, screenHeight);
final List<String> focusModes = params.getSupportedFocusModes();
Log.d(TAG, "Preview size: " + previewSize.width + "x" + previewSize.height + " Picture size: " + pictureSize.width + "x" + pictureSize.height);
params.setPreviewSize(previewSize.width, previewSize.height);
params.setPictureSize(pictureSize.width, pictureSize.height);
params.setFlashMode(Camera.Parameters.FLASH_MODE_OFF);
params.setColorEffect(Camera.Parameters.EFFECT_NONE);
params.setWhiteBalance(Camera.Parameters.WHITE_BALANCE_AUTO);
if (focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) {
params.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);
} else if (focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) {
params.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO);
}
camera.setParameters(params);
enforcer.markCompleted(Stage.INITIALIZED);
eventListener.onPropertiesAvailable(getProperties());
}
void release() {
Log.d(TAG, "release() called");
enforcer.run(Stage.INITIALIZED, () -> {
Log.d(TAG, "release() executing");
previewSurface = null;
camera.stopPreview();
camera.release();
enforcer.reset();
});
}
void linkSurface(@NonNull SurfaceTexture surfaceTexture) {
Log.d(TAG, "linkSurface() called");
enforcer.run(Stage.INITIALIZED, () -> {
try {
Log.d(TAG, "linkSurface() executing");
previewSurface = surfaceTexture;
camera.setPreviewTexture(surfaceTexture);
camera.startPreview();
enforcer.markCompleted(Stage.PREVIEW_STARTED);
} catch (Exception e) {
Log.w(TAG, "Failed to start preview.", e);
eventListener.onCameraUnavailable();
}
});
}
void capture(@NonNull CaptureCallback callback) {
enforcer.run(Stage.PREVIEW_STARTED, () -> {
camera.takePicture(null, null, null, (data, camera) -> {
callback.onCaptureAvailable(data, cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT);
});
});
}
int flip() {
Log.d(TAG, "flip()");
SurfaceTexture surfaceTexture = previewSurface;
cameraId = (cameraId == Camera.CameraInfo.CAMERA_FACING_BACK) ? Camera.CameraInfo.CAMERA_FACING_FRONT : Camera.CameraInfo.CAMERA_FACING_BACK;
release();
initialize();
linkSurface(surfaceTexture);
setScreenRotation(screenRotation);
return cameraId;
}
void setScreenRotation(int screenRotation) {
Log.d(TAG, "setScreenRotation(" + screenRotation + ") called");
enforcer.run(Stage.PREVIEW_STARTED, () -> {
Log.d(TAG, "setScreenRotation(" + screenRotation + ") executing");
this.screenRotation = screenRotation;
int previewRotation = getPreviewRotation(screenRotation);
int outputRotation = getOutputRotation(screenRotation);
Log.d(TAG, "Preview rotation: " + previewRotation + " Output rotation: " + outputRotation);
camera.setDisplayOrientation(previewRotation);
Camera.Parameters params = camera.getParameters();
params.setRotation(outputRotation);
camera.setParameters(params);
});
}
private void onCameraUnavailable() {
enforcer.reset();
eventListener.onCameraUnavailable();
}
private Properties getProperties() {
Camera.Size previewSize = camera.getParameters().getPreviewSize();
return new Properties(Camera.getNumberOfCameras(), previewSize.width, previewSize.height);
}
private Camera.Size getClosestSize(List<Camera.Size> sizes, int width, int height) {
Collections.sort(sizes, ASC_SIZE_COMPARATOR);
int i = 0;
while (i < sizes.size() && (sizes.get(i).width * sizes.get(i).height) < (width * height)) {
i++;
}
return sizes.get(Math.min(i, sizes.size() - 1));
}
private int getOutputRotation(int displayRotationCode) {
int degrees = convertRotationToDegrees(displayRotationCode);
Camera.CameraInfo info = new Camera.CameraInfo();
Camera.getCameraInfo(cameraId, info);
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
return (info.orientation + degrees) % 360;
} else {
return (info.orientation - degrees + 360) % 360;
}
}
private int getPreviewRotation(int displayRotationCode) {
int degrees = convertRotationToDegrees(displayRotationCode);
Camera.CameraInfo info = new Camera.CameraInfo();
Camera.getCameraInfo(cameraId, info);
int result;
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
result = (info.orientation + degrees) % 360;
result = (360 - result) % 360;
} else {
result = (info.orientation - degrees + 360) % 360;
}
return result;
}
private int convertRotationToDegrees(int screenRotation) {
switch (screenRotation) {
case Surface.ROTATION_0: return 0;
case Surface.ROTATION_90: return 90;
case Surface.ROTATION_180: return 180;
case Surface.ROTATION_270: return 270;
}
return 0;
}
private final Comparator<Camera.Size> ASC_SIZE_COMPARATOR = (o1, o2) -> Integer.compare(o1.width * o1.height, o2.width * o2.height);
private enum Stage {
INITIALIZED, PREVIEW_STARTED
}
class Properties {
private final int cameraCount;
private final int previewWidth;
private final int previewHeight;
Properties(int cameraCount, int previewWidth, int previewHeight) {
this.cameraCount = cameraCount;
this.previewWidth = previewWidth;
this.previewHeight = previewHeight;
}
int getCameraCount() {
return cameraCount;
}
int getPreviewWidth() {
return previewWidth;
}
int getPreviewHeight() {
return previewHeight;
}
@Override
public @NonNull String toString() {
return "cameraCount: " + cameraCount + " previewWidth: " + previewWidth + " previewHeight: " + previewHeight;
}
}
interface EventListener {
void onPropertiesAvailable(@NonNull Properties properties);
void onCameraUnavailable();
}
interface CaptureCallback {
void onCaptureAvailable(@NonNull byte[] jpegData, boolean frontFacing);
}
}

View File

@@ -0,0 +1,354 @@
package org.thoughtcrime.securesms.mediasend;
import android.annotation.SuppressLint;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.SurfaceTexture;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.view.Display;
import android.view.GestureDetector;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.TextureView;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.RotateAnimation;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.MultiTransformation;
import com.bumptech.glide.load.Transformation;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.request.target.SimpleTarget;
import com.bumptech.glide.request.transition.Transition;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.ByteArrayOutputStream;
/**
* 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
{
private static final String TAG = Camera1Fragment.class.getSimpleName();
private TextureView cameraPreview;
private ViewGroup controlsContainer;
private ImageButton flipButton;
private View captureButton;
private Camera1Controller camera;
private Controller controller;
private OrderEnforcer<Stage> orderEnforcer;
private Camera1Controller.Properties properties;
private MediaSendViewModel viewModel;
public static Camera1Fragment newInstance() {
return new Camera1Fragment();
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (!(getActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement the Controller interface.");
}
WindowManager windowManager = ServiceUtil.getWindowManager(getActivity());
Display display = windowManager.getDefaultDisplay();
Point displaySize = new Point();
display.getSize(displaySize);
controller = (Controller) getActivity();
camera = new Camera1Controller(TextSecurePreferences.getDirectCaptureCameraId(getContext()), displaySize.x, displaySize.y, this);
orderEnforcer = new OrderEnforcer<>(Stage.SURFACE_AVAILABLE, Stage.CAMERA_PROPERTIES_AVAILABLE);
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.camera_fragment, container, false);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
cameraPreview = view.findViewById(R.id.camera_preview);
controlsContainer = view.findViewById(R.id.camera_controls_container);
onOrientationChanged(getResources().getConfiguration().orientation);
cameraPreview.setSurfaceTextureListener(this);
GestureDetector gestureDetector = new GestureDetector(flipGestureListener);
cameraPreview.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event));
viewModel.getMostRecentMediaItem(requireContext()).observe(this, this::presentRecentItemThumbnail);
viewModel.getHudState().observe(this, this::presentHud);
}
@Override
public void onResume() {
super.onResume();
viewModel.onCameraStarted();
camera.initialize();
if (cameraPreview.isAvailable()) {
orderEnforcer.markCompleted(Stage.SURFACE_AVAILABLE);
}
if (properties != null) {
orderEnforcer.markCompleted(Stage.CAMERA_PROPERTIES_AVAILABLE);
}
orderEnforcer.run(Stage.SURFACE_AVAILABLE, () -> {
camera.linkSurface(cameraPreview.getSurfaceTexture());
camera.setScreenRotation(controller.getDisplayRotation());
});
orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale);
}
@Override
public void onPause() {
super.onPause();
camera.release();
orderEnforcer.reset();
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onOrientationChanged(newConfig.orientation);
}
@Override
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
Log.d(TAG, "onSurfaceTextureAvailable");
orderEnforcer.markCompleted(Stage.SURFACE_AVAILABLE);
}
@Override
public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
orderEnforcer.run(Stage.SURFACE_AVAILABLE, () -> camera.setScreenRotation(controller.getDisplayRotation()));
orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale);
}
@Override
public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
return false;
}
@Override
public void onSurfaceTextureUpdated(SurfaceTexture surface) {
}
@Override
public void onPropertiesAvailable(@NonNull Camera1Controller.Properties properties) {
Log.d(TAG, "Got camera properties: " + properties);
this.properties = properties;
orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale);
orderEnforcer.markCompleted(Stage.CAMERA_PROPERTIES_AVAILABLE);
}
@Override
public void onCameraUnavailable() {
controller.onCameraError();
}
private void presentRecentItemThumbnail(Optional<Media> media) {
if (media == null) {
return;
}
ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button);
if (media.isPresent()) {
thumbnail.setVisibility(View.VISIBLE);
Glide.with(this)
.load(new DecryptableUri(media.get().getUri()))
.centerCrop()
.into(thumbnail);
} else {
thumbnail.setVisibility(View.GONE);
thumbnail.setImageResource(0);
}
}
private void presentHud(@Nullable MediaSendViewModel.HudState state) {
if (state == null) return;
View countButton = controlsContainer.findViewById(R.id.camera_count_button);
TextView countButtonText = controlsContainer.findViewById(R.id.mediasend_count_button_text);
if (state.getButtonState() == MediaSendViewModel.ButtonState.COUNT) {
countButton.setVisibility(View.VISIBLE);
countButtonText.setText(String.valueOf(state.getSelectionCount()));
} else {
countButton.setVisibility(View.GONE);
}
}
private void initControls() {
flipButton = requireView().findViewById(R.id.camera_flip_button);
captureButton = requireView().findViewById(R.id.camera_capture_button);
View galleryButton = requireView().findViewById(R.id.camera_gallery_button);
View countButton = requireView().findViewById(R.id.camera_count_button);
captureButton.setOnClickListener(v -> {
captureButton.setEnabled(false);
onCaptureClicked();
});
orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, () -> {
if (properties.getCameraCount() > 1) {
flipButton.setVisibility(properties.getCameraCount() > 1 ? View.VISIBLE : View.GONE);
flipButton.setOnClickListener(v -> {
int newCameraId = camera.flip();
TextSecurePreferences.setDirectCaptureCameraId(getContext(), newCameraId);
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);
}
});
galleryButton.setOnClickListener(v -> controller.onGalleryClicked());
countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked());
viewModel.onCameraControlsInitialized();
}
private void onCaptureClicked() {
orderEnforcer.reset();
Stopwatch fastCaptureTimer = new Stopwatch("Capture");
camera.capture((jpegData, frontFacing) -> {
fastCaptureTimer.split("captured");
Transformation<Bitmap> transformation = frontFacing ? new MultiTransformation<>(new CenterCrop(), new FlipTransformation())
: new CenterCrop();
GlideApp.with(this)
.asBitmap()
.load(jpegData)
.transform(transformation)
.override(cameraPreview.getWidth(), cameraPreview.getHeight())
.into(new SimpleTarget<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
fastCaptureTimer.split("transform");
ByteArrayOutputStream stream = new ByteArrayOutputStream();
resource.compress(Bitmap.CompressFormat.JPEG, 80, stream);
fastCaptureTimer.split("compressed");
byte[] data = stream.toByteArray();
fastCaptureTimer.split("bytes");
fastCaptureTimer.stop(TAG);
controller.onImageCaptured(data, resource.getWidth(), resource.getHeight());
}
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
controller.onCameraError();
}
});
});
}
private PointF getScaleTransform(float viewWidth, float viewHeight, int cameraWidth, int cameraHeight) {
float camWidth = isPortrait() ? Math.min(cameraWidth, cameraHeight) : Math.max(cameraWidth, cameraHeight);
float camHeight = isPortrait() ? Math.max(cameraWidth, cameraHeight) : Math.min(cameraWidth, cameraHeight);
float scaleX = 1;
float scaleY = 1;
if ((camWidth / viewWidth) > (camHeight / viewHeight)) {
float targetWidth = viewHeight * (camWidth / camHeight);
scaleX = targetWidth / viewWidth;
} else {
float targetHeight = viewWidth * (camHeight / camWidth);
scaleY = targetHeight / viewHeight;
}
return new PointF(scaleX, scaleY);
}
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();
}
private void updatePreviewScale() {
PointF scale = getScaleTransform(cameraPreview.getWidth(), cameraPreview.getHeight(), properties.getPreviewWidth(), properties.getPreviewHeight());
Matrix matrix = new Matrix();
float camWidth = isPortrait() ? Math.min(cameraPreview.getWidth(), cameraPreview.getHeight()) : Math.max(cameraPreview.getWidth(), cameraPreview.getHeight());
float camHeight = isPortrait() ? Math.max(cameraPreview.getWidth(), cameraPreview.getHeight()) : Math.min(cameraPreview.getWidth(), cameraPreview.getHeight());
matrix.setScale(scale.x, scale.y);
matrix.postTranslate((camWidth - (camWidth * scale.x)) / 2, (camHeight - (camHeight * scale.y)) / 2);
cameraPreview.setTransform(matrix);
}
private boolean isPortrait() {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT;
}
private final GestureDetector.OnGestureListener flipGestureListener = new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
return true;
}
@Override
public boolean onDoubleTap(MotionEvent e) {
flipButton.performClick();
return true;
}
};
private enum Stage {
SURFACE_AVAILABLE, CAMERA_PROPERTIES_AVAILABLE
}
}

View File

@@ -0,0 +1,309 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.Util;
public class CameraButtonView extends View {
private enum CameraButtonMode { IMAGE, MIXED }
private static final float CAPTURE_ARC_STROKE_WIDTH = 6f;
private static final float HALF_CAPTURE_ARC_STROKE_WIDTH = CAPTURE_ARC_STROKE_WIDTH / 2;
private static final float PROGRESS_ARC_STROKE_WIDTH = 12f;
private static final float HALF_PROGRESS_ARC_STROKE_WIDTH = PROGRESS_ARC_STROKE_WIDTH / 2;
private static final float MINIMUM_ALLOWED_ZOOM_STEP = 0.005f;
private static final float DEADZONE_REDUCTION_PERCENT = 0.35f;
private static final int DRAG_DISTANCE_MULTIPLIER = 3;
private static final Interpolator ZOOM_INTERPOLATOR = new DecelerateInterpolator();
private final @NonNull Paint outlinePaint = outlinePaint();
private final @NonNull Paint backgroundPaint = backgroundPaint();
private final @NonNull Paint arcPaint = arcPaint();
private final @NonNull Paint recordPaint = recordPaint();
private final @NonNull Paint progressPaint = progressPaint();
private Animation growAnimation;
private Animation shrinkAnimation;
private boolean isRecordingVideo;
private float progressPercent = 0f;
private float latestIncrement = 0f;
private @NonNull CameraButtonMode cameraButtonMode = CameraButtonMode.IMAGE;
private @Nullable VideoCaptureListener videoCaptureListener;
private final float imageCaptureSize;
private final float recordSize;
private final RectF progressRect = new RectF();
private final Rect deadzoneRect = new Rect();
private final @NonNull OnLongClickListener internalLongClickListener = v -> {
notifyVideoCaptureStarted();
shrinkAnimation.cancel();
setScaleX(1f);
setScaleY(1f);
isRecordingVideo = true;
return true;
};
public CameraButtonView(@NonNull Context context) {
this(context, null);
}
public CameraButtonView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.camera_button_style);
}
public CameraButtonView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CameraButtonView, defStyleAttr, 0);
imageCaptureSize = a.getDimensionPixelSize(R.styleable.CameraButtonView_imageCaptureSize, -1);
recordSize = a.getDimensionPixelSize(R.styleable.CameraButtonView_recordSize, -1);
a.recycle();
initializeImageAnimations();
}
private static Paint recordPaint() {
Paint recordPaint = new Paint();
recordPaint.setColor(0xFFF44336);
recordPaint.setAntiAlias(true);
recordPaint.setStyle(Paint.Style.FILL);
return recordPaint;
}
private static Paint outlinePaint() {
Paint outlinePaint = new Paint();
outlinePaint.setColor(0x26000000);
outlinePaint.setAntiAlias(true);
outlinePaint.setStyle(Paint.Style.STROKE);
outlinePaint.setStrokeWidth(1.5f);
return outlinePaint;
}
private static Paint backgroundPaint() {
Paint backgroundPaint = new Paint();
backgroundPaint.setColor(0x4CFFFFFF);
backgroundPaint.setAntiAlias(true);
backgroundPaint.setStyle(Paint.Style.FILL);
return backgroundPaint;
}
private static Paint arcPaint() {
Paint arcPaint = new Paint();
arcPaint.setColor(0xFFFFFFFF);
arcPaint.setAntiAlias(true);
arcPaint.setStyle(Paint.Style.STROKE);
arcPaint.setStrokeWidth(CAPTURE_ARC_STROKE_WIDTH);
return arcPaint;
}
private static Paint progressPaint() {
Paint progressPaint = new Paint();
progressPaint.setColor(0xFFFFFFFF);
progressPaint.setAntiAlias(true);
progressPaint.setStyle(Paint.Style.STROKE);
progressPaint.setStrokeWidth(PROGRESS_ARC_STROKE_WIDTH);
progressPaint.setShadowLayer(4, 0, 2, 0x40000000);
return progressPaint;
}
private void initializeImageAnimations() {
shrinkAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_shrink);
growAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_grow);
shrinkAnimation.setFillAfter(true);
shrinkAnimation.setFillEnabled(true);
growAnimation.setFillAfter(true);
growAnimation.setFillEnabled(true);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (isRecordingVideo) {
drawForVideoCapture(canvas);
} else {
drawForImageCapture(canvas);
}
}
private void drawForImageCapture(Canvas canvas) {
float centerX = getWidth() / 2f;
float centerY = getHeight() / 2f;
float radius = imageCaptureSize / 2f;
canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
canvas.drawCircle(centerX, centerY, radius, outlinePaint);
canvas.drawCircle(centerX, centerY, radius - HALF_CAPTURE_ARC_STROKE_WIDTH, arcPaint);
}
private void drawForVideoCapture(Canvas canvas) {
float centerX = getWidth() / 2f;
float centerY = getHeight() / 2f;
canvas.drawCircle(centerX, centerY, centerY, backgroundPaint);
canvas.drawCircle(centerX, centerY, centerY, outlinePaint);
canvas.drawCircle(centerX, centerY, recordSize / 2f, recordPaint);
progressRect.top = HALF_PROGRESS_ARC_STROKE_WIDTH;
progressRect.left = HALF_PROGRESS_ARC_STROKE_WIDTH;
progressRect.right = getWidth() - HALF_PROGRESS_ARC_STROKE_WIDTH;
progressRect.bottom = getHeight() - HALF_PROGRESS_ARC_STROKE_WIDTH;
canvas.drawArc(progressRect, 270f, 360f * progressPercent, false, progressPaint);
}
@Override
public void setOnLongClickListener(@Nullable OnLongClickListener listener) {
throw new IllegalStateException("Use setVideoCaptureListener instead");
}
public void setVideoCaptureListener(@Nullable VideoCaptureListener videoCaptureListener) {
if (isRecordingVideo) throw new IllegalStateException("Cannot set video capture listener while recording");
if (videoCaptureListener != null) {
this.cameraButtonMode = CameraButtonMode.MIXED;
this.videoCaptureListener = videoCaptureListener;
super.setOnLongClickListener(internalLongClickListener);
} else {
this.cameraButtonMode = CameraButtonMode.IMAGE;
this.videoCaptureListener = null;
super.setOnLongClickListener(null);
}
}
public void setProgress(float percentage) {
progressPercent = Util.clamp(percentage, 0f, 1f);
invalidate();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (cameraButtonMode == CameraButtonMode.IMAGE) {
return handleImageModeTouchEvent(event);
}
boolean eventWasHandled = handleVideoModeTouchEvent(event);
int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
isRecordingVideo = false;
}
return eventWasHandled;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
getLocalVisibleRect(deadzoneRect);
deadzoneRect.left += (int) (getWidth() * DEADZONE_REDUCTION_PERCENT / 2f);
deadzoneRect.top += (int) (getHeight() * DEADZONE_REDUCTION_PERCENT / 2f);
deadzoneRect.right -= (int) (getWidth() * DEADZONE_REDUCTION_PERCENT / 2f);
deadzoneRect.bottom -= (int) (getHeight() * DEADZONE_REDUCTION_PERCENT / 2f);
}
private boolean handleImageModeTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (isEnabled()) {
startAnimation(shrinkAnimation);
performClick();
}
return true;
case MotionEvent.ACTION_UP:
startAnimation(growAnimation);
return true;
default:
return super.onTouchEvent(event);
}
}
private boolean handleVideoModeTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
latestIncrement = 0f;
if (isEnabled()) {
startAnimation(shrinkAnimation);
}
case MotionEvent.ACTION_MOVE:
if (isRecordingVideo && eventIsNotInsideDeadzone(event)) {
float maxRange = getHeight() * DRAG_DISTANCE_MULTIPLIER;
float deltaY = Math.abs(event.getY() - deadzoneRect.top);
float increment = Math.min(1f, deltaY / maxRange);
if (Math.abs(increment - latestIncrement) < MINIMUM_ALLOWED_ZOOM_STEP) {
break;
}
latestIncrement = increment;
notifyZoomPercent(ZOOM_INTERPOLATOR.getInterpolation(increment));
invalidate();
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (!isRecordingVideo) {
startAnimation(growAnimation);
}
notifyVideoCaptureEnded();
break;
}
return super.onTouchEvent(event);
}
private boolean eventIsNotInsideDeadzone(MotionEvent event) {
return Math.round(event.getY()) < deadzoneRect.top;
}
private void notifyVideoCaptureStarted() {
if (!isRecordingVideo && videoCaptureListener != null) {
videoCaptureListener.onVideoCaptureStarted();
}
}
private void notifyVideoCaptureEnded() {
if (isRecordingVideo && videoCaptureListener != null) {
videoCaptureListener.onVideoCaptureComplete();
}
}
private void notifyZoomPercent(float percent) {
if (isRecordingVideo && videoCaptureListener != null) {
videoCaptureListener.onZoomIncremented(percent);
}
}
interface VideoCaptureListener {
void onVideoCaptureStarted();
void onVideoCaptureComplete();
void onZoomIncremented(float percent);
}
}

View File

@@ -0,0 +1,252 @@
package org.thoughtcrime.securesms.mediasend;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.adapter.SectionedRecyclerViewAdapter;
import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
class CameraContactAdapter extends SectionedRecyclerViewAdapter<String, CameraContactAdapter.ContactSection> {
private static final int TYPE_INVITE = 1337;
private static final long ID_INVITE = Long.MAX_VALUE;
private static final String TAG_RECENT = "recent";
private static final String TAG_ALL = "all";
private static final String TAG_GROUPS = "groups";
private final GlideRequests glideRequests;
private final Set<Recipient> selected;
private final CameraContactListener cameraContactListener;
private final List<ContactSection> sections = new ArrayList<ContactSection>(3) {{
ContactSection recentContacts = new ContactSection(TAG_RECENT, R.string.CameraContacts_recent_contacts, Collections.emptyList(), 0);
ContactSection allContacts = new ContactSection(TAG_ALL, R.string.CameraContacts_signal_contacts, Collections.emptyList(), recentContacts.size());
ContactSection groups = new ContactSection(TAG_GROUPS, R.string.CameraContacts_signal_groups, Collections.emptyList(), recentContacts.size() + allContacts.size());
add(recentContacts);
add(allContacts);
add(groups);
}};
CameraContactAdapter(@NonNull GlideRequests glideRequests, @NonNull CameraContactListener listener) {
this.glideRequests = glideRequests;
this.selected = new HashSet<>();
this.cameraContactListener = listener;
}
@Override
protected @NonNull List<ContactSection> getSections() {
return sections;
}
@Override
public long getItemId(int globalPosition) {
if (isInvitePosition(globalPosition)) {
return ID_INVITE;
} else {
return super.getItemId(globalPosition);
}
}
@Override
public int getItemViewType(int globalPosition) {
if (isInvitePosition(globalPosition)) {
return TYPE_INVITE;
} else {
return super.getItemViewType(globalPosition);
}
}
@Override
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
if (viewType == TYPE_INVITE) {
return new InviteViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.camera_contact_invite_item, viewGroup, false));
} else {
return super.onCreateViewHolder(viewGroup, viewType);
}
}
@Override
protected @NonNull RecyclerView.ViewHolder createHeaderViewHolder(@NonNull ViewGroup parent) {
return new HeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.camera_contact_header_item, parent, false));
}
@Override
protected @NonNull RecyclerView.ViewHolder createContentViewHolder(@NonNull ViewGroup parent) {
return new ContactViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.camera_contact_contact_item, parent, false));
}
@Override
protected @Nullable RecyclerView.ViewHolder createEmptyViewHolder(@NonNull ViewGroup viewGroup) {
return null;
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int globalPosition) {
if (isInvitePosition(globalPosition)) {
((InviteViewHolder) holder).bind(cameraContactListener);
} else {
super.onBindViewHolder(holder, globalPosition);
}
}
@Override
protected void bindViewHolder(@NonNull RecyclerView.ViewHolder holder, @NonNull ContactSection section, int localPosition) {
section.bind(holder, localPosition, selected, glideRequests, cameraContactListener);
}
@Override
public int getItemCount() {
return super.getItemCount() + 1;
}
public void setContacts(@NonNull CameraContacts contacts, @NonNull Collection<Recipient> selected) {
ContactSection recentContacts = new ContactSection(TAG_RECENT, R.string.CameraContacts_recent_contacts, contacts.getRecents(), 0);
ContactSection allContacts = new ContactSection(TAG_ALL, R.string.CameraContacts_signal_contacts, contacts.getContacts(), recentContacts.size());
ContactSection groups = new ContactSection(TAG_GROUPS, R.string.CameraContacts_signal_groups, contacts.getGroups(), recentContacts.size() + allContacts.size());
sections.clear();
sections.add(recentContacts);
sections.add(allContacts);
sections.add(groups);
this.selected.clear();
this.selected.addAll(selected);
notifyDataSetChanged();
}
private boolean isInvitePosition(int globalPosition) {
return globalPosition == getItemCount() - 1;
}
public static class ContactSection extends SectionedRecyclerViewAdapter.Section<String> {
private final String tag;
private final int titleResId;
private final List<Recipient> recipients;
public ContactSection(@NonNull String tag, @StringRes int titleResId, @NonNull List<Recipient> recipients, int offset) {
super(offset);
this.tag = tag;
this.titleResId = titleResId;
this.recipients = recipients;
}
@Override
public boolean hasEmptyState() {
return false;
}
@Override
public int getContentSize() {
return recipients.size();
}
@Override
public long getItemId(@NonNull StableIdGenerator<String> idGenerator, int globalPosition) {
int localPosition = getLocalPosition(globalPosition);
if (localPosition == 0) {
return idGenerator.getId(tag);
} else {
return idGenerator.getId(recipients.get(localPosition - 1).getId().serialize());
}
}
void bind(@NonNull RecyclerView.ViewHolder viewHolder,
int localPosition,
@NonNull Set<Recipient> selected,
@NonNull GlideRequests glideRequests,
@NonNull CameraContactListener cameraContactListener)
{
if (localPosition == 0) {
((HeaderViewHolder) viewHolder).bind(titleResId);
} else {
Recipient recipient = recipients.get(localPosition - 1);
((ContactViewHolder) viewHolder).bind(recipient, selected.contains(recipient), glideRequests, cameraContactListener);
}
}
}
private static class HeaderViewHolder extends RecyclerView.ViewHolder {
private final TextView title;
HeaderViewHolder(@NonNull View itemView) {
super(itemView);
this.title = itemView.findViewById(R.id.camera_contact_header);
}
void bind(@StringRes int titleResId) {
this.title.setText(titleResId);
}
}
private static class ContactViewHolder extends RecyclerView.ViewHolder {
private final AvatarImageView avatar;
private final FromTextView name;
private final CheckBox checkbox;
ContactViewHolder(@NonNull View itemView) {
super(itemView);
this.avatar = itemView.findViewById(R.id.camera_contact_item_avatar);
this.name = itemView.findViewById(R.id.camera_contact_item_name);
this.checkbox = itemView.findViewById(R.id.camera_contact_item_checkbox);
}
void bind(@NonNull Recipient recipient,
boolean selected,
@NonNull GlideRequests glideRequests,
@NonNull CameraContactListener listener)
{
avatar.setAvatar(glideRequests, recipient, false);
name.setText(recipient);
itemView.setOnClickListener(v -> listener.onContactClicked(recipient));
checkbox.setChecked(selected);
}
}
private static class InviteViewHolder extends RecyclerView.ViewHolder {
private final View inviteButton;
public InviteViewHolder(@NonNull View itemView) {
super(itemView);
inviteButton = itemView.findViewById(R.id.camera_contact_invite);
}
void bind(@NonNull CameraContactListener listener) {
inviteButton.setOnClickListener(v -> listener.onInviteContactsClicked());
}
}
interface CameraContactListener {
void onContactClicked(@NonNull Recipient recipient);
void onInviteContactsClicked();
}
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.mediasend;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
import java.util.ArrayList;
import java.util.List;
class CameraContactSelectionAdapter extends RecyclerView.Adapter<CameraContactSelectionAdapter.RecipientViewHolder> {
private final List<Recipient> recipients = new ArrayList<>();
private final StableIdGenerator<String> idGenerator = new StableIdGenerator<>();
CameraContactSelectionAdapter() {
setHasStableIds(true);
}
@Override
public long getItemId(int position) {
return idGenerator.getId(recipients.get(position).getId().serialize());
}
@Override
public @NonNull RecipientViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new RecipientViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.camera_contact_selection_item, parent, false));
}
@Override
public void onBindViewHolder(@NonNull RecipientViewHolder holder, int position) {
holder.bind(recipients.get(position), position == recipients.size() - 1);
}
@Override
public int getItemCount() {
return recipients.size();
}
void setRecipients(@NonNull List<Recipient> recipients) {
this.recipients.clear();
this.recipients.addAll(recipients);
notifyDataSetChanged();
}
static class RecipientViewHolder extends RecyclerView.ViewHolder {
private final FromTextView name;
RecipientViewHolder(View itemView) {
super(itemView);
name = (FromTextView) itemView;
}
void bind(@NonNull Recipient recipient, boolean isLast) {
name.setText(recipient, true, isLast ? null : ",");
}
}
}

View File

@@ -0,0 +1,197 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import androidx.constraintlayout.widget.Group;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.InviteActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.List;
/**
* Fragment that selects Signal contacts. Intended to be used in the camera-first capture flow.
*/
public class CameraContactSelectionFragment extends Fragment implements CameraContactAdapter.CameraContactListener {
private Controller controller;
private MediaSendViewModel mediaSendViewModel;
private CameraContactSelectionViewModel contactViewModel;
private RecyclerView contactList;
private CameraContactAdapter contactAdapter;
private RecyclerView selectionList;
private CameraContactSelectionAdapter selectionAdapter;
private Toolbar toolbar;
private View sendButton;
private Group selectionFooterGroup;
private ViewGroup cameraContactsEmpty;
private View inviteButton;
public static Fragment newInstance() {
return new CameraContactSelectionFragment();
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
this.mediaSendViewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
this.contactViewModel = ViewModelProviders.of(requireActivity(), new CameraContactSelectionViewModel.Factory(new CameraContactsRepository(requireContext())))
.get(CameraContactSelectionViewModel.class);
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
if (!(getActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement controller interface.");
}
controller = (Controller) getActivity();
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
int theme = TextSecurePreferences.getTheme(inflater.getContext()).equals("light") ? R.style.TextSecure_LightTheme
: R.style.TextSecure_DarkTheme;
return ThemeUtil.getThemedInflater(inflater.getContext(), inflater, theme)
.inflate(R.layout.camera_contact_selection_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
this.contactList = view.findViewById(R.id.camera_contacts_list);
this.selectionList = view.findViewById(R.id.camera_contacts_selected_list);
this.toolbar = view.findViewById(R.id.camera_contacts_toolbar);
this.sendButton = view.findViewById(R.id.camera_contacts_send_button);
this.selectionFooterGroup = view.findViewById(R.id.camera_contacts_footer_group);
this.cameraContactsEmpty = view.findViewById(R.id.camera_contacts_empty);
this.inviteButton = view.findViewById(R.id.camera_contacts_invite_button);
this.contactAdapter = new CameraContactAdapter(GlideApp.with(this), this);
this.selectionAdapter = new CameraContactSelectionAdapter();
contactList.setLayoutManager(new LinearLayoutManager(requireContext()));
contactList.setAdapter(contactAdapter);
selectionList.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false));
selectionList.setAdapter(selectionAdapter);
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
inviteButton.setOnClickListener(v -> onInviteContactsClicked());
initViewModel();
}
@Override
public void onResume() {
super.onResume();
mediaSendViewModel.onContactSelectStarted();
}
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
requireActivity().getMenuInflater().inflate(R.menu.camera_contacts, menu);
MenuItem searchViewItem = menu.findItem(R.id.menu_search);
SearchView searchView = (SearchView) searchViewItem.getActionView();
SearchView.OnQueryTextListener queryListener = new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
contactViewModel.onQueryUpdated(query);
return true;
}
@Override
public boolean onQueryTextChange(String query) {
contactViewModel.onQueryUpdated(query);
return true;
}
};
searchViewItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
@Override
public boolean onMenuItemActionExpand(MenuItem item) {
searchView.setOnQueryTextListener(queryListener);
return true;
}
@Override
public boolean onMenuItemActionCollapse(MenuItem item) {
searchView.setOnQueryTextListener(null);
contactViewModel.onSearchClosed();
return true;
}
});
}
@Override
public void onContactClicked(@NonNull Recipient recipient) {
contactViewModel.onContactClicked(recipient);
}
@Override
public void onInviteContactsClicked() {
startActivity(new Intent(requireContext(), InviteActivity.class));
}
private void initViewModel() {
contactViewModel.getContacts().observe(getViewLifecycleOwner(), contactState -> {
if (contactState == null) return;
if (contactState.getContacts().isEmpty() && TextUtils.isEmpty(contactState.getQuery())) {
cameraContactsEmpty.setVisibility(View.VISIBLE);
contactList.setVisibility(View.GONE);
selectionFooterGroup.setVisibility(View.GONE);
} else {
cameraContactsEmpty.setVisibility(View.GONE);
contactList.setVisibility(View.VISIBLE);
sendButton.setOnClickListener(v -> controller.onCameraContactsSendClicked(contactState.getSelected()));
contactAdapter.setContacts(contactState.getContacts(), contactState.getSelected());
selectionAdapter.setRecipients(contactState.getSelected());
selectionFooterGroup.setVisibility(contactState.getSelected().isEmpty() ? View.GONE : View.VISIBLE);
}
});
contactViewModel.getError().observe(getViewLifecycleOwner(), error -> {
if (error == null) return;
if (error == CameraContactSelectionViewModel.Error.MAX_SELECTION) {
String message = getString(R.string.CameraContacts_you_can_share_with_a_maximum_of_n_conversations, CameraContactSelectionViewModel.MAX_SELECTION_COUNT);
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
}
});
}
public interface Controller {
void onCameraContactsSendClicked(@NonNull List<Recipient> recipients);
}
}

View File

@@ -0,0 +1,132 @@
package org.thoughtcrime.securesms.mediasend;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.Util;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
class CameraContactSelectionViewModel extends ViewModel {
static final int MAX_SELECTION_COUNT = 16;
private final CameraContactsRepository repository;
private final MutableLiveData<ContactState> contacts;
private final SingleLiveEvent<Error> error;
private final Set<Recipient> selected;
private String currentQuery;
private CameraContactSelectionViewModel(@NonNull CameraContactsRepository repository) {
this.repository = repository;
this.contacts = new MutableLiveData<>();
this.error = new SingleLiveEvent<>();
this.selected = new LinkedHashSet<>();
repository.getCameraContacts(cameraContacts -> {
Util.runOnMain(() -> {
contacts.postValue(new ContactState(cameraContacts, new ArrayList<>(selected), currentQuery));
});
});
}
LiveData<ContactState> getContacts() {
return contacts;
}
LiveData<Error> getError() {
return error;
}
void onSearchClosed() {
onQueryUpdated("");
}
void onQueryUpdated(String query) {
this.currentQuery = query;
repository.getCameraContacts(query, cameraContacts -> {
Util.runOnMain(() -> {
contacts.postValue(new ContactState(cameraContacts, new ArrayList<>(selected), query));
});
});
}
void onRefresh() {
repository.getCameraContacts(cameraContacts -> {
Util.runOnMain(() -> {
contacts.postValue(new ContactState(cameraContacts, new ArrayList<>(selected), currentQuery));
});
});
}
void onContactClicked(@NonNull Recipient recipient) {
if (selected.contains(recipient)) {
selected.remove(recipient);
} else if (selected.size() < MAX_SELECTION_COUNT) {
selected.add(recipient);
} else {
error.postValue(Error.MAX_SELECTION);
}
ContactState currentState = contacts.getValue();
if (currentState != null) {
contacts.setValue(new ContactState(currentState.getContacts(), new ArrayList<>(selected), currentQuery));
}
}
static class ContactState {
private final CameraContacts contacts;
private final List<Recipient> selected;
private final String query;
ContactState(@NonNull CameraContacts contacts, @NonNull List<Recipient> selected, @Nullable String query) {
this.contacts = contacts;
this.selected = selected;
this.query = query;
}
public CameraContacts getContacts() {
return contacts;
}
public List<Recipient> getSelected() {
return selected;
}
public @Nullable String getQuery() {
return query;
}
}
enum Error {
MAX_SELECTION
}
static class Factory extends ViewModelProvider.NewInstanceFactory {
private final CameraContactsRepository repository;
Factory(CameraContactsRepository repository) {
this.repository = repository;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new CameraContactSelectionViewModel(repository));
}
}
}

View File

@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.mediasend;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.List;
/**
* Represents the list of results to display in the {@link CameraContactSelectionFragment}.
*/
public class CameraContacts {
private final List<Recipient> recents;
private final List<Recipient> contacts;
private final List<Recipient> groups;
public CameraContacts(@NonNull List<Recipient> recents, @NonNull List<Recipient> contacts, @NonNull List<Recipient> groups) {
this.recents = recents;
this.contacts = contacts;
this.groups = groups;
}
public @NonNull List<Recipient> getRecents() {
return recents;
}
public @NonNull List<Recipient> getContacts() {
return contacts;
}
public @NonNull List<Recipient> getGroups() {
return groups;
}
public boolean isEmpty() {
return recents.isEmpty() && contacts.isEmpty() && groups.isEmpty();
}
}

View File

@@ -0,0 +1,115 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.Context;
import android.database.Cursor;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.contacts.ContactRepository;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Handles retrieving the data to be shown in {@link CameraContactSelectionFragment}.
*/
class CameraContactsRepository {
private static final int RECENT_MAX = 25;
private final Context context;
private final ThreadDatabase threadDatabase;
private final GroupDatabase groupDatabase;
private final RecipientDatabase recipientDatabase;
private final ContactRepository contactRepository;
CameraContactsRepository(@NonNull Context context) {
this.context = context.getApplicationContext();
this.threadDatabase = DatabaseFactory.getThreadDatabase(context);
this.groupDatabase = DatabaseFactory.getGroupDatabase(context);
this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
this.contactRepository = new ContactRepository(context);
}
void getCameraContacts(@NonNull Callback<CameraContacts> callback) {
getCameraContacts("", callback);
}
void getCameraContacts(@NonNull String query, @NonNull Callback<CameraContacts> callback) {
SignalExecutors.BOUNDED.execute(() -> {
List<Recipient> recents = getRecents(query);
List<Recipient> contacts = getContacts(query);
List<Recipient> groups = getGroups(query);
callback.onComplete(new CameraContacts(recents, contacts, groups));
});
}
@WorkerThread
private @NonNull List<Recipient> getRecents(@NonNull String query) {
if (!TextUtils.isEmpty(query)) {
return Collections.emptyList();
}
List<Recipient> recipients = new ArrayList<>(RECENT_MAX);
try (ThreadDatabase.Reader threadReader = threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(RECENT_MAX, false))) {
ThreadRecord threadRecord;
while ((threadRecord = threadReader.getNext()) != null) {
recipients.add(threadRecord.getRecipient().resolve());
}
}
return recipients;
}
@WorkerThread
private @NonNull List<Recipient> getContacts(@NonNull String query) {
List<Recipient> recipients = new ArrayList<>();
try (Cursor cursor = contactRepository.querySignalContacts(query)) {
while (cursor.moveToNext()) {
RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN)));
Recipient recipient = Recipient.resolved(id);
recipients.add(recipient);
}
}
return recipients;
}
@WorkerThread
private @NonNull List<Recipient> getGroups(@NonNull String query) {
if (TextUtils.isEmpty(query)) {
return Collections.emptyList();
}
List<Recipient> recipients = new ArrayList<>();
try (GroupDatabase.Reader reader = groupDatabase.getGroupsFilteredByTitle(query, false)) {
GroupDatabase.GroupRecord groupRecord;
while ((groupRecord = reader.getNext()) != null) {
RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupRecord.getEncodedId());
recipients.add(Recipient.resolved(recipientId));
}
}
return recipients;
}
interface Callback<E> {
void onComplete(E result);
}
}

View File

@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.mediasend;
import android.annotation.SuppressLint;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.camera.core.CameraX;
import androidx.fragment.app.Fragment;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
import java.io.FileDescriptor;
import java.util.HashSet;
import java.util.Set;
public interface CameraFragment {
@SuppressLint("RestrictedApi")
static Fragment newInstance() {
if (CameraXUtil.isSupported() && CameraX.isInitialized()) {
return CameraXFragment.newInstance();
} else {
return Camera1Fragment.newInstance();
}
}
interface Controller {
void onCameraError();
void onImageCaptured(@NonNull byte[] data, int width, int height);
void onVideoCaptured(@NonNull FileDescriptor fd);
void onVideoCaptureError();
void onGalleryClicked();
int getDisplayRotation();
void onCameraCountButtonClicked();
}
}

View File

@@ -0,0 +1,396 @@
package org.thoughtcrime.securesms.mediasend;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.view.GestureDetector;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.Surface;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.RotateAnimation;
import android.widget.ImageView;
import android.widget.TextView;
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.camera.core.impl.utils.executor.CameraXExecutors;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import com.bumptech.glide.Glide;
import com.bumptech.glide.util.Executors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXFlashToggleView;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXView;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.util.MemoryFileDescriptor;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.video.VideoUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.FileDescriptor;
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;
private MediaSendViewModel viewModel;
private View selfieFlash;
private MemoryFileDescriptor videoFileDescriptor;
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 void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository()))
.get(MediaSendViewModel.class);
}
@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(getViewLifecycleOwner());
camera.setCameraLensFacing(CameraXUtil.toLensFacing(TextSecurePreferences.getDirectCaptureCameraId(requireContext())));
onOrientationChanged(getResources().getConfiguration().orientation);
viewModel.getMostRecentMediaItem(requireContext()).observe(this, this::presentRecentItemThumbnail);
viewModel.getHudState().observe(this, this::presentHud);
}
@Override
public void onResume() {
super.onResume();
viewModel.onCameraStarted();
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
}
@Override
public void onDestroyView() {
super.onDestroyView();
closeVideoFileDescriptor();
}
@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();
}
private void presentRecentItemThumbnail(Optional<Media> media) {
if (media == null) {
return;
}
ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button);
if (media.isPresent()) {
thumbnail.setVisibility(View.VISIBLE);
Glide.with(this)
.load(new DecryptableUri(media.get().getUri()))
.centerCrop()
.into(thumbnail);
} else {
thumbnail.setVisibility(View.GONE);
thumbnail.setImageResource(0);
}
}
private void presentHud(@Nullable MediaSendViewModel.HudState state) {
if (state == null) return;
View countButton = controlsContainer.findViewById(R.id.camera_count_button);
TextView countButtonText = controlsContainer.findViewById(R.id.mediasend_count_button_text);
if (state.getButtonState() == MediaSendViewModel.ButtonState.COUNT) {
countButton.setVisibility(View.VISIBLE);
countButtonText.setText(String.valueOf(state.getSelectionCount()));
} else {
countButton.setVisibility(View.GONE);
}
}
@SuppressLint({"ClickableViewAccessibility", "MissingPermission"})
private void initControls() {
View flipButton = requireView().findViewById(R.id.camera_flip_button);
CameraButtonView captureButton = requireView().findViewById(R.id.camera_capture_button);
View galleryButton = requireView().findViewById(R.id.camera_gallery_button);
View countButton = requireView().findViewById(R.id.camera_count_button);
CameraXFlashToggleView flashButton = requireView().findViewById(R.id.camera_flash_button);
selfieFlash = requireView().findViewById(R.id.camera_selfie_flash);
captureButton.setOnClickListener(v -> {
captureButton.setEnabled(false);
flipButton.setEnabled(false);
flashButton.setEnabled(false);
onCaptureClicked();
});
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);
flashButton.setAutoFlashEnabled(camera.hasFlash());
flashButton.setFlash(camera.getFlash());
});
GestureDetector gestureDetector = new GestureDetector(requireContext(), new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDoubleTap(MotionEvent e) {
if (flipButton.isEnabled()) {
flipButton.performClick();
}
return true;
}
});
camera.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event));
} else {
flipButton.setVisibility(View.GONE);
}
flashButton.setAutoFlashEnabled(camera.hasFlash());
flashButton.setFlash(camera.getFlash());
flashButton.setOnFlashModeChangedListener(camera::setFlash);
galleryButton.setOnClickListener(v -> controller.onGalleryClicked());
countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked());
if (isVideoRecordingSupported(requireContext())) {
try {
closeVideoFileDescriptor();
videoFileDescriptor = CameraXVideoCaptureHelper.createFileDescriptor(requireContext());
Animation inAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in);
Animation outAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_out);
camera.setCaptureMode(CameraXView.CaptureMode.MIXED);
int maxDuration = VideoUtil.getMaxVideoDurationInSeconds(requireContext(), viewModel.getMediaConstraints());
Log.d(TAG, "Max duration: " + maxDuration + " sec");
captureButton.setVideoCaptureListener(new CameraXVideoCaptureHelper(
this,
captureButton,
camera,
videoFileDescriptor,
maxDuration,
new CameraXVideoCaptureHelper.Callback() {
@Override
public void onVideoRecordStarted() {
hideAndDisableControlsForVideoRecording(captureButton, flashButton, flipButton, outAnimation);
}
@Override
public void onVideoSaved(@NonNull FileDescriptor fd) {
showAndEnableControlsAfterVideoRecording(captureButton, flashButton, flipButton, inAnimation);
controller.onVideoCaptured(fd);
}
@Override
public void onVideoError(@Nullable Throwable cause) {
showAndEnableControlsAfterVideoRecording(captureButton, flashButton, flipButton, inAnimation);
controller.onVideoCaptureError();
}
}
));
displayVideoRecordingTooltipIfNecessary(captureButton);
} catch (IOException e) {
Log.w(TAG, "Video capture is not supported on this device.", e);
}
} else {
Log.i(TAG, "Video capture not supported. " +
"API: " + Build.VERSION.SDK_INT + ", " +
"MFD: " + MemoryFileDescriptor.supported() + ", " +
"Camera: " + CameraXUtil.getLowestSupportedHardwareLevel(requireContext()) + ", " +
"MaxDuration: " + VideoUtil.getMaxVideoDurationInSeconds(requireContext(), viewModel.getMediaConstraints()) + " sec");
}
viewModel.onCameraControlsInitialized();
}
private boolean isVideoRecordingSupported(@NonNull Context context) {
return Build.VERSION.SDK_INT >= 26 &&
MediaConstraints.isVideoTranscodeAvailable() &&
CameraXUtil.isMixedModeSupported(context) &&
VideoUtil.getMaxVideoDurationInSeconds(context, viewModel.getMediaConstraints()) > 0;
}
private void displayVideoRecordingTooltipIfNecessary(CameraButtonView captureButton) {
if (shouldDisplayVideoRecordingTooltip()) {
int displayRotation = requireActivity().getWindowManager().getDefaultDisplay().getRotation();
TooltipPopup.forTarget(captureButton)
.setOnDismissListener(this::neverDisplayVideoRecordingTooltipAgain)
.setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.signal_primary))
.setTextColor(ThemeUtil.getThemedColor(requireContext(), R.attr.conversation_title_color))
.setText(R.string.CameraXFragment_tap_for_photo_hold_for_video)
.show(displayRotation == Surface.ROTATION_0 || displayRotation == Surface.ROTATION_180 ? TooltipPopup.POSITION_ABOVE : TooltipPopup.POSITION_START);
}
}
private boolean shouldDisplayVideoRecordingTooltip() {
return !TextSecurePreferences.hasSeenVideoRecordingTooltip(requireContext()) && MediaConstraints.isVideoTranscodeAvailable();
}
private void neverDisplayVideoRecordingTooltipAgain() {
Context context = getContext();
if (context != null) {
TextSecurePreferences.setHasSeenVideoRecordingTooltip(requireContext(), true);
}
}
private void hideAndDisableControlsForVideoRecording(@NonNull View captureButton,
@NonNull View flashButton,
@NonNull View flipButton,
@NonNull Animation outAnimation)
{
captureButton.setEnabled(false);
flashButton.startAnimation(outAnimation);
flashButton.setVisibility(View.INVISIBLE);
flipButton.startAnimation(outAnimation);
flipButton.setVisibility(View.INVISIBLE);
}
private void showAndEnableControlsAfterVideoRecording(@NonNull View captureButton,
@NonNull View flashButton,
@NonNull View flipButton,
@NonNull Animation inAnimation)
{
requireActivity().runOnUiThread(() -> {
captureButton.setEnabled(true);
flashButton.startAnimation(inAnimation);
flashButton.setVisibility(View.VISIBLE);
flipButton.startAnimation(inAnimation);
flipButton.setVisibility(View.VISIBLE);
});
}
private void onCaptureClicked() {
Stopwatch stopwatch = new Stopwatch("Capture");
CameraXSelfieFlashHelper flashHelper = new CameraXSelfieFlashHelper(
requireActivity().getWindow(),
camera,
selfieFlash
);
camera.takePicture(Executors.mainThreadExecutor(), new ImageCapture.OnImageCapturedListener() {
@Override
public void onCaptureSuccess(ImageProxy image, int rotationDegrees) {
flashHelper.endFlash();
SimpleTask.run(CameraXFragment.this.getViewLifecycleOwner().getLifecycle(), () -> {
stopwatch.split("captured");
try {
return CameraXUtil.toJpeg(image, rotationDegrees, camera.getCameraLensFacing() == CameraX.LensFacing.FRONT);
} catch (IOException e) {
return null;
} finally {
image.close();
}
}, result -> {
stopwatch.split("transformed");
stopwatch.stop(TAG);
if (result != null) {
controller.onImageCaptured(result.getData(), result.getWidth(), result.getHeight());
} else {
controller.onCameraError();
}
});
}
@Override
public void onError(ImageCapture.ImageCaptureError useCaseError, String message, @Nullable Throwable cause) {
flashHelper.endFlash();
controller.onCameraError();
}
});
flashHelper.startFlash();
}
private void closeVideoFileDescriptor() {
if (videoFileDescriptor != null) {
try {
videoFileDescriptor.close();
videoFileDescriptor = null;
} catch (IOException e) {
Log.w(TAG, "Failed to close video file descriptor", e);
}
}
}
}

View File

@@ -0,0 +1,72 @@
package org.thoughtcrime.securesms.mediasend;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.camera.core.CameraX;
import androidx.camera.core.FlashMode;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXView;
@RequiresApi(21)
final class CameraXSelfieFlashHelper {
private static final float MAX_SCREEN_BRIGHTNESS = 1f;
private static final float MAX_SELFIE_FLASH_ALPHA = 0.75f;
private static final long SELFIE_FLASH_DURATION_MS = 250;
private final Window window;
private final CameraXView camera;
private final View selfieFlash;
private float brightnessBeforeFlash;
private boolean inFlash;
CameraXSelfieFlashHelper(@NonNull Window window,
@NonNull CameraXView camera,
@NonNull View selfieFlash)
{
this.window = window;
this.camera = camera;
this.selfieFlash = selfieFlash;
}
void startFlash() {
if (inFlash || !shouldUseViewBasedFlash()) return;
inFlash = true;
WindowManager.LayoutParams params = window.getAttributes();
brightnessBeforeFlash = params.screenBrightness;
params.screenBrightness = MAX_SCREEN_BRIGHTNESS;
window.setAttributes(params);
selfieFlash.animate()
.alpha(MAX_SELFIE_FLASH_ALPHA)
.setDuration(SELFIE_FLASH_DURATION_MS);
}
void endFlash() {
if (!inFlash) return;
WindowManager.LayoutParams params = window.getAttributes();
params.screenBrightness = brightnessBeforeFlash;
window.setAttributes(params);
selfieFlash.animate()
.alpha(0f)
.setDuration(SELFIE_FLASH_DURATION_MS);
inFlash = false;
}
private boolean shouldUseViewBasedFlash() {
return camera.getFlash() == FlashMode.ON &&
!camera.hasFlash() &&
camera.getCameraLensFacing() == CameraX.LensFacing.FRONT;
}
}

View File

@@ -0,0 +1,252 @@
package org.thoughtcrime.securesms.mediasend;
import android.Manifest;
import android.content.Context;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.Size;
import android.view.ViewGroup;
import android.view.animation.LinearInterpolator;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.fragment.app.Fragment;
import com.bumptech.glide.util.Executors;
import com.nineoldandroids.animation.Animator;
import com.nineoldandroids.animation.ValueAnimator;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXView;
import org.thoughtcrime.securesms.mediasend.camerax.VideoCapture;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.MemoryFileDescriptor;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.video.VideoUtil;
import java.io.FileDescriptor;
import java.io.IOException;
@RequiresApi(26)
class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener {
private static final String TAG = CameraXVideoCaptureHelper.class.getName();
private static final String VIDEO_DEBUG_LABEL = "video-capture";
private static final long VIDEO_SIZE = 10 * 1024 * 1024;
private final @NonNull Fragment fragment;
private final @NonNull CameraXView camera;
private final @NonNull Callback callback;
private final @NonNull MemoryFileDescriptor memoryFileDescriptor;
private final @NonNull ValueAnimator updateProgressAnimator;
private boolean isRecording;
private ValueAnimator cameraMetricsAnimator;
private final VideoCapture.OnVideoSavedListener videoSavedListener = new VideoCapture.OnVideoSavedListener() {
@Override
public void onVideoSaved(@NonNull FileDescriptor fileDescriptor) {
try {
isRecording = false;
camera.setZoomLevel(0f);
memoryFileDescriptor.seek(0);
callback.onVideoSaved(fileDescriptor);
} catch (IOException e) {
callback.onVideoError(e);
}
}
@Override
public void onError(@NonNull VideoCapture.VideoCaptureError videoCaptureError,
@NonNull String message,
@Nullable Throwable cause)
{
isRecording = false;
callback.onVideoError(cause);
Util.runOnMain(() -> resetCameraSizing());
}
};
CameraXVideoCaptureHelper(@NonNull Fragment fragment,
@NonNull CameraButtonView captureButton,
@NonNull CameraXView camera,
@NonNull MemoryFileDescriptor memoryFileDescriptor,
int maxVideoDurationSec,
@NonNull Callback callback)
{
this.fragment = fragment;
this.camera = camera;
this.memoryFileDescriptor = memoryFileDescriptor;
this.callback = callback;
this.updateProgressAnimator = ValueAnimator.ofFloat(0f, 1f).setDuration(maxVideoDurationSec * 1000);
updateProgressAnimator.setInterpolator(new LinearInterpolator());
updateProgressAnimator.addUpdateListener(anim -> captureButton.setProgress(anim.getAnimatedFraction()));
updateProgressAnimator.addListener(new AnimationEndCallback() {
@Override
public void onAnimationEnd(Animator animation) {
if (isRecording) onVideoCaptureComplete();
}
});
}
@Override
public void onVideoCaptureStarted() {
Log.d(TAG, "onVideoCaptureStarted");
if (canRecordAudio()) {
isRecording = true;
beginCameraRecording();
} else {
displayAudioRecordingPermissionsDialog();
}
}
private boolean canRecordAudio() {
return Permissions.hasAll(fragment.requireContext(), Manifest.permission.RECORD_AUDIO);
}
private void displayAudioRecordingPermissionsDialog() {
Permissions.with(fragment)
.request(Manifest.permission.RECORD_AUDIO)
.ifNecessary()
.withRationaleDialog(fragment.getString(R.string.ConversationActivity_enable_the_microphone_permission_to_capture_videos_with_sound), R.drawable.ic_mic_solid_24)
.withPermanentDenialDialog(fragment.getString(R.string.ConversationActivity_signal_needs_the_recording_permissions_to_capture_video))
.onAnyDenied(() -> Toast.makeText(fragment.requireContext(), R.string.ConversationActivity_signal_needs_recording_permissions_to_capture_video, Toast.LENGTH_LONG).show())
.execute();
}
private void beginCameraRecording() {
this.camera.setZoomLevel(0f);
callback.onVideoRecordStarted();
shrinkCaptureArea();
camera.startRecording(memoryFileDescriptor.getFileDescriptor(), Executors.mainThreadExecutor(), videoSavedListener);
updateProgressAnimator.start();
}
private void shrinkCaptureArea() {
Size screenSize = getScreenSize();
Size videoRecordingSize = VideoUtil.getVideoRecordingSize();
float scale = getSurfaceScaleForRecording();
float targetWidthForAnimation = videoRecordingSize.getWidth() * scale;
float scaleX = targetWidthForAnimation / screenSize.getWidth();
if (scaleX == 1f) {
float targetHeightForAnimation = videoRecordingSize.getHeight() * scale;
cameraMetricsAnimator = ValueAnimator.ofFloat(screenSize.getHeight(), targetHeightForAnimation);
} else {
cameraMetricsAnimator = ValueAnimator.ofFloat(screenSize.getWidth(), targetWidthForAnimation);
}
ViewGroup.LayoutParams params = camera.getLayoutParams();
cameraMetricsAnimator.setInterpolator(new LinearInterpolator());
cameraMetricsAnimator.setDuration(200);
cameraMetricsAnimator.addListener(new AnimationEndCallback() {
@Override
public void onAnimationEnd(Animator animation) {
if (!isRecording) return;
scaleCameraViewToMatchRecordingSizeAndAspectRatio();
}
});
cameraMetricsAnimator.addUpdateListener(animation -> {
if (scaleX == 1f) {
params.height = Math.round((float) animation.getAnimatedValue());
} else {
params.width = Math.round((float) animation.getAnimatedValue());
}
camera.setLayoutParams(params);
});
cameraMetricsAnimator.start();
}
private void scaleCameraViewToMatchRecordingSizeAndAspectRatio() {
ViewGroup.LayoutParams layoutParams = camera.getLayoutParams();
Size videoRecordingSize = VideoUtil.getVideoRecordingSize();
float scale = getSurfaceScaleForRecording();
layoutParams.height = videoRecordingSize.getHeight();
layoutParams.width = videoRecordingSize.getWidth();
camera.setLayoutParams(layoutParams);
camera.setScaleX(scale);
camera.setScaleY(scale);
}
private Size getScreenSize() {
DisplayMetrics metrics = camera.getResources().getDisplayMetrics();
return new Size(metrics.widthPixels, metrics.heightPixels);
}
private float getSurfaceScaleForRecording() {
Size videoRecordingSize = VideoUtil.getVideoRecordingSize();
Size screenSize = getScreenSize();
return Math.min(screenSize.getHeight(), screenSize.getWidth()) / (float) Math.min(videoRecordingSize.getHeight(), videoRecordingSize.getWidth());
}
private void resetCameraSizing() {
ViewGroup.LayoutParams layoutParams = camera.getLayoutParams();
layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT;
layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT;
camera.setLayoutParams(layoutParams);
camera.setScaleX(1);
camera.setScaleY(1);
}
@Override
public void onVideoCaptureComplete() {
isRecording = false;
if (!canRecordAudio()) return;
Log.d(TAG, "onVideoCaptureComplete");
camera.stopRecording();
if (cameraMetricsAnimator != null && cameraMetricsAnimator.isRunning()) {
cameraMetricsAnimator.reverse();
}
updateProgressAnimator.cancel();
}
@Override
public void onZoomIncremented(float increment) {
float range = camera.getMaxZoomLevel() - camera.getMinZoomLevel();
camera.setZoomLevel(range * increment);
}
static MemoryFileDescriptor createFileDescriptor(@NonNull Context context) throws MemoryFileDescriptor.MemoryFileException {
return MemoryFileDescriptor.newMemoryFileDescriptor(
context,
VIDEO_DEBUG_LABEL,
VIDEO_SIZE
);
}
private abstract class AnimationEndCallback implements Animator.AnimatorListener {
@Override
public final void onAnimationStart(Animator animation) {
}
@Override
public final void onAnimationCancel(Animator animation) {
}
@Override
public final void onAnimationRepeat(Animator animation) {
}
}
interface Callback {
void onVideoRecordStarted();
void onVideoSaved(@NonNull FileDescriptor fd);
void onVideoError(@Nullable Throwable cause);
}
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.mediasend;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import androidx.annotation.NonNull;
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool;
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
import java.security.MessageDigest;
public class FlipTransformation extends BitmapTransformation {
@Override
protected Bitmap transform(@NonNull BitmapPool pool, @NonNull Bitmap toTransform, int outWidth, int outHeight) {
Bitmap output = pool.get(toTransform.getWidth(), toTransform.getHeight(), toTransform.getConfig());
Canvas canvas = new Canvas(output);
Matrix matrix = new Matrix();
matrix.setScale(-1, 1);
matrix.postTranslate(toTransform.getWidth(), 0);
canvas.drawBitmap(toTransform, matrix, null);
return output;
}
@Override
public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) {
messageDigest.update(FlipTransformation.class.getSimpleName().getBytes());
}
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.mediasend;
import android.os.Build;
import java.util.HashSet;
import java.util.Set;
public final class LegacyCameraModels {
private static final Set<String> LEGACY_MODELS = new HashSet<String>() {{
// Pixel 4
add("Pixel 4");
add("Pixel 4 XL");
// Huawei Mate 10
add("ALP-L29");
add("ALP-L09");
add("ALP-AL00");
// Huawei Mate 10 Pro
add("BLA-L29");
add("BLA-L09");
add("BLA-AL00");
add("BLA-A09");
// Huawei Mate 20
add("HMA-L29");
add("HMA-L09");
add("HMA-LX9");
add("HMA-AL00");
// Huawei Mate 20 Pro
add("LYA-L09");
add("LYA-L29");
add("LYA-AL00");
add("LYA-AL10");
add("LYA-TL00");
add("LYA-L0C");
// Huawei P20
add("EML-L29C");
add("EML-L09C");
add("EML-AL00");
add("EML-TL00");
add("EML-L29");
add("EML-L09");
// Huawei P20 Pro
add("CLT-L29C");
add("CLT-L29");
add("CLT-L09C");
add("CLT-L09");
add("CLT-AL00");
add("CLT-AL01");
add("CLT-TL01");
add("CLT-AL00L");
add("CLT-L04");
add("HW-01K");
// Huawei P30
add("ELE-L29");
add("ELE-L09");
add("ELE-AL00");
add("ELE-TL00");
add("ELE-L04");
// Huawei P30 Pro
add("VOG-L29");
add("VOG-L09");
add("VOG-AL00");
add("VOG-TL00");
add("VOG-L04");
add("VOG-AL10");
// Huawei Honor 10
add("COL-AL10");
add("COL-L29");
add("COL-L19");
}};
private LegacyCameraModels() {
}
public static boolean isLegacyCameraModel() {
return LEGACY_MODELS.contains(Build.MODEL);
}
}

View File

@@ -0,0 +1,128 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import org.whispersystems.libsignal.util.guava.Optional;
/**
* Represents a piece of media that the user has on their device.
*/
public class Media implements Parcelable {
public static final String ALL_MEDIA_BUCKET_ID = "org.thoughtcrime.securesms.ALL_MEDIA";
private final Uri uri;
private final String mimeType;
private final long date;
private final int width;
private final int height;
private final long size;
private Optional<String> bucketId;
private Optional<String> caption;
public Media(@NonNull Uri uri, @NonNull String mimeType, long date, int width, int height, long size, Optional<String> bucketId, Optional<String> caption) {
this.uri = uri;
this.mimeType = mimeType;
this.date = date;
this.width = width;
this.height = height;
this.size = size;
this.bucketId = bucketId;
this.caption = caption;
}
protected Media(Parcel in) {
uri = in.readParcelable(Uri.class.getClassLoader());
mimeType = in.readString();
date = in.readLong();
width = in.readInt();
height = in.readInt();
size = in.readLong();
bucketId = Optional.fromNullable(in.readString());
caption = Optional.fromNullable(in.readString());
}
public Uri getUri() {
return uri;
}
public String getMimeType() {
return mimeType;
}
public long getDate() {
return date;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public long getSize() {
return size;
}
public Optional<String> getBucketId() {
return bucketId;
}
public Optional<String> getCaption() {
return caption;
}
public void setCaption(String caption) {
this.caption = Optional.fromNullable(caption);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(uri, flags);
dest.writeString(mimeType);
dest.writeLong(date);
dest.writeInt(width);
dest.writeInt(height);
dest.writeLong(size);
dest.writeString(bucketId.orNull());
dest.writeString(caption.orNull());
}
public static final Creator<Media> CREATOR = new Creator<Media>() {
@Override
public Media createFromParcel(Parcel in) {
return new Media(in);
}
@Override
public Media[] newArray(int size) {
return new Media[size];
}
};
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Media media = (Media) o;
return uri.equals(media.uri);
}
@Override
public int hashCode() {
return uri.hashCode();
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import androidx.annotation.NonNull;
/**
* Represents a folder that's shown in {@link MediaPickerFolderFragment}.
*/
public class MediaFolder {
private final Uri thumbnailUri;
private final String title;
private final int itemCount;
private final String bucketId;
private final FolderType folderType;
MediaFolder(@NonNull Uri thumbnailUri, @NonNull String title, int itemCount, @NonNull String bucketId, @NonNull FolderType folderType) {
this.thumbnailUri = thumbnailUri;
this.title = title;
this.itemCount = itemCount;
this.bucketId = bucketId;
this.folderType = folderType;
}
Uri getThumbnailUri() {
return thumbnailUri;
}
public String getTitle() {
return title;
}
int getItemCount() {
return itemCount;
}
public String getBucketId() {
return bucketId;
}
FolderType getFolderType() {
return folderType;
}
enum FolderType {
NORMAL, CAMERA
}
}

View File

@@ -0,0 +1,98 @@
package org.thoughtcrime.securesms.mediasend;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
import java.util.ArrayList;
import java.util.List;
class MediaPickerFolderAdapter extends RecyclerView.Adapter<MediaPickerFolderAdapter.FolderViewHolder> {
private final GlideRequests glideRequests;
private final EventListener eventListener;
private final List<MediaFolder> folders;
MediaPickerFolderAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
this.glideRequests = glideRequests;
this.eventListener = eventListener;
this.folders = new ArrayList<>();
}
@NonNull
@Override
public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new FolderViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_folder_item, viewGroup, false));
}
@Override
public void onBindViewHolder(@NonNull FolderViewHolder folderViewHolder, int i) {
folderViewHolder.bind(folders.get(i), glideRequests, eventListener);
}
@Override
public void onViewRecycled(@NonNull FolderViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return folders.size();
}
void setFolders(@NonNull List<MediaFolder> folders) {
this.folders.clear();
this.folders.addAll(folders);
notifyDataSetChanged();
}
static class FolderViewHolder extends RecyclerView.ViewHolder {
private final ImageView thumbnail;
private final ImageView icon;
private final TextView title;
private final TextView count;
FolderViewHolder(@NonNull View itemView) {
super(itemView);
thumbnail = itemView.findViewById(R.id.mediapicker_folder_item_thumbnail);
icon = itemView.findViewById(R.id.mediapicker_folder_item_icon);
title = itemView.findViewById(R.id.mediapicker_folder_item_title);
count = itemView.findViewById(R.id.mediapicker_folder_item_count);
}
void bind(@NonNull MediaFolder folder, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
title.setText(folder.getTitle());
count.setText(String.valueOf(folder.getItemCount()));
icon.setImageResource(folder.getFolderType() == MediaFolder.FolderType.CAMERA ? R.drawable.ic_camera_solid_white_24 : R.drawable.ic_folder_white_48dp);
glideRequests.load(folder.getThumbnailUri())
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.withCrossFade())
.into(thumbnail);
itemView.setOnClickListener(v -> eventListener.onFolderClicked(folder));
}
void recycle() {
itemView.setOnClickListener(null);
}
}
interface EventListener {
void onFolderClicked(@NonNull MediaFolder mediaFolder);
}
}

View File

@@ -0,0 +1,156 @@
package org.thoughtcrime.securesms.mediasend;
import androidx.lifecycle.ViewModelProviders;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Point;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.appcompat.widget.Toolbar;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.libsignal.util.guava.Optional;
/**
* Allows the user to select a media folder to explore.
*/
public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener {
private static final String KEY_TOOLBAR_TITLE = "toolbar_title";
private String toolbarTitle;
private MediaSendViewModel viewModel;
private Controller controller;
private GridLayoutManager layoutManager;
public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient) {
String toolbarTitle;
if (recipient != null) {
String name = recipient.getDisplayName(context);
toolbarTitle = context.getString(R.string.MediaPickerActivity_send_to, name);
} else {
toolbarTitle = "";
}
Bundle args = new Bundle();
args.putString(KEY_TOOLBAR_TITLE, toolbarTitle);
MediaPickerFolderFragment fragment = new MediaPickerFolderFragment();
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
toolbarTitle = getArguments().getString(KEY_TOOLBAR_TITLE);
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
if (!(getActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement controller class.");
}
controller = (Controller) getActivity();
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.mediapicker_folder_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
RecyclerView list = view.findViewById(R.id.mediapicker_folder_list);
MediaPickerFolderAdapter adapter = new MediaPickerFolderAdapter(GlideApp.with(this), this);
layoutManager = new GridLayoutManager(requireContext(), 2);
onScreenWidthChanged(getScreenWidth());
list.setLayoutManager(layoutManager);
list.setAdapter(adapter);
viewModel.getFolders(requireContext()).observe(this, adapter::setFolders);
initToolbar(view.findViewById(R.id.mediapicker_toolbar));
}
@Override
public void onResume() {
super.onResume();
viewModel.onFolderPickerStarted();
}
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
switch (item.getItemId()) {
case R.id.mediapicker_menu_camera:
controller.onCameraSelected();
return true;
}
return false;
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onScreenWidthChanged(getScreenWidth());
}
private void initToolbar(Toolbar toolbar) {
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(toolbarTitle);
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
}
private void onScreenWidthChanged(int newWidth) {
if (layoutManager != null) {
layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_folder_width));
}
}
private int getScreenWidth() {
Point size = new Point();
requireActivity().getWindowManager().getDefaultDisplay().getSize(size);
return size.x;
}
@Override
public void onFolderClicked(@NonNull MediaFolder folder) {
controller.onFolderSelected(folder);
}
public interface Controller {
void onFolderSelected(@NonNull MediaFolder folder);
void onCameraSelected();
}
}

View File

@@ -0,0 +1,172 @@
package org.thoughtcrime.securesms.mediasend;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
public class MediaPickerItemAdapter extends RecyclerView.Adapter<MediaPickerItemAdapter.ItemViewHolder> {
private final GlideRequests glideRequests;
private final EventListener eventListener;
private final List<Media> media;
private final List<Media> selected;
private final int maxSelection;
private final StableIdGenerator<Media> stableIdGenerator;
private boolean forcedMultiSelect;
public MediaPickerItemAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, int maxSelection) {
this.glideRequests = glideRequests;
this.eventListener = eventListener;
this.media = new ArrayList<>();
this.maxSelection = maxSelection;
this.stableIdGenerator = new StableIdGenerator<>();
this.selected = new LinkedList<>();
setHasStableIds(true);
}
@Override
public @NonNull ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new ItemViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_media_item, viewGroup, false));
}
@Override
public void onBindViewHolder(@NonNull ItemViewHolder holder, int i) {
holder.bind(media.get(i), forcedMultiSelect, selected, maxSelection, glideRequests, eventListener);
}
@Override
public void onViewRecycled(@NonNull ItemViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return media.size();
}
@Override
public long getItemId(int position) {
return stableIdGenerator.getId(media.get(position));
}
void setMedia(@NonNull List<Media> media) {
this.media.clear();
this.media.addAll(media);
notifyDataSetChanged();
}
void setSelected(@NonNull Collection<Media> selected) {
this.selected.clear();
this.selected.addAll(selected);
notifyDataSetChanged();
}
List<Media> getSelected() {
return selected;
}
void setForcedMultiSelect(boolean forcedMultiSelect) {
this.forcedMultiSelect = forcedMultiSelect;
notifyDataSetChanged();
}
static class ItemViewHolder extends RecyclerView.ViewHolder {
private final ImageView thumbnail;
private final View playOverlay;
private final View selectOn;
private final View selectOff;
private final View selectOverlay;
private final TextView selectOrder;
ItemViewHolder(@NonNull View itemView) {
super(itemView);
thumbnail = itemView.findViewById(R.id.mediapicker_image_item_thumbnail);
playOverlay = itemView.findViewById(R.id.mediapicker_play_overlay);
selectOn = itemView.findViewById(R.id.mediapicker_select_on);
selectOff = itemView.findViewById(R.id.mediapicker_select_off);
selectOverlay = itemView.findViewById(R.id.mediapicker_select_overlay);
selectOrder = itemView.findViewById(R.id.mediapicker_select_order);
}
void bind(@NonNull Media media, boolean multiSelect, List<Media> selected, int maxSelection, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
glideRequests.load(media.getUri())
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.withCrossFade())
.into(thumbnail);
playOverlay.setVisibility(MediaUtil.isVideoType(media.getMimeType()) ? View.VISIBLE : View.GONE);
if (selected.isEmpty() && !multiSelect) {
itemView.setOnClickListener(v -> eventListener.onMediaChosen(media));
selectOn.setVisibility(View.GONE);
selectOff.setVisibility(View.GONE);
selectOverlay.setVisibility(View.GONE);
if (maxSelection > 1) {
itemView.setOnLongClickListener(v -> {
selected.add(media);
eventListener.onMediaSelectionStarted();
eventListener.onMediaSelectionChanged(new ArrayList<>(selected));
return true;
});
}
} else if (selected.contains(media)) {
selectOff.setVisibility(View.VISIBLE);
selectOn.setVisibility(View.VISIBLE);
selectOverlay.setVisibility(View.VISIBLE);
selectOrder.setText(String.valueOf(selected.indexOf(media) + 1));
itemView.setOnLongClickListener(null);
itemView.setOnClickListener(v -> {
selected.remove(media);
eventListener.onMediaSelectionChanged(new ArrayList<>(selected));
});
} else {
selectOff.setVisibility(View.VISIBLE);
selectOn.setVisibility(View.GONE);
selectOverlay.setVisibility(View.GONE);
itemView.setOnLongClickListener(null);
itemView.setOnClickListener(v -> {
if (selected.size() < maxSelection) {
selected.add(media);
eventListener.onMediaSelectionChanged(new ArrayList<>(selected));
} else {
eventListener.onMediaSelectionOverflow(maxSelection);
}
});
}
}
void recycle() {
itemView.setOnClickListener(null);
}
}
interface EventListener {
void onMediaChosen(@NonNull Media media);
void onMediaSelectionStarted();
void onMediaSelectionChanged(@NonNull List<Media> media);
void onMediaSelectionOverflow(int maxSelection);
}
}

View File

@@ -0,0 +1,183 @@
package org.thoughtcrime.securesms.mediasend;
import androidx.lifecycle.ViewModelProviders;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Point;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.appcompat.widget.Toolbar;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Toast;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.Util;
import java.util.ArrayList;
import java.util.List;
/**
* Allows the user to select a set of media items from a specified folder.
*/
public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener {
private static final String KEY_BUCKET_ID = "bucket_id";
private static final String KEY_FOLDER_TITLE = "folder_title";
private static final String KEY_MAX_SELECTION = "max_selection";
private String bucketId;
private String folderTitle;
private int maxSelection;
private MediaSendViewModel viewModel;
private MediaPickerItemAdapter adapter;
private Controller controller;
private GridLayoutManager layoutManager;
public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection) {
Bundle args = new Bundle();
args.putString(KEY_BUCKET_ID, bucketId);
args.putString(KEY_FOLDER_TITLE, folderTitle);
args.putInt(KEY_MAX_SELECTION, maxSelection);
MediaPickerItemFragment fragment = new MediaPickerItemFragment();
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
bucketId = getArguments().getString(KEY_BUCKET_ID);
folderTitle = getArguments().getString(KEY_FOLDER_TITLE);
maxSelection = getArguments().getInt(KEY_MAX_SELECTION);
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
if (!(getActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement controller class.");
}
controller = (Controller) getActivity();
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.mediapicker_item_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
RecyclerView imageList = view.findViewById(R.id.mediapicker_item_list);
adapter = new MediaPickerItemAdapter(GlideApp.with(this), this, maxSelection);
layoutManager = new GridLayoutManager(requireContext(), 4);
imageList.setLayoutManager(layoutManager);
imageList.setAdapter(adapter);
initToolbar(view.findViewById(R.id.mediapicker_toolbar));
onScreenWidthChanged(getScreenWidth());
if (!Util.isEmpty(viewModel.getSelectedMedia().getValue())) {
adapter.setSelected(viewModel.getSelectedMedia().getValue());
onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue()));
}
viewModel.getMediaInBucket(requireContext(), bucketId).observe(this, adapter::setMedia);
}
@Override
public void onResume() {
super.onResume();
viewModel.onItemPickerStarted();
adapter.setForcedMultiSelect(true);
viewModel.onMultiSelectStarted();
}
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
switch (item.getItemId()) {
case R.id.mediapicker_menu_camera:
controller.onCameraSelected();
return true;
}
return false;
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onScreenWidthChanged(getScreenWidth());
}
@Override
public void onMediaChosen(@NonNull Media media) {
controller.onMediaSelected(media);
}
@Override
public void onMediaSelectionStarted() {
viewModel.onMultiSelectStarted();
}
@Override
public void onMediaSelectionChanged(@NonNull List<Media> selected) {
adapter.notifyDataSetChanged();
viewModel.onSelectedMediaChanged(requireContext(), selected);
}
@Override
public void onMediaSelectionOverflow(int maxSelection) {
Toast.makeText(requireContext(), getResources().getQuantityString(R.plurals.MediaSendActivity_cant_share_more_than_n_items, maxSelection, maxSelection), Toast.LENGTH_SHORT).show();
}
private void initToolbar(Toolbar toolbar) {
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(folderTitle);
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
}
private void onScreenWidthChanged(int newWidth) {
if (layoutManager != null) {
layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_item_width));
}
}
private int getScreenWidth() {
Point size = new Point();
requireActivity().getWindowManager().getDefaultDisplay().getSize(size);
return size.x;
}
public interface Controller {
void onMediaSelected(@NonNull Media media);
void onCameraSelected();
}
}

View File

@@ -0,0 +1,386 @@
package org.thoughtcrime.securesms.mediasend;
import android.Manifest;
import android.annotation.TargetApi;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Environment;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Video;
import android.provider.OpenableColumns;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import android.util.Pair;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
/**
* Handles the retrieval of media present on the user's device.
*/
class MediaRepository {
/**
* Retrieves a list of folders that contain media.
*/
void getFolders(@NonNull Context context, @NonNull Callback<List<MediaFolder>> callback) {
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getFolders(context)));
}
/**
* Retrieves a list of media items (images and videos) that are present int he specified bucket.
*/
void getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Callback<List<Media>> callback) {
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getMediaInBucket(context, bucketId)));
}
/**
* Given an existing list of {@link Media}, this will ensure that the media is populate with as
* much data as we have, like width/height.
*/
void getPopulatedMedia(@NonNull Context context, @NonNull List<Media> media, @NonNull Callback<List<Media>> callback) {
if (Stream.of(media).allMatch(this::isPopulated)) {
callback.onComplete(media);
return;
}
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getPopulatedMedia(context, media)));
}
void getMostRecentItem(@NonNull Context context, @NonNull Callback<Optional<Media>> callback) {
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getMostRecentItem(context)));
}
@WorkerThread
private @NonNull List<MediaFolder> getFolders(@NonNull Context context) {
if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {
return Collections.emptyList();
}
FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI);
FolderResult videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI);
Map<String, FolderData> folders = new HashMap<>(imageFolders.getFolderData());
for (Map.Entry<String, FolderData> entry : videoFolders.getFolderData().entrySet()) {
if (folders.containsKey(entry.getKey())) {
folders.get(entry.getKey()).incrementCount(entry.getValue().getCount());
} else {
folders.put(entry.getKey(), entry.getValue());
}
}
String cameraBucketId = imageFolders.getCameraBucketId() != null ? imageFolders.getCameraBucketId() : videoFolders.getCameraBucketId();
FolderData cameraFolder = cameraBucketId != null ? folders.remove(cameraBucketId) : null;
List<MediaFolder> mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(),
folder.getTitle(),
folder.getCount(),
folder.getBucketId(),
MediaFolder.FolderType.NORMAL))
.filter(folder -> folder.getTitle() != null)
.sorted((o1, o2) -> o1.getTitle().toLowerCase().compareTo(o2.getTitle().toLowerCase()))
.toList();
Uri allMediaThumbnail = imageFolders.getThumbnailTimestamp() > videoFolders.getThumbnailTimestamp() ? imageFolders.getThumbnail() : videoFolders.getThumbnail();
if (allMediaThumbnail != null) {
int allMediaCount = Stream.of(mediaFolders).reduce(0, (count, folder) -> count + folder.getItemCount());
if (cameraFolder != null) {
allMediaCount += cameraFolder.getCount();
}
mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.MediaRepository_all_media), allMediaCount, Media.ALL_MEDIA_BUCKET_ID, MediaFolder.FolderType.NORMAL));
}
if (cameraFolder != null) {
mediaFolders.add(0, new MediaFolder(cameraFolder.getThumbnail(), cameraFolder.getTitle(), cameraFolder.getCount(), cameraFolder.getBucketId(), MediaFolder.FolderType.CAMERA));
}
return mediaFolders;
}
@WorkerThread
private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) {
String cameraPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() + File.separator + "Camera";
String cameraBucketId = null;
Uri globalThumbnail = null;
long thumbnailTimestamp = 0;
Map<String, FolderData> folders = new HashMap<>();
String[] projection = new String[] { Images.Media.DATA, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_TAKEN };
String selection = Images.Media.DATA + " NOT NULL";
String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_TAKEN + " DESC";
try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) {
while (cursor != null && cursor.moveToNext()) {
String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0]));
Uri thumbnail = Uri.fromFile(new File(path));
String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1]));
String title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2]));
long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]));
FolderData folder = Util.getOrDefault(folders, bucketId, new FolderData(thumbnail, title, bucketId));
folder.incrementCount();
folders.put(bucketId, folder);
if (cameraBucketId == null && path.startsWith(cameraPath)) {
cameraBucketId = bucketId;
}
if (timestamp > thumbnailTimestamp) {
globalThumbnail = thumbnail;
thumbnailTimestamp = timestamp;
}
}
}
return new FolderResult(cameraBucketId, globalThumbnail, thumbnailTimestamp, folders);
}
@WorkerThread
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId) {
if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {
return Collections.emptyList();
}
List<Media> images = getMediaInBucket(context, bucketId, Images.Media.EXTERNAL_CONTENT_URI, true);
List<Media> videos = getMediaInBucket(context, bucketId, Video.Media.EXTERNAL_CONTENT_URI, false);
List<Media> media = new ArrayList<>(images.size() + videos.size());
media.addAll(images);
media.addAll(videos);
Collections.sort(media, (o1, o2) -> Long.compare(o2.getDate(), o1.getDate()));
return media;
}
@WorkerThread
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean hasOrientation) {
List<Media> media = new LinkedList<>();
String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL";
String[] selectionArgs = new String[] { bucketId };
String sortBy = Images.Media.DATE_TAKEN + " DESC";
String[] projection;
if (hasOrientation) {
projection = new String[]{Images.Media.DATA, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE};
} else {
projection = new String[]{Images.Media.DATA, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE};
}
if (Media.ALL_MEDIA_BUCKET_ID.equals(bucketId)) {
selection = Images.Media.DATA + " NOT NULL";
selectionArgs = null;
}
try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, selectionArgs, sortBy)) {
while (cursor != null && cursor.moveToNext()) {
String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0]));
Uri uri = Uri.fromFile(new File(path));
String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE));
long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_TAKEN));
int orientation = hasOrientation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0;
int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)));
int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)));
long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE));
media.add(new Media(uri, mimetype, dateTaken, width, height, size, Optional.of(bucketId), Optional.absent()));
}
}
return media;
}
@WorkerThread
private List<Media> getPopulatedMedia(@NonNull Context context, @NonNull List<Media> media) {
if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {
return media;
}
return Stream.of(media).map(m -> {
try {
if (isPopulated(m)) {
return m;
} else if (PartAuthority.isLocalUri(m.getUri())) {
return getLocallyPopulatedMedia(context, m);
} else {
return getContentResolverPopulatedMedia(context, m);
}
} catch (IOException e) {
return m;
}
}).toList();
}
@WorkerThread
private Optional<Media> getMostRecentItem(@NonNull Context context) {
if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {
return Optional.absent();
}
List<Media> media = getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, Images.Media.EXTERNAL_CONTENT_URI, true);
return media.size() > 0 ? Optional.of(media.get(0)) : Optional.absent();
}
@TargetApi(16)
@SuppressWarnings("SuspiciousNameCombination")
private String getWidthColumn(int orientation) {
if (orientation == 0 || orientation == 180) return Images.Media.WIDTH;
else return Images.Media.HEIGHT;
}
@TargetApi(16)
@SuppressWarnings("SuspiciousNameCombination")
private String getHeightColumn(int orientation) {
if (orientation == 0 || orientation == 180) return Images.Media.HEIGHT;
else return Images.Media.WIDTH;
}
private boolean isPopulated(@NonNull Media media) {
return media.getWidth() > 0 && media.getHeight() > 0 && media.getSize() > 0;
}
private Media getLocallyPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException {
int width = media.getWidth();
int height = media.getHeight();
long size = media.getSize();
if (size <= 0) {
Optional<Long> optionalSize = Optional.fromNullable(PartAuthority.getAttachmentSize(context, media.getUri()));
size = optionalSize.isPresent() ? optionalSize.get() : 0;
}
if (size <= 0) {
size = MediaUtil.getMediaSize(context, media.getUri());
}
if (width == 0 || height == 0) {
Pair<Integer, Integer> dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri());
width = dimens.first;
height = dimens.second;
}
return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption());
}
private Media getContentResolverPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException {
int width = media.getWidth();
int height = media.getHeight();
long size = media.getSize();
if (size <= 0) {
try (Cursor cursor = context.getContentResolver().query(media.getUri(), null, null, null, null)) {
if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) {
size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
}
}
}
if (size <= 0) {
size = MediaUtil.getMediaSize(context, media.getUri());
}
if (width == 0 || height == 0) {
Pair<Integer, Integer> dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri());
width = dimens.first;
height = dimens.second;
}
return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption());
}
private static class FolderResult {
private final String cameraBucketId;
private final Uri thumbnail;
private final long thumbnailTimestamp;
private final Map<String, FolderData> folderData;
private FolderResult(@Nullable String cameraBucketId,
@Nullable Uri thumbnail,
long thumbnailTimestamp,
@NonNull Map<String, FolderData> folderData)
{
this.cameraBucketId = cameraBucketId;
this.thumbnail = thumbnail;
this.thumbnailTimestamp = thumbnailTimestamp;
this.folderData = folderData;
}
@Nullable String getCameraBucketId() {
return cameraBucketId;
}
@Nullable Uri getThumbnail() {
return thumbnail;
}
long getThumbnailTimestamp() {
return thumbnailTimestamp;
}
@NonNull Map<String, FolderData> getFolderData() {
return folderData;
}
}
private static class FolderData {
private final Uri thumbnail;
private final String title;
private final String bucketId;
private int count;
private FolderData(Uri thumbnail, String title, String bucketId) {
this.thumbnail = thumbnail;
this.title = title;
this.bucketId = bucketId;
}
Uri getThumbnail() {
return thumbnail;
}
String getTitle() {
return title;
}
String getBucketId() {
return bucketId;
}
int getCount() {
return count;
}
void incrementCount() {
incrementCount(1);
}
void incrementCount(int amount) {
count += amount;
}
}
interface Callback<E> {
void onComplete(@NonNull E result);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
package org.thoughtcrime.securesms.mediasend;
import androidx.lifecycle.ViewModelProviders;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.viewpager.widget.ViewPager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ControllableViewPager;
import org.thoughtcrime.securesms.util.Util;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Allows the user to edit and caption a set of media items before choosing to send them.
*/
public class MediaSendFragment extends Fragment {
private static final String TAG = MediaSendFragment.class.getSimpleName();
private static final String KEY_LOCALE = "locale";
private ViewGroup playbackControlsContainer;
private ControllableViewPager fragmentPager;
private MediaSendFragmentPagerAdapter fragmentPagerAdapter;
private MediaSendViewModel viewModel;
public static MediaSendFragment newInstance(@NonNull Locale locale) {
Bundle args = new Bundle();
args.putSerializable(KEY_LOCALE, locale);
MediaSendFragment fragment = new MediaSendFragment();
fragment.setArguments(args);
return fragment;
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.mediasend_fragment, container, false);
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
initViewModel();
fragmentPager = view.findViewById(R.id.mediasend_pager);
playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container);
fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager());
fragmentPager.setAdapter(fragmentPagerAdapter);
FragmentPageChangeListener pageChangeListener = new FragmentPageChangeListener();
fragmentPager.addOnPageChangeListener(pageChangeListener);
fragmentPager.post(() -> pageChangeListener.onPageSelected(fragmentPager.getCurrentItem()));
}
@Override
public void onStart() {
super.onStart();
fragmentPagerAdapter.restoreState(viewModel.getDrawState());
viewModel.onImageEditorStarted();
}
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
if (!hidden) {
viewModel.onImageEditorStarted();
} else {
fragmentPagerAdapter.notifyHidden();
}
}
@Override
public void onPause() {
super.onPause();
fragmentPagerAdapter.notifyHidden();
}
@Override
public void onStop() {
super.onStop();
fragmentPagerAdapter.saveAllState();
viewModel.saveDrawState(fragmentPagerAdapter.getSavedState());
}
public void onTouchEventsNeeded(boolean needed) {
if (fragmentPager != null) {
fragmentPager.setEnabled(!needed);
}
}
public List<Media> getAllMedia() {
return fragmentPagerAdapter.getAllMedia();
}
public @NonNull Map<Uri, Object> getSavedState() {
return fragmentPagerAdapter.getSavedState();
}
public int getCurrentImagePosition() {
return fragmentPager.getCurrentItem();
}
private void initViewModel() {
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
viewModel.getSelectedMedia().observe(this, media -> {
if (Util.isEmpty(media)) {
return;
}
fragmentPagerAdapter.setMedia(media);
});
viewModel.getPosition().observe(this, position -> {
if (position == null || position < 0) return;
fragmentPager.setCurrentItem(position, true);
View playbackControls = fragmentPagerAdapter.getPlaybackControls(position);
if (playbackControls != null) {
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
playbackControls.setLayoutParams(params);
playbackControlsContainer.removeAllViews();
playbackControlsContainer.addView(playbackControls);
} else {
playbackControlsContainer.removeAllViews();
}
});
}
private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
@Override
public void onPageSelected(int position) {
viewModel.onPageChanged(position);
fragmentPagerAdapter.notifyPageChanged(position);
}
}
}

View File

@@ -0,0 +1,141 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapter;
import android.view.View;
import android.view.ViewGroup;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter {
private final List<Media> media;
private final Map<Integer, MediaSendPageFragment> fragments;
private final Map<Uri, Object> savedState;
MediaSendFragmentPagerAdapter(@NonNull FragmentManager fm) {
super(fm);
this.media = new ArrayList<>();
this.fragments = new HashMap<>();
this.savedState = new HashMap<>();
}
@Override
public Fragment getItem(int i) {
Media mediaItem = media.get(i);
if (MediaUtil.isGif(mediaItem.getMimeType())) {
return MediaSendGifFragment.newInstance(mediaItem.getUri());
} else if (MediaUtil.isImageType(mediaItem.getMimeType())) {
return ImageEditorFragment.newInstance(mediaItem.getUri());
} else if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
return MediaSendVideoFragment.newInstance(mediaItem.getUri());
} else {
throw new UnsupportedOperationException("Can only render images and videos. Found mimetype: '" + mediaItem.getMimeType() + "'");
}
}
@Override
public int getItemPosition(@NonNull Object object) {
return POSITION_NONE;
}
@NonNull
@Override
public Object instantiateItem(@NonNull ViewGroup container, int position) {
MediaSendPageFragment fragment = (MediaSendPageFragment) super.instantiateItem(container, position);
fragments.put(position, fragment);
Object state = savedState.get(fragment.getUri());
if (state != null) {
fragment.restoreState(state);
}
return fragment;
}
@Override
public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
MediaSendPageFragment fragment = (MediaSendPageFragment) object;
Object state = fragment.saveState();
if (state != null) {
savedState.put(fragment.getUri(), state);
}
super.destroyItem(container, position, object);
fragments.remove(position);
}
@Override
public int getCount() {
return media.size();
}
List<Media> getAllMedia() {
return media;
}
void setMedia(@NonNull List<Media> media) {
this.media.clear();
this.media.addAll(media);
notifyDataSetChanged();
}
Map<Uri, Object> getSavedState() {
for (MediaSendPageFragment fragment : fragments.values()) {
Object state = fragment.saveState();
if (state != null) {
savedState.put(fragment.getUri(), state);
}
}
return new HashMap<>(savedState);
}
void saveAllState() {
for (MediaSendPageFragment fragment : fragments.values()) {
Object state = fragment.saveState();
if (state != null) {
savedState.put(fragment.getUri(), state);
}
}
}
void restoreState(@NonNull Map<Uri, Object> state) {
savedState.clear();
savedState.putAll(state);
}
@Nullable View getPlaybackControls(int position) {
return fragments.containsKey(position) ? fragments.get(position).getPlaybackControls() : null;
}
void notifyHidden() {
Stream.of(fragments.values()).forEach(MediaSendPageFragment::notifyHidden);
}
void notifyPageChanged(int currentPage) {
notifyHiddenIfExists(currentPage - 1);
notifyHiddenIfExists(currentPage + 1);
}
private void notifyHiddenIfExists(int position) {
MediaSendPageFragment fragment = fragments.get(position);
if (fragment != null) {
fragment.notifyHidden();
}
}
}

View File

@@ -0,0 +1,72 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
import org.thoughtcrime.securesms.mms.GlideApp;
public class MediaSendGifFragment extends Fragment implements MediaSendPageFragment {
private static final String KEY_URI = "uri";
private Uri uri;
public static MediaSendGifFragment newInstance(@NonNull Uri uri) {
Bundle args = new Bundle();
args.putParcelable(KEY_URI, uri);
MediaSendGifFragment fragment = new MediaSendGifFragment();
fragment.setArguments(args);
fragment.setUri(uri);
return fragment;
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.mediasend_image_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
uri = getArguments().getParcelable(KEY_URI);
GlideApp.with(this).load(new DecryptableStreamUriLoader.DecryptableUri(uri)).into((ImageView) view);
}
@Override
public void setUri(@NonNull Uri uri) {
this.uri = uri;
}
@Override
public @NonNull Uri getUri() {
return uri;
}
@Override
public @Nullable View getPlaybackControls() {
return null;
}
@Override
public @Nullable Object saveState() {
return null;
}
@Override
public void restoreState(@NonNull Object state) { }
@Override
public void notifyHidden() {
}
}

View File

@@ -0,0 +1,24 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.View;
/**
* A page that sits in the {@link MediaSendFragmentPagerAdapter}.
*/
public interface MediaSendPageFragment {
@NonNull Uri getUri();
void setUri(@NonNull Uri uri);
@Nullable View getPlaybackControls();
@Nullable Object saveState();
void restoreState(@NonNull Object state);
void notifyHidden();
}

View File

@@ -0,0 +1,105 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.video.VideoPlayer;
import java.io.IOException;
public class MediaSendVideoFragment extends Fragment implements MediaSendPageFragment {
private static final String TAG = MediaSendVideoFragment.class.getSimpleName();
private static final String KEY_URI = "uri";
private Uri uri;
public static MediaSendVideoFragment newInstance(@NonNull Uri uri) {
Bundle args = new Bundle();
args.putParcelable(KEY_URI, uri);
MediaSendVideoFragment fragment = new MediaSendVideoFragment();
fragment.setArguments(args);
fragment.setUri(uri);
return fragment;
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.mediasend_video_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
uri = getArguments().getParcelable(KEY_URI);
VideoSlide slide = new VideoSlide(requireContext(), uri, 0);
((VideoPlayer) view).setWindow(requireActivity().getWindow());
((VideoPlayer) view).setVideoSource(slide, true);
}
@Override
public void onDestroyView() {
super.onDestroyView();
if (getView() != null) {
((VideoPlayer) getView()).cleanup();
}
}
@Override
public void onPause() {
super.onPause();
notifyHidden();
}
@Override
public void onHiddenChanged(boolean hidden) {
if (hidden) {
notifyHidden();
}
}
@Override
public void setUri(@NonNull Uri uri) {
this.uri = uri;
}
@Override
public @NonNull Uri getUri() {
return uri;
}
@Override
public @Nullable View getPlaybackControls() {
VideoPlayer player = (VideoPlayer) getView();
return player != null ? player.getControlView() : null;
}
@Override
public @Nullable Object saveState() {
return null;
}
@Override
public void restoreState(@NonNull Object state) { }
@Override
public void notifyHidden() {
if (getView() != null) {
((VideoPlayer) getView()).pause();
}
}
}

View File

@@ -0,0 +1,620 @@
package org.thoughtcrime.securesms.mediasend;
import android.app.Application;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
/**
* Manages the observable datasets available in {@link MediaSendActivity}.
*/
class MediaSendViewModel extends ViewModel {
private static final String TAG = MediaSendViewModel.class.getSimpleName();
private static final int MAX_PUSH = 32;
private static final int MAX_SMS = 1;
private final Application application;
private final MediaRepository repository;
private final MutableLiveData<List<Media>> selectedMedia;
private final MutableLiveData<List<Media>> bucketMedia;
private final MutableLiveData<Optional<Media>> mostRecentMedia;
private final MutableLiveData<Integer> position;
private final MutableLiveData<String> bucketId;
private final MutableLiveData<List<MediaFolder>> folders;
private final MutableLiveData<HudState> hudState;
private final SingleLiveEvent<Error> error;
private final SingleLiveEvent<Event> event;
private final Map<Uri, Object> savedDrawState;
private MediaConstraints mediaConstraints;
private CharSequence body;
private boolean sentMedia;
private int maxSelection;
private Page page;
private boolean isSms;
private Optional<Media> lastCameraCapture;
private boolean hudVisible;
private boolean composeVisible;
private boolean captionVisible;
private ButtonState buttonState;
private RailState railState;
private ViewOnceState viewOnceState;
private @Nullable Recipient recipient;
private MediaSendViewModel(@NonNull Application application, @NonNull MediaRepository repository) {
this.application = application;
this.repository = repository;
this.selectedMedia = new MutableLiveData<>();
this.bucketMedia = new MutableLiveData<>();
this.mostRecentMedia = new MutableLiveData<>();
this.position = new MutableLiveData<>();
this.bucketId = new MutableLiveData<>();
this.folders = new MutableLiveData<>();
this.hudState = new MutableLiveData<>();
this.error = new SingleLiveEvent<>();
this.event = new SingleLiveEvent<>();
this.savedDrawState = new HashMap<>();
this.lastCameraCapture = Optional.absent();
this.body = "";
this.buttonState = ButtonState.GONE;
this.railState = RailState.GONE;
this.viewOnceState = ViewOnceState.GONE;
this.page = Page.UNKNOWN;
position.setValue(-1);
}
void setTransport(@NonNull TransportOption transport) {
if (transport.isSms()) {
isSms = true;
maxSelection = MAX_SMS;
mediaConstraints = MediaConstraints.getMmsMediaConstraints(transport.getSimSubscriptionId().or(-1));
} else {
isSms = false;
maxSelection = MAX_PUSH;
mediaConstraints = MediaConstraints.getPushMediaConstraints();
}
}
void setRecipient(@Nullable Recipient recipient) {
this.recipient = recipient;
}
void onSelectedMediaChanged(@NonNull Context context, @NonNull List<Media> newMedia) {
if (!newMedia.isEmpty()) {
selectedMedia.setValue(newMedia);
}
repository.getPopulatedMedia(context, newMedia, populatedMedia -> {
Util.runOnMain(() -> {
List<Media> filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints);
if (filteredMedia.size() != newMedia.size()) {
error.setValue(Error.ITEM_TOO_LARGE);
} else if (filteredMedia.size() > maxSelection) {
filteredMedia = filteredMedia.subList(0, maxSelection);
error.setValue(Error.TOO_MANY_ITEMS);
}
if (filteredMedia.size() > 0) {
String computedId = Stream.of(filteredMedia)
.skip(1)
.reduce(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID), (id, m) -> {
if (Util.equals(id, m.getBucketId().or(Media.ALL_MEDIA_BUCKET_ID))) {
return id;
} else {
return Media.ALL_MEDIA_BUCKET_ID;
}
});
bucketId.setValue(computedId);
} else {
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
}
if (page == Page.EDITOR && filteredMedia.isEmpty()) {
error.postValue(Error.NO_ITEMS);
} else if (filteredMedia.isEmpty()) {
hudVisible = false;
selectedMedia.setValue(filteredMedia);
hudState.setValue(buildHudState());
} else {
hudVisible = true;
selectedMedia.setValue(filteredMedia);
hudState.setValue(buildHudState());
}
});
});
}
void onSingleMediaSelected(@NonNull Context context, @NonNull Media media) {
selectedMedia.setValue(Collections.singletonList(media));
repository.getPopulatedMedia(context, Collections.singletonList(media), populatedMedia -> {
Util.runOnMain(() -> {
List<Media> filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints);
if (filteredMedia.isEmpty()) {
error.setValue(Error.ITEM_TOO_LARGE);
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
} else {
bucketId.setValue(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID));
}
selectedMedia.setValue(filteredMedia);
});
});
}
void onMultiSelectStarted() {
hudVisible = true;
composeVisible = false;
captionVisible = false;
buttonState = ButtonState.COUNT;
railState = RailState.VIEWABLE;
viewOnceState = ViewOnceState.GONE;
hudState.setValue(buildHudState());
}
void onImageEditorStarted() {
page = Page.EDITOR;
hudVisible = true;
captionVisible = getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent());
buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
if (viewOnceState == ViewOnceState.GONE && viewOnceSupported()) {
viewOnceState = TextSecurePreferences.isViewOnceMessageEnabled(application) ? ViewOnceState.ENABLED : ViewOnceState.DISABLED;
showViewOnceTooltipIfNecessary(viewOnceState);
} else if (!viewOnceSupported()) {
viewOnceState = ViewOnceState.GONE;
}
railState = !isSms && viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
composeVisible = viewOnceState != ViewOnceState.ENABLED;
hudState.setValue(buildHudState());
}
void onCameraStarted() {
// TODO: Don't need this?
Page previous = page;
page = Page.CAMERA;
hudVisible = false;
viewOnceState = ViewOnceState.GONE;
buttonState = ButtonState.COUNT;
List<Media> selected = getSelectedMediaOrDefault();
if (previous == Page.EDITOR && lastCameraCapture.isPresent() && selected.contains(lastCameraCapture.get()) && selected.size() == 1) {
selected.remove(lastCameraCapture.get());
selectedMedia.setValue(selected);
BlobProvider.getInstance().delete(application, lastCameraCapture.get().getUri());
}
hudState.setValue(buildHudState());
}
void onItemPickerStarted() {
page = Page.ITEM_PICKER;
hudVisible = true;
composeVisible = false;
captionVisible = false;
buttonState = ButtonState.COUNT;
viewOnceState = ViewOnceState.GONE;
railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE;
lastCameraCapture = Optional.absent();
hudState.setValue(buildHudState());
}
void onFolderPickerStarted() {
page = Page.FOLDER_PICKER;
hudVisible = true;
composeVisible = false;
captionVisible = false;
buttonState = ButtonState.COUNT;
viewOnceState = ViewOnceState.GONE;
railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE;
lastCameraCapture = Optional.absent();
hudState.setValue(buildHudState());
}
void onContactSelectStarted() {
hudVisible = false;
hudState.setValue(buildHudState());
}
void onRevealButtonToggled() {
hudVisible = true;
viewOnceState = viewOnceState == ViewOnceState.ENABLED ? ViewOnceState.DISABLED : ViewOnceState.ENABLED;
composeVisible = viewOnceState != ViewOnceState.ENABLED;
railState = viewOnceState == ViewOnceState.ENABLED || isSms ? RailState.GONE : RailState.INTERACTIVE;
captionVisible = false;
List<Media> uncaptioned = Stream.of(getSelectedMediaOrDefault())
.map(m -> new Media(m.getUri(), m.getMimeType(), m.getDate(), m.getWidth(), m.getHeight(), m.getSize(), m.getBucketId(), Optional.absent()))
.toList();
selectedMedia.setValue(uncaptioned);
TextSecurePreferences.setIsViewOnceMessageEnabled(application, viewOnceState == ViewOnceState.ENABLED);
hudState.setValue(buildHudState());
}
void onKeyboardHidden(boolean isSms) {
if (page != Page.EDITOR) return;
composeVisible = (viewOnceState != ViewOnceState.ENABLED);
buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
if (isSms) {
railState = RailState.GONE;
captionVisible = false;
} else {
railState = viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
if (getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent())) {
captionVisible = true;
}
}
hudState.setValue(buildHudState());
}
void onKeyboardShown(boolean isComposeFocused, boolean isCaptionFocused, boolean isSms) {
if (page != Page.EDITOR) return;
if (isSms) {
railState = RailState.GONE;
composeVisible = (viewOnceState == ViewOnceState.GONE);
captionVisible = false;
buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
} else {
if (isCaptionFocused) {
railState = viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
composeVisible = false;
captionVisible = true;
buttonState = ButtonState.GONE;
} else if (isComposeFocused) {
railState = viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
composeVisible = (viewOnceState != ViewOnceState.ENABLED);
captionVisible = false;
buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
}
}
hudState.setValue(buildHudState());
}
void onBodyChanged(@NonNull CharSequence body) {
this.body = body;
}
void onFolderSelected(@NonNull String bucketId) {
this.bucketId.setValue(bucketId);
bucketMedia.setValue(Collections.emptyList());
}
void onPageChanged(int position) {
if (position < 0 || position >= getSelectedMediaOrDefault().size()) {
Log.w(TAG, "Tried to move to an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position);
return;
}
this.position.setValue(position);
}
void onMediaItemRemoved(@NonNull Context context, int position) {
if (position < 0 || position >= getSelectedMediaOrDefault().size()) {
Log.w(TAG, "Tried to remove an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position);
return;
}
Media removed = getSelectedMediaOrDefault().remove(position);
if (removed != null && BlobProvider.isAuthority(removed.getUri())) {
BlobProvider.getInstance().delete(context, removed.getUri());
}
if (page == Page.EDITOR && getSelectedMediaOrDefault().isEmpty()) {
error.setValue(Error.NO_ITEMS);
} else {
selectedMedia.setValue(selectedMedia.getValue());
}
if (getSelectedMediaOrDefault().size() > 0) {
this.position.setValue(Math.min(position, getSelectedMediaOrDefault().size() - 1));
}
if (getSelectedMediaOrDefault().size() == 1) {
viewOnceState = viewOnceSupported() ? ViewOnceState.DISABLED : ViewOnceState.GONE;
}
hudState.setValue(buildHudState());
}
void onMediaCaptured(@NonNull Media media) {
lastCameraCapture = Optional.of(media);
List<Media> selected = selectedMedia.getValue();
if (selected == null) {
selected = new LinkedList<>();
}
if (selected.size() >= maxSelection) {
error.setValue(Error.TOO_MANY_ITEMS);
return;
}
selected.add(media);
selectedMedia.setValue(selected);
position.setValue(selected.size() - 1);
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
}
void onCaptionChanged(@NonNull String newCaption) {
if (position.getValue() >= 0 && !Util.isEmpty(selectedMedia.getValue())) {
selectedMedia.getValue().get(position.getValue()).setCaption(TextUtils.isEmpty(newCaption) ? null : newCaption);
}
}
void onCameraControlsInitialized() {
repository.getMostRecentItem(application, mostRecentMedia::postValue);
}
void saveDrawState(@NonNull Map<Uri, Object> state) {
savedDrawState.clear();
savedDrawState.putAll(state);
}
void onSendClicked() {
sentMedia = true;
}
@NonNull Map<Uri, Object> getDrawState() {
return savedDrawState;
}
@NonNull LiveData<List<Media>> getSelectedMedia() {
return selectedMedia;
}
@NonNull LiveData<List<Media>> getMediaInBucket(@NonNull Context context, @NonNull String bucketId) {
repository.getMediaInBucket(context, bucketId, bucketMedia::postValue);
return bucketMedia;
}
@NonNull LiveData<List<MediaFolder>> getFolders(@NonNull Context context) {
repository.getFolders(context, folders::postValue);
return folders;
}
@NonNull LiveData<Optional<Media>> getMostRecentMediaItem(@NonNull Context context) {
return mostRecentMedia;
}
@NonNull CharSequence getBody() {
return body;
}
@NonNull LiveData<Integer> getPosition() {
return position;
}
@NonNull LiveData<String> getBucketId() {
return bucketId;
}
@NonNull LiveData<Error> getError() {
return error;
}
@NonNull LiveData<Event> getEvents() {
return event;
}
@NonNull LiveData<HudState> getHudState() {
return hudState;
}
int getMaxSelection() {
return maxSelection;
}
boolean isViewOnce() {
return viewOnceState == ViewOnceState.ENABLED;
}
@NonNull MediaConstraints getMediaConstraints() {
return mediaConstraints;
}
private @NonNull List<Media> getSelectedMediaOrDefault() {
return selectedMedia.getValue() == null ? Collections.emptyList()
: selectedMedia.getValue();
}
private @NonNull List<Media> getFilteredMedia(@NonNull Context context, @NonNull List<Media> media, @NonNull MediaConstraints mediaConstraints) {
return Stream.of(media).filter(m -> MediaUtil.isGif(m.getMimeType()) ||
MediaUtil.isImageType(m.getMimeType()) ||
MediaUtil.isVideoType(m.getMimeType()))
.filter(m -> {
return (MediaUtil.isImageType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType())) ||
(MediaUtil.isGif(m.getMimeType()) && m.getSize() < mediaConstraints.getGifMaxSize(context)) ||
(MediaUtil.isVideoType(m.getMimeType()) && m.getSize() < mediaConstraints.getUncompressedVideoMaxSize(context));
}).toList();
}
private HudState buildHudState() {
List<Media> selectedMedia = getSelectedMediaOrDefault();
int selectionCount = selectedMedia.size();
ButtonState updatedButtonState = buttonState == ButtonState.COUNT && selectionCount == 0 ? ButtonState.GONE : buttonState;
boolean updatedCaptionVisible = captionVisible && (selectedMedia.size() > 1 || (selectedMedia.size() > 0 && selectedMedia.get(0).getCaption().isPresent()));
return new HudState(hudVisible, composeVisible, updatedCaptionVisible, selectionCount, updatedButtonState, railState, viewOnceState);
}
private void clearPersistedMedia() {
Stream.of(getSelectedMediaOrDefault())
.map(Media::getUri)
.filter(BlobProvider::isAuthority)
.forEach(uri -> BlobProvider.getInstance().delete(application.getApplicationContext(), uri));
}
private boolean viewOnceSupported() {
return !isSms && (recipient == null || !recipient.isLocalNumber()) && mediaSupportsRevealableMessage(getSelectedMediaOrDefault());
}
private boolean mediaSupportsRevealableMessage(@NonNull List<Media> media) {
if (media.size() != 1) return false;
return MediaUtil.isImageOrVideoType(media.get(0).getMimeType());
}
private void showViewOnceTooltipIfNecessary(@NonNull ViewOnceState viewOnceState) {
if (viewOnceState == ViewOnceState.DISABLED && !TextSecurePreferences.hasSeenViewOnceTooltip(application)) {
event.postValue(Event.VIEW_ONCE_TOOLTIP);
}
}
@Override
protected void onCleared() {
if (!sentMedia) {
clearPersistedMedia();
}
}
enum Error {
ITEM_TOO_LARGE, TOO_MANY_ITEMS, NO_ITEMS
}
enum Event {
VIEW_ONCE_TOOLTIP
}
enum Page {
CAMERA, ITEM_PICKER, FOLDER_PICKER, EDITOR, CONTACT_SELECT, UNKNOWN
}
enum ButtonState {
COUNT, SEND, CONTINUE, GONE
}
enum RailState {
INTERACTIVE, VIEWABLE, GONE
}
enum ViewOnceState {
ENABLED, DISABLED, GONE
}
static class HudState {
private final boolean hudVisible;
private final boolean composeVisible;
private final boolean captionVisible;
private final int selectionCount;
private final ButtonState buttonState;
private final RailState railState;
private final ViewOnceState viewOnceState;
HudState(boolean hudVisible,
boolean composeVisible,
boolean captionVisible,
int selectionCount,
@NonNull ButtonState buttonState,
@NonNull RailState railState,
@NonNull ViewOnceState viewOnceState)
{
this.hudVisible = hudVisible;
this.composeVisible = composeVisible;
this.captionVisible = captionVisible;
this.selectionCount = selectionCount;
this.buttonState = buttonState;
this.railState = railState;
this.viewOnceState = viewOnceState;
}
public boolean isHudVisible() {
return hudVisible;
}
public boolean isComposeVisible() {
return hudVisible && composeVisible;
}
public boolean isCaptionVisible() {
return hudVisible && captionVisible;
}
public int getSelectionCount() {
return selectionCount;
}
public @NonNull ButtonState getButtonState() {
return buttonState;
}
public @NonNull RailState getRailState() {
return hudVisible ? railState : RailState.GONE;
}
public @NonNull ViewOnceState getViewOnceState() {
return hudVisible ? viewOnceState : ViewOnceState.GONE;
}
}
static class Factory extends ViewModelProvider.NewInstanceFactory {
private final Application application;
private final MediaRepository repository;
Factory(@NonNull Application application, @NonNull MediaRepository repository) {
this.application = application;
this.repository = repository;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return modelClass.cast(new MediaSendViewModel(application, repository));
}
}
}

View File

@@ -0,0 +1,91 @@
package org.thoughtcrime.securesms.mediasend;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Stack;
@SuppressWarnings("ConstantConditions")
public class OrderEnforcer<E> {
private final Map<E, StageDetails> stages = new LinkedHashMap<>();
public OrderEnforcer(@NonNull E... stages) {
for (E stage : stages) {
this.stages.put(stage, new StageDetails());
}
}
public synchronized void run(@NonNull E stage, Runnable r) {
if (isCompletedThrough(stage)) {
r.run();
} else {
stages.get(stage).addAction(r);
}
}
public synchronized void markCompleted(@NonNull E stage) {
stages.get(stage).markCompleted();
for (E s : stages.keySet()) {
StageDetails details = stages.get(s);
if (details.isCompleted()) {
while (details.hasAction()) {
details.popAction().run();
}
} else {
break;
}
}
}
public synchronized void reset() {
for (StageDetails details : stages.values()) {
details.reset();
}
}
private boolean isCompletedThrough(@NonNull E stage) {
for (E s : stages.keySet()) {
if (s.equals(stage)) {
return stages.get(s).isCompleted();
} else if (!stages.get(s).isCompleted()) {
return false;
}
}
return false;
}
private static class StageDetails {
private boolean completed = false;
private Stack<Runnable> actions = new Stack<>();
boolean hasAction() {
return !actions.isEmpty();
}
@Nullable Runnable popAction() {
return actions.pop();
}
void addAction(@NonNull Runnable runnable) {
actions.push(runnable);
}
void reset() {
actions.clear();
completed = false;
}
boolean isCompleted() {
return completed;
}
void markCompleted() {
completed = true;
}
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.mediasend;
import android.view.animation.Animation;
/**
* Basic implementation of {@link android.view.animation.Animation.AnimationListener} with empty
* implementation so you don't have to override every method.
*/
public class SimpleAnimationListener implements Animation.AnimationListener {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
}
@Override
public void onAnimationRepeat(Animation animation) {
}
}

View File

@@ -0,0 +1,131 @@
package org.thoughtcrime.securesms.mediasend.camerax;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatImageView;
import androidx.camera.core.FlashMode;
import org.thoughtcrime.securesms.R;
import java.util.Arrays;
import java.util.List;
public final class CameraXFlashToggleView extends AppCompatImageView {
private static final String STATE_FLASH_INDEX = "flash.toggle.state.flash.index";
private static final String STATE_SUPPORT_AUTO = "flash.toggle.state.support.auto";
private static final String STATE_PARENT = "flash.toggle.state.parent";
private static final int[] FLASH_AUTO = { R.attr.state_flash_auto };
private static final int[] FLASH_OFF = { R.attr.state_flash_off };
private static final int[] FLASH_ON = { R.attr.state_flash_on };
private static final int[][] FLASH_ENUM = { FLASH_AUTO, FLASH_OFF, FLASH_ON };
private static final List<FlashMode> FLASH_MODES = Arrays.asList(FlashMode.AUTO, FlashMode.OFF, FlashMode.ON);
private static final FlashMode FLASH_FALLBACK = FlashMode.OFF;
private boolean supportsFlashModeAuto = true;
private int flashIndex;
private OnFlashModeChangedListener flashModeChangedListener;
public CameraXFlashToggleView(Context context) {
this(context, null);
}
public CameraXFlashToggleView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public CameraXFlashToggleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
super.setOnClickListener((v) -> setFlash(FLASH_MODES.get((flashIndex + 1) % FLASH_ENUM.length)));
}
@Override
public int[] onCreateDrawableState(int extraSpace) {
final int[] extra = FLASH_ENUM[flashIndex];
final int[] drawableState = super.onCreateDrawableState(extraSpace + extra.length);
mergeDrawableStates(drawableState, extra);
return drawableState;
}
@Override
public void setOnClickListener(@Nullable OnClickListener l) {
throw new IllegalStateException("This View does not support custom click listeners.");
}
public void setAutoFlashEnabled(boolean isAutoEnabled) {
supportsFlashModeAuto = isAutoEnabled;
setFlash(FLASH_MODES.get(flashIndex));
}
public void setFlash(@NonNull FlashMode flashMode) {
flashIndex = resolveFlashIndex(FLASH_MODES.indexOf(flashMode), supportsFlashModeAuto);
refreshDrawableState();
notifyListener();
}
public void setOnFlashModeChangedListener(@Nullable OnFlashModeChangedListener listener) {
this.flashModeChangedListener = listener;
}
@Override
protected Parcelable onSaveInstanceState() {
Parcelable parentState = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putParcelable(STATE_PARENT, parentState);
bundle.putInt(STATE_FLASH_INDEX, flashIndex);
bundle.putBoolean(STATE_SUPPORT_AUTO, supportsFlashModeAuto);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle savedState = (Bundle) state;
supportsFlashModeAuto = savedState.getBoolean(STATE_SUPPORT_AUTO);
setFlash(FLASH_MODES.get(
resolveFlashIndex(savedState.getInt(STATE_FLASH_INDEX), supportsFlashModeAuto))
);
super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT));
} else {
super.onRestoreInstanceState(state);
}
}
private void notifyListener() {
if (flashModeChangedListener == null) return;
flashModeChangedListener.flashModeChanged(FLASH_MODES.get(flashIndex));
}
private static int resolveFlashIndex(int desiredFlashIndex, boolean supportsFlashModeAuto) {
if (isIllegalFlashIndex(desiredFlashIndex)) {
throw new IllegalArgumentException("Unsupported index: " + desiredFlashIndex);
}
if (isUnsupportedFlashMode(desiredFlashIndex, supportsFlashModeAuto)) {
return FLASH_MODES.indexOf(FLASH_FALLBACK);
}
return desiredFlashIndex;
}
private static boolean isIllegalFlashIndex(int desiredFlashIndex) {
return desiredFlashIndex < 0 || desiredFlashIndex > FLASH_ENUM.length;
}
private static boolean isUnsupportedFlashMode(int desiredFlashIndex, boolean supportsFlashModeAuto) {
return FLASH_MODES.get(desiredFlashIndex) == FlashMode.AUTO && !supportsFlashModeAuto;
}
public interface OnFlashModeChangedListener {
void flashModeChanged(FlashMode flashMode);
}
}

View File

@@ -0,0 +1,797 @@
/*
* 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.
*/
package org.thoughtcrime.securesms.mediasend.camerax;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Resources;
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.Build;
import android.os.Looper;
import android.util.Log;
import android.util.Rational;
import android.util.Size;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RequiresPermission;
import androidx.annotation.UiThread;
import androidx.camera.core.AspectRatio;
import androidx.camera.core.CameraInfo;
import androidx.camera.core.CameraInfoUnavailableException;
import androidx.camera.core.CameraOrientationUtil;
import androidx.camera.core.CameraX;
import androidx.camera.core.FlashMode;
import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageCaptureConfig;
import androidx.camera.core.Preview;
import androidx.camera.core.PreviewConfig;
import androidx.camera.core.VideoCaptureConfig;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.OnLifecycleEvent;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.video.VideoUtil;
import java.io.File;
import java.io.FileDescriptor;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
/** CameraX use case operation built on @{link androidx.camera.core}. */
@RequiresApi(21)
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 mCameraView;
final AtomicBoolean mVideoIsRecording = new AtomicBoolean(false);
private CameraXView.CaptureMode mCaptureMode = CameraXView.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 = CameraX.LensFacing.BACK;
CameraXModule(CameraXView view) {
this.mCameraView = view;
mCameraManager = (CameraManager) view.getContext().getSystemService(Context.CAMERA_SERVICE);
mPreviewConfigBuilder = new PreviewConfig.Builder().setTargetName("Preview");
mImageCaptureConfigBuilder =
new ImageCaptureConfig.Builder().setTargetName("ImageCapture");
// Begin Signal Custom Code Block
mVideoCaptureConfigBuilder =
new VideoCaptureConfig.Builder().setTargetName("VideoCapture")
.setAudioBitRate(VideoUtil.AUDIO_BIT_RATE)
.setVideoFrameRate(VideoUtil.VIDEO_FRAME_RATE)
.setBitRate(VideoUtil.VIDEO_BIT_RATE);
// End Signal Custom Code Block
}
/**
* 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 {
Set<CameraX.LensFacing> 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;
}
CameraInfo cameraInfo = CameraX.getCameraInfo(getLensFacing());
cameraOrientation = cameraInfo.getSensorRotationDegrees();
} catch (CameraInfoUnavailableException e) {
throw new IllegalStateException("Unable to get Camera Info.", e);
} 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;
// Begin Signal Custom Code Block
Rational targetAspectRatio;
int resolution = CameraXUtil.getIdealResolution(Resources.getSystem().getDisplayMetrics().widthPixels, Resources.getSystem().getDisplayMetrics().heightPixels);
Log.i(TAG, "Ideal resolution: " + resolution);
if (getCaptureMode() == CameraXView.CaptureMode.IMAGE) {
mImageCaptureConfigBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_4_3, isDisplayPortrait));
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3;
} else {
mImageCaptureConfigBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
}
mImageCaptureConfigBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode());
mImageCaptureConfigBuilder.setLensFacing(mCameraLensFacing);
// End Signal Custom Code Block
mImageCaptureConfigBuilder.setTargetRotation(getDisplaySurfaceRotation());
mImageCapture = new ImageCapture(mImageCaptureConfigBuilder.build());
// Begin Signal Custom Code Block
Size size = VideoUtil.getVideoRecordingSize();
mVideoCaptureConfigBuilder.setTargetResolution(size);
mVideoCaptureConfigBuilder.setMaxResolution(size);
// End Signal Custom Code Block
mVideoCaptureConfigBuilder.setTargetRotation(getDisplaySurfaceRotation());
mVideoCaptureConfigBuilder.setLensFacing(mCameraLensFacing);
// Begin Signal Custom Code Block
if (MediaConstraints.isVideoTranscodeAvailable()) {
mVideoCapture = new VideoCapture(mVideoCaptureConfigBuilder.build());
}
mPreviewConfigBuilder.setLensFacing(mCameraLensFacing);
// Adjusts the preview resolution according to the view size and the target aspect ratio.
int height = (int) (getMeasuredWidth() / targetAspectRatio.floatValue());
mPreviewConfigBuilder.setTargetResolution(new Size(getMeasuredWidth(), height));
mPreview = new Preview(mPreviewConfigBuilder.build());
mPreview.setOnPreviewOutputUpdateListener(
new Preview.OnPreviewOutputUpdateListener() {
@Override
public void onUpdated(@NonNull 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() == CameraXView.CaptureMode.IMAGE) {
CameraX.bindToLifecycle(mCurrentLifecycle, mImageCapture, mPreview);
} else if (getCaptureMode() == CameraXView.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 takePicture(Executor executor, ImageCapture.OnImageCapturedListener listener) {
if (mImageCapture == null) {
return;
}
if (getCaptureMode() == CameraXView.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(executor, listener);
}
public void takePicture(File saveLocation, Executor executor, ImageCapture.OnImageSavedListener listener) {
if (mImageCapture == null) {
return;
}
if (getCaptureMode() == CameraXView.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 == CameraX.LensFacing.FRONT;
mImageCapture.takePicture(saveLocation, metadata, executor, listener);
}
// Begin Signal Custom Code Block
@RequiresApi(26)
public void startRecording(FileDescriptor file, Executor executor, final VideoCapture.OnVideoSavedListener listener) {
// End Signal Custom Code Block
if (mVideoCapture == null) {
return;
}
if (getCaptureMode() == CameraXView.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,
executor,
new VideoCapture.OnVideoSavedListener() {
@Override
// Begin Signal Custom Code block
public void onVideoSaved(@NonNull FileDescriptor savedFile) {
// End Signal Custom Code Block
mVideoIsRecording.set(false);
listener.onVideoSaved(savedFile);
}
@Override
public void onError(
@NonNull VideoCapture.VideoCaptureError videoCaptureError,
@NonNull String message,
@Nullable Throwable cause) {
mVideoIsRecording.set(false);
Log.e(TAG, message, cause);
listener.onError(videoCaptureError, message, cause);
}
});
}
// Begin Signal Custom Code Block
@RequiresApi(26)
// End Signal Custom Code Block
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 CameraX.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(CameraX.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 CameraX.LensFacing getLensFacing() {
return mCameraLensFacing;
}
public void toggleCamera() {
// TODO(b/124269166): Rethink how we can handle permissions here.
@SuppressLint("MissingPermission")
Set<CameraX.LensFacing> availableCameraLensFacing = getAvailableCameraLensFacing();
if (availableCameraLensFacing.isEmpty()) {
return;
}
if (mCameraLensFacing == null) {
setCameraLensFacing(availableCameraLensFacing.iterator().next());
return;
}
if (mCameraLensFacing == CameraX.LensFacing.BACK
&& availableCameraLensFacing.contains(CameraX.LensFacing.FRONT)) {
setCameraLensFacing(CameraX.LensFacing.FRONT);
return;
}
if (mCameraLensFacing == CameraX.LensFacing.FRONT
&& availableCameraLensFacing.contains(CameraX.LensFacing.BACK)) {
setCameraLensFacing(CameraX.LensFacing.BACK);
return;
}
}
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 = 0;
try {
CameraInfo cameraInfo = CameraX.getCameraInfo(getLensFacing());
rotationDegrees = cameraInfo.getSensorRotationDegrees(getDisplaySurfaceRotation());
if (compensateForMirroring) {
rotationDegrees = (360 - rotationDegrees) % 360;
}
} catch (CameraInfoUnavailableException e) {
Log.e(TAG, "Failed to get CameraInfo", e);
} catch (Exception e) {
Log.e(TAG, "Failed to query camera", e);
}
return rotationDegrees;
}
public void invalidateView() {
transformPreview();
updateViewInfo();
}
void clearCurrentLifecycle() {
if (mCurrentLifecycle != null) {
// Remove previous use cases
// Begin Signal Custom Code Block
CameraX.unbind(mImageCapture, mPreview);
if (mVideoCapture != null) {
CameraX.unbind(mVideoCapture);
}
// End Signal Custom Code Block
}
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.setTargetAspectRatioCustom(new Rational(getWidth(), getHeight()));
mImageCapture.setTargetRotation(getDisplaySurfaceRotation());
}
if (mVideoCapture != null && MediaConstraints.isVideoTranscodeAvailable()) {
mVideoCapture.setTargetRotation(getDisplaySurfaceRotation());
}
}
@RequiresPermission(permission.CAMERA)
private Set<CameraX.LensFacing> getAvailableCameraLensFacing() {
// Start with all camera directions
Set<CameraX.LensFacing> available = new LinkedHashSet<>(Arrays.asList(CameraX.LensFacing.values()));
// If we're bound to a lifecycle, remove unavailable cameras
if (mCurrentLifecycle != null) {
if (!hasCameraWithLensFacing(CameraX.LensFacing.BACK)) {
available.remove(CameraX.LensFacing.BACK);
}
if (!hasCameraWithLensFacing(CameraX.LensFacing.FRONT)) {
available.remove(CameraX.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 mCameraView.getContext();
}
public int getWidth() {
return mCameraView.getWidth();
}
public int getHeight() {
return mCameraView.getHeight();
}
public int getDisplayRotationDegrees() {
return CameraOrientationUtil.surfaceRotationToDegrees(getDisplaySurfaceRotation());
}
// Begin Signal Custom Code Block
public boolean hasFlash() {
try {
LiveData<Boolean> isFlashAvailable = CameraX.getCameraInfo(getLensFacing()).isFlashAvailable();
return isFlashAvailable.getValue() == Boolean.TRUE;
} catch (CameraInfoUnavailableException e) {
return false;
}
}
// End Signal Custom Code Block
protected int getDisplaySurfaceRotation() {
return mCameraView.getDisplaySurfaceRotation();
}
public void setSurfaceTexture(SurfaceTexture st) {
mCameraView.setSurfaceTexture(st);
}
private int getPreviewWidth() {
return mCameraView.getPreviewWidth();
}
private int getPreviewHeight() {
return mCameraView.getPreviewHeight();
}
private int getMeasuredWidth() {
return mCameraView.getMeasuredWidth();
}
private int getMeasuredHeight() {
return mCameraView.getMeasuredHeight();
}
void setTransform(final Matrix matrix) {
if (Looper.myLooper() != Looper.getMainLooper()) {
mCameraView.post(
new Runnable() {
@Override
public void run() {
setTransform(matrix);
}
});
} else {
mCameraView.setTransform(matrix);
}
}
/**
* Notify the view that the source dimensions have changed.
*
* <p>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) {
mCameraView.onPreviewSourceDimensUpdated(width, height);
}
public CameraXView.CaptureMode getCaptureMode() {
return mCaptureMode;
}
public void setCaptureMode(CameraXView.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;
}
}

View File

@@ -0,0 +1,260 @@
package org.thoughtcrime.securesms.mediasend.camerax;
import android.annotation.TargetApi;
import android.content.Context;
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.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.CameraMetadata;
import android.os.Build;
import android.util.Rational;
import android.util.Size;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.camera.camera2.impl.compat.CameraManagerCompat;
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.mediasend.LegacyCameraModels;
import org.thoughtcrime.securesms.migrations.LegacyMigrationJob;
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)
private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL};
@RequiresApi(24)
private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING_24 = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3};
@RequiresApi(28)
private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING_28 = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_EXTERNAL,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3};
@SuppressWarnings("SuspiciousNameCombination")
@RequiresApi(21)
public static ImageResult toJpeg(@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);
}
int width = cropRect != null ? (cropRect.right - cropRect.left) : image.getWidth();
int height = cropRect != null ? (cropRect.bottom - cropRect.top) : image.getHeight();
if (rotation == 90 || rotation == 270) {
int swap = width;
width = height;
height = swap;
}
return new ImageResult(data, width, height);
}
public static boolean isSupported() {
return Build.VERSION.SDK_INT >= 21 && !LegacyCameraModels.isLegacyCameraModel();
}
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;
}
public static int getIdealResolution(int displayWidth, int displayHeight) {
int maxDisplay = Math.max(displayWidth, displayHeight);
return Math.max(maxDisplay, 1920);
}
@TargetApi(21)
public static @NonNull Size buildResolutionForRatio(int longDimension, @NonNull Rational ratio, boolean isPortrait) {
int shortDimension = longDimension * ratio.getDenominator() / ratio.getNumerator();
if (isPortrait) {
return new Size(shortDimension, longDimension);
} else {
return new Size(longDimension, shortDimension);
}
}
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();
}
@RequiresApi(21)
public static boolean isMixedModeSupported(@NonNull Context context) {
return getLowestSupportedHardwareLevel(context) != CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY;
}
@RequiresApi(21)
public static int getLowestSupportedHardwareLevel(@NonNull Context context) {
CameraManager cameraManager = CameraManagerCompat.from(context).unwrap();
try {
int supported = maxHardwareLevel();
for (String cameraId : cameraManager.getCameraIdList()) {
CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId);
Integer hwLevel = characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL);
if (hwLevel == null || hwLevel == CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) {
return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY;
}
supported = smallerHardwareLevel(supported, hwLevel);
}
return supported;
} catch (CameraAccessException e) {
Log.w(TAG, "Failed to enumerate cameras", e);
return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY;
}
}
@RequiresApi(21)
private static int maxHardwareLevel() {
if (Build.VERSION.SDK_INT >= 24) return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3;
else return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL;
}
@RequiresApi(21)
private static int smallerHardwareLevel(int levelA, int levelB) {
int[] hardwareInfoOrdering = getHardwareInfoOrdering();
for (int hwInfo : hardwareInfoOrdering) {
if (levelA == hwInfo || levelB == hwInfo) return hwInfo;
}
return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY;
}
@RequiresApi(21)
private static int[] getHardwareInfoOrdering() {
if (Build.VERSION.SDK_INT >= 28) return CAMERA_HARDWARE_LEVEL_ORDERING_28;
else if (Build.VERSION.SDK_INT >= 24) return CAMERA_HARDWARE_LEVEL_ORDERING_24;
else return CAMERA_HARDWARE_LEVEL_ORDERING;
}
public static class ImageResult {
private final byte[] data;
private final int width;
private final int height;
public ImageResult(@NonNull byte[] data, int width, int height) {
this.data = data;
this.width = width;
this.height = height;
}
public byte[] getData() {
return data;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<String> MODELS = new HashSet<String>() {{
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);
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright 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.
*/
package org.thoughtcrime.securesms.mediasend.camerax;
import android.graphics.Matrix;
import android.graphics.PointF;
import android.graphics.SurfaceTexture;
import android.view.TextureView;
import androidx.annotation.NonNull;
import androidx.camera.core.MeteringPoint;
import androidx.camera.core.MeteringPointFactory;
/**
* A {@link MeteringPointFactory} for creating a {@link MeteringPoint} by {@link TextureView} and
* (x,y).
*
* <p>SurfaceTexture in TextureView could be cropped, scaled or rotated by
* {@link TextureView#getTransform(Matrix)}. This factory translates the (x, y) into the sensor
* crop region normalized (x,y) by this transform. {@link SurfaceTexture#getTransformMatrix} is
* also used during the translation. No lens facing information is required because
* {@link SurfaceTexture#getTransformMatrix} contains the necessary transformation corresponding
* to the lens face of current camera ouput.
*/
public class TextureViewMeteringPointFactory extends MeteringPointFactory {
private final TextureView mTextureView;
public TextureViewMeteringPointFactory(@NonNull TextureView textureView) {
mTextureView = textureView;
}
/**
* Translates a (x,y) from TextureView.
*/
@NonNull
@Override
protected PointF translatePoint(float x, float y) {
Matrix transform = new Matrix();
mTextureView.getTransform(transform);
// applying reverse of TextureView#getTransform
Matrix inverse = new Matrix();
transform.invert(inverse);
float[] pt = new float[]{x, y};
inverse.mapPoints(pt);
// get SurfaceTexture#getTransformMatrix
float[] surfaceTextureMat = new float[16];
mTextureView.getSurfaceTexture().getTransformMatrix(surfaceTextureMat);
// convert SurfaceTexture#getTransformMatrix(4x4 column major 3D matrix) to
// android.graphics.Matrix(3x3 row major 2D matrix)
Matrix surfaceTextureTransform = glMatrixToGraphicsMatrix(surfaceTextureMat);
float[] pt2 = new float[2];
// convert to texture coordinates first.
pt2[0] = pt[0] / mTextureView.getWidth();
pt2[1] = (mTextureView.getHeight() - pt[1]) / mTextureView.getHeight();
surfaceTextureTransform.mapPoints(pt2);
return new PointF(pt2[0], pt2[1]);
}
private Matrix glMatrixToGraphicsMatrix(float[] glMatrix) {
float[] convert = new float[9];
convert[0] = glMatrix[0];
convert[1] = glMatrix[4];
convert[2] = glMatrix[12];
convert[3] = glMatrix[1];
convert[4] = glMatrix[5];
convert[5] = glMatrix[13];
convert[6] = glMatrix[3];
convert[7] = glMatrix[7];
convert[8] = glMatrix[15];
Matrix graphicsMatrix = new Matrix();
graphicsMatrix.setValues(convert);
return graphicsMatrix;
}
}

File diff suppressed because it is too large Load Diff