mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-25 19:29:54 +01:00
Move all files to natural position.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 : ",");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user