Refresh media selection and sending flow with a shiny new UX.

This commit is contained in:
Alex Hart
2021-09-02 17:04:43 -03:00
committed by Greyson Parrelli
parent a940487611
commit 664d6475d9
195 changed files with 7075 additions and 4812 deletions

View File

@@ -13,21 +13,23 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import androidx.lifecycle.ViewModelProviders;
import androidx.lifecycle.LiveData;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOptions;
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
import org.thoughtcrime.securesms.mediasend.v2.gallery.MediaGalleryFragment;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.FileDescriptor;
import java.util.Collections;
public class AvatarSelectionActivity extends AppCompatActivity implements CameraFragment.Controller, ImageEditorFragment.Controller, MediaPickerFolderFragment.Controller, MediaPickerItemFragment.Controller {
public class AvatarSelectionActivity extends AppCompatActivity implements CameraFragment.Controller, ImageEditorFragment.Controller, MediaGalleryFragment.Callbacks {
private static final Point AVATAR_DIMENSIONS = new Point(AvatarHelper.AVATAR_DIMENSIONS, AvatarHelper.AVATAR_DIMENSIONS);
@@ -62,13 +64,10 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
super.onCreate(savedInstanceState);
setContentView(R.layout.avatar_selection_activity);
MediaSendViewModel viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
viewModel.setTransport(TransportOptions.getPushTransportOption(this));
if (isGalleryFirst()) {
onGalleryClicked();
} else {
onCameraSelected();
onNavigateToCamera();
}
}
@@ -115,9 +114,9 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
return;
}
MediaPickerFolderFragment fragment = MediaPickerFolderFragment.newInstance(this, null);
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, fragment);
MediaGalleryFragment fragment = new MediaGalleryFragment();
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, fragment);
if (isCameraFirst()) {
transaction.addToBackStack(null);
@@ -136,6 +135,16 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
throw new UnsupportedOperationException("Cannot select more than one photo");
}
@Override
public @NonNull LiveData<Optional<Media>> getMostRecentMediaItem() {
return new DefaultValueLiveData<>(Optional.absent());
}
@Override
public @NonNull MediaConstraints getMediaConstraints() {
return MediaConstraints.getPushMediaConstraints();
}
@Override
public void onTouchEventsNeeded(boolean needed) {
}
@@ -144,14 +153,6 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) {
}
@Override
public void onFolderSelected(@NonNull MediaFolder folder) {
getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), 1, false))
.addToBackStack(null)
.commit();
}
@Override
public void onMediaSelected(@NonNull Media media) {
currentMedia = media;
@@ -163,25 +164,23 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
}
@Override
public void onCameraSelected() {
if (isCameraFirst() && popToRoot()) {
return;
}
Fragment fragment = CameraFragment.newInstanceForAvatarCapture();
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, fragment, IMAGE_CAPTURE);
if (isGalleryFirst()) {
transaction.addToBackStack(null);
}
transaction.commit();
public void onDoneEditing() {
handleSave();
}
@Override
public void onDoneEditing() {
handleSave();
public void onCancelEditing() {
finish();
}
@Override
public void onMainImageLoaded() {
}
@Override
public void onMainImageFailedToLoad() {
}
public boolean popToRoot() {
@@ -230,4 +229,46 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
finish();
});
}
@Override
public boolean isMultiselectEnabled() {
return false;
}
@Override
public void onMediaUnselected(@NonNull Media media) {
throw new UnsupportedOperationException();
}
@Override
public void onSelectedMediaClicked(@NonNull Media media) {
throw new UnsupportedOperationException();
}
@Override
public void onNavigateToCamera() {
if (isCameraFirst() && popToRoot()) {
return;
}
Fragment fragment = CameraFragment.newInstanceForAvatarCapture();
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, fragment, IMAGE_CAPTURE);
if (isGalleryFirst()) {
transaction.addToBackStack(null);
}
transaction.commit();
}
@Override
public void onSubmit() {
throw new UnsupportedOperationException();
}
@Override
public void onToolbarNavigationClicked() {
finish();
}
}

View File

@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.mediasend;
import android.animation.Animator;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Matrix;
@@ -22,11 +24,9 @@ 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.lifecycle.ViewModelProviders;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.MultiTransformation;
@@ -38,6 +38,8 @@ import com.bumptech.glide.request.transition.Transition;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.ServiceUtil;
@@ -65,7 +67,6 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
private Controller controller;
private OrderEnforcer<Stage> orderEnforcer;
private Camera1Controller.Properties properties;
private MediaSendViewModel viewModel;
public static Camera1Fragment newInstance() {
return new Camera1Fragment();
@@ -74,8 +75,15 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (!(getActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement the Controller interface.");
if (getActivity() instanceof Controller) {
this.controller = (Controller) getActivity();
} else if (getParentFragment() instanceof Controller) {
this.controller = (Controller) getParentFragment();
}
if (controller == null) {
throw new IllegalStateException("Parent must implement controller interface.");
}
WindowManager windowManager = ServiceUtil.getWindowManager(getActivity());
@@ -84,10 +92,8 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
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
@@ -104,6 +110,8 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
cameraPreview = view.findViewById(R.id.camera_preview);
controlsContainer = view.findViewById(R.id.camera_controls_container);
View cameraParent = view.findViewById(R.id.camera_preview_parent);
onOrientationChanged(getResources().getConfiguration().orientation);
cameraPreview.setSurfaceTextureListener(this);
@@ -111,14 +119,29 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
GestureDetector gestureDetector = new GestureDetector(flipGestureListener);
cameraPreview.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event));
viewModel.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
viewModel.getHudState().observe(getViewLifecycleOwner(), this::presentHud);
controller.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
// Let's assume portrait for now, so 9:16
float aspectRatio = 9f / 16f;
float width = right - left;
float height = Math.min((1f / aspectRatio) * width, bottom - top);
ViewGroup.LayoutParams params = cameraParent.getLayoutParams();
// If there's a mismatch...
if (params.height != (int) height) {
params.width = (int) width;
params.height = (int) height;
cameraParent.setLayoutParams(params);
}
});
}
@Override
public void onResume() {
super.onResume();
viewModel.onCameraStarted();
camera.initialize();
if (cameraPreview.isAvailable()) {
@@ -144,6 +167,35 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
orderEnforcer.reset();
}
@Override
public void fadeOutControls(@NonNull Runnable onEndAction) {
controlsContainer.setEnabled(false);
controlsContainer.animate()
.setDuration(250)
.alpha(0f)
.setListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
controlsContainer.setEnabled(true);
onEndAction.run();
}
});
}
@Override
public void fadeInControls() {
controlsContainer.setEnabled(false);
controlsContainer.animate()
.setDuration(250)
.alpha(1f)
.setListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
controlsContainer.setEnabled(true);
}
});
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
@@ -203,15 +255,13 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
}
}
private void presentHud(@Nullable MediaSendViewModel.HudState state) {
if (state == null) return;
@Override
public void presentHud(int selectedMediaCount) {
MediaCountIndicatorButton countButton = controlsContainer.findViewById(R.id.camera_review_button);
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) {
if (selectedMediaCount > 0) {
countButton.setVisibility(View.VISIBLE);
countButtonText.setText(String.valueOf(state.getSelectionCount()));
countButton.setCount(selectedMediaCount);
} else {
countButton.setVisibility(View.GONE);
}
@@ -222,7 +272,7 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
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);
View countButton = requireView().findViewById(R.id.camera_review_button);
captureButton.setOnClickListener(v -> {
captureButton.setEnabled(false);
@@ -248,8 +298,6 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
galleryButton.setOnClickListener(v -> controller.onGalleryClicked());
countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked());
viewModel.onCameraControlsInitialized();
}
private void onCaptureClicked() {

View File

@@ -19,6 +19,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
public class CameraButtonView extends View {
@@ -95,7 +96,7 @@ public class CameraButtonView extends View {
outlinePaint.setColor(0x26000000);
outlinePaint.setAntiAlias(true);
outlinePaint.setStyle(Paint.Style.STROKE);
outlinePaint.setStrokeWidth(1.5f);
outlinePaint.setStrokeWidth(ViewUtil.dpToPx(4));
return outlinePaint;
}

View File

@@ -38,7 +38,6 @@ import java.util.List;
public class CameraContactSelectionFragment extends LoggingFragment implements CameraContactAdapter.CameraContactListener {
private Controller controller;
private MediaSendViewModel mediaSendViewModel;
private CameraContactSelectionViewModel contactViewModel;
private RecyclerView contactList;
private CameraContactAdapter contactAdapter;
@@ -60,7 +59,6 @@ public class CameraContactSelectionFragment extends LoggingFragment implements C
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);
}
@@ -109,12 +107,6 @@ public class CameraContactSelectionFragment extends LoggingFragment implements C
initViewModel();
}
@Override
public void onResume() {
super.onResume();
mediaSendViewModel.onContactSelectStarted();
}
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
requireActivity().getMenuInflater().inflate(R.menu.camera_contacts, menu);

View File

@@ -17,7 +17,6 @@ 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.FeatureFlags;
import java.util.ArrayList;
import java.util.Collections;

View File

@@ -4,8 +4,11 @@ import android.annotation.SuppressLint;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.LiveData;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.FileDescriptor;
@@ -29,6 +32,10 @@ public interface CameraFragment {
}
}
void presentHud(int selectedMediaCount);
void fadeOutControls(@NonNull Runnable onEndAction);
void fadeInControls();
interface Controller {
void onCameraError();
void onImageCaptured(@NonNull byte[] data, int width, int height);
@@ -37,5 +44,7 @@ public interface CameraFragment {
void onGalleryClicked();
int getDisplayRotation();
void onCameraCountButtonClicked();
@NonNull LiveData<Optional<Media>> getMostRecentMediaItem();
@NonNull MediaConstraints getMediaConstraints();
}
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.mediasend;
import android.animation.Animator;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Configuration;
@@ -17,7 +18,6 @@ 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;
@@ -30,7 +30,6 @@ import androidx.camera.lifecycle.ProcessCameraProvider;
import androidx.camera.view.PreviewView;
import androidx.camera.view.SignalCameraView;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.ViewModelProviders;
import com.bumptech.glide.Glide;
import com.bumptech.glide.util.Executors;
@@ -38,9 +37,11 @@ import com.bumptech.glide.util.Executors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXFlashToggleView;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.util.MemoryFileDescriptor;
@@ -63,12 +64,11 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
private static final String TAG = Log.tag(CameraXFragment.class);
private static final String IS_VIDEO_ENABLED = "is_video_enabled";
private SignalCameraView camera;
private ViewGroup controlsContainer;
private Controller controller;
private MediaSendViewModel viewModel;
private View selfieFlash;
private MemoryFileDescriptor videoFileDescriptor;
private SignalCameraView camera;
private ViewGroup controlsContainer;
private Controller controller;
private View selfieFlash;
private MemoryFileDescriptor videoFileDescriptor;
public static CameraXFragment newInstanceForAvatarCapture() {
CameraXFragment fragment = new CameraXFragment();
@@ -92,18 +92,15 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
public void onAttach(@NonNull Context context) {
super.onAttach(context);
if (!(getActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement controller interface.");
if (getActivity() instanceof Controller) {
this.controller = (Controller) getActivity();
} else if (getParentFragment() instanceof Controller) {
this.controller = (Controller) getParentFragment();
}
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);
if (controller == null) {
throw new IllegalStateException("Parent must implement controller interface.");
}
}
@Override
@@ -114,16 +111,35 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
@SuppressLint("MissingPermission")
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
ViewGroup cameraParent = view.findViewById(R.id.camerax_camera_parent);
this.camera = view.findViewById(R.id.camerax_camera);
this.controlsContainer = view.findViewById(R.id.camerax_controls_container);
camera.setScaleType(PreviewView.ScaleType.FIT_CENTER);
camera.bindToLifecycle(getViewLifecycleOwner());
camera.setCameraLensFacing(CameraXUtil.toLensFacing(TextSecurePreferences.getDirectCaptureCameraId(requireContext())));
onOrientationChanged(getResources().getConfiguration().orientation);
viewModel.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
viewModel.getHudState().observe(getViewLifecycleOwner(), this::presentHud);
controller.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
// Let's assume portrait for now, so 9:16
float aspectRatio = 9f / 16f;
float width = right - left;
float height = Math.min((1f / aspectRatio) * width, bottom - top);
ViewGroup.LayoutParams params = cameraParent.getLayoutParams();
// If there's a mismatch...
if (params.height != (int) height) {
params.width = (int) width;
params.height = (int) height;
cameraParent.setLayoutParams(params);
}
});
}
@Override
@@ -131,7 +147,6 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
super.onResume();
camera.bindToLifecycle(getViewLifecycleOwner());
viewModel.onCameraStarted();
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
}
@@ -148,6 +163,35 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
onOrientationChanged(newConfig.orientation);
}
@Override
public void fadeOutControls(@NonNull Runnable onEndAction) {
controlsContainer.setEnabled(false);
controlsContainer.animate()
.setDuration(250)
.alpha(0f)
.setListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
controlsContainer.setEnabled(true);
onEndAction.run();
}
});
}
@Override
public void fadeInControls() {
controlsContainer.setEnabled(false);
controlsContainer.animate()
.setDuration(250)
.alpha(1f)
.setListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
controlsContainer.setEnabled(true);
}
});
}
private void onOrientationChanged(int orientation) {
int layout = orientation == Configuration.ORIENTATION_PORTRAIT ? R.layout.camera_controls_portrait
: R.layout.camera_controls_landscape;
@@ -176,15 +220,13 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
}
}
private void presentHud(@Nullable MediaSendViewModel.HudState state) {
if (state == null) return;
@Override
public void presentHud(int selectedMediaCount) {
MediaCountIndicatorButton countButton = controlsContainer.findViewById(R.id.camera_review_button);
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) {
if (selectedMediaCount > 0) {
countButton.setVisibility(View.VISIBLE);
countButtonText.setText(String.valueOf(state.getSelectionCount()));
countButton.setCount(selectedMediaCount);
} else {
countButton.setVisibility(View.GONE);
}
@@ -195,7 +237,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
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);
View countButton = requireView().findViewById(R.id.camera_review_button);
CameraXFlashToggleView flashButton = requireView().findViewById(R.id.camera_flash_button);
selfieFlash = requireView().findViewById(R.id.camera_selfie_flash);
@@ -230,7 +272,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
camera.setCaptureMode(SignalCameraView.CaptureMode.MIXED);
int maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), viewModel.getMediaConstraints());
int maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller.getMediaConstraints());
Log.d(TAG, "Max duration: " + maxDuration + " sec");
captureButton.setVideoCaptureListener(new CameraXVideoCaptureHelper(
@@ -267,10 +309,8 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
"API: " + Build.VERSION.SDK_INT + ", " +
"MFD: " + MemoryFileDescriptor.supported() + ", " +
"Camera: " + CameraXUtil.getLowestSupportedHardwareLevel(requireContext()) + ", " +
"MaxDuration: " + VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), viewModel.getMediaConstraints()) + " sec");
"MaxDuration: " + VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller.getMediaConstraints()) + " sec");
}
viewModel.onCameraControlsInitialized();
}
private boolean isVideoRecordingSupported(@NonNull Context context) {
@@ -278,7 +318,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
requireArguments().getBoolean(IS_VIDEO_ENABLED, true) &&
MediaConstraints.isVideoTranscodeAvailable() &&
CameraXUtil.isMixedModeSupported(context) &&
VideoUtil.getMaxVideoRecordDurationInSeconds(context, viewModel.getMediaConstraints()) > 0;
VideoUtil.getMaxVideoRecordDurationInSeconds(context, controller.getMediaConstraints()) > 0;
}
private void displayVideoRecordingTooltipIfNecessary(CameraButtonView captureButton) {
@@ -389,6 +429,11 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
@SuppressLint({"MissingPermission"})
private void initializeFlipButton(@NonNull View flipButton, @NonNull CameraXFlashToggleView flashButton) {
if (getContext() == null) {
Log.w(TAG, "initializeFlipButton called either before or after fragment was attached.");
return;
}
if (camera.hasCameraWithLensFacing(CameraSelector.LENS_FACING_FRONT) && camera.hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK)) {
flipButton.setVisibility(View.VISIBLE);
flipButton.setOnClickListener(v -> {

View File

@@ -13,7 +13,7 @@ public final class CompositeMediaTransform implements MediaTransform {
private final MediaTransform[] transforms;
CompositeMediaTransform(MediaTransform ...transforms) {
public CompositeMediaTransform(MediaTransform ...transforms) {
this.transforms = transforms;
}

View File

@@ -26,11 +26,11 @@ public final class ImageEditorModelRenderMediaTransform implements MediaTransfor
@NonNull private final EditorModel modelToRender;
@Nullable private final Point size;
ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender) {
public ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender) {
this(modelToRender, null);
}
ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender, @Nullable Point size) {
public ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender, @Nullable Point size) {
this.modelToRender = modelToRender;
this.size = size;
}

View File

@@ -23,7 +23,7 @@ public class MediaFolder {
this.folderType = folderType;
}
Uri getThumbnailUri() {
public Uri getThumbnailUri() {
return thumbnailUri;
}
@@ -31,7 +31,7 @@ public class MediaFolder {
return title;
}
int getItemCount() {
public int getItemCount() {
return itemCount;
}
@@ -39,7 +39,7 @@ public class MediaFolder {
return bucketId;
}
FolderType getFolderType() {
public FolderType getFolderType() {
return folderType;
}

View File

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

View File

@@ -1,166 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Point;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
/**
* 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 static final String KEY_HIDE_CAMERA = "hide_camera";
private String toolbarTitle;
private boolean showCamera;
private MediaSendViewModel viewModel;
private Controller controller;
private GridLayoutManager layoutManager;
public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient) {
return newInstance(context, recipient, false);
}
public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient, boolean hideCamera) {
String toolbarTitle;
if (recipient != null) {
String name = recipient.getDisplayName(context);
toolbarTitle = context.getString(R.string.MediaPickerActivity_send_to, name);
} else {
toolbarTitle = "";
}
return newInstance(toolbarTitle, hideCamera);
}
public static @NonNull MediaPickerFolderFragment newInstance(@NonNull String toolbarTitle, boolean hideCamera) {
Bundle args = new Bundle();
args.putString(KEY_TOOLBAR_TITLE, toolbarTitle);
args.putBoolean(KEY_HIDE_CAMERA, hideCamera);
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);
showCamera = !getArguments().getBoolean(KEY_HIDE_CAMERA);
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(getViewLifecycleOwner(), adapter::setFolders);
initToolbar(view.findViewById(R.id.mediapicker_toolbar));
}
@Override
public void onResume() {
super.onResume();
viewModel.onFolderPickerStarted();
}
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
if (showCamera) {
requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu);
}
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.mediapicker_menu_camera) { controller.onCameraSelected(); return true; }
return false;
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onScreenWidthChanged(getScreenWidth());
}
private void initToolbar(Toolbar toolbar) {
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(toolbarTitle);
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
}
private void onScreenWidthChanged(int newWidth) {
if (layoutManager != null) {
layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_folder_width));
}
}
private int getScreenWidth() {
Point size = new Point();
requireActivity().getWindowManager().getDefaultDisplay().getSize(size);
return size.x;
}
@Override
public void onFolderClicked(@NonNull MediaFolder folder) {
controller.onFolderSelected(folder);
}
public interface Controller {
void onFolderSelected(@NonNull MediaFolder folder);
void onCameraSelected();
}
}

View File

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

View File

@@ -1,191 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Point;
import android.os.Bundle;
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.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
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 static final String KEY_FORCE_MULTI_SELECT = "force_multi_select";
private static final String KEY_HIDE_CAMERA = "hide_camera";
private String bucketId;
private String folderTitle;
private int maxSelection;
private boolean showCamera;
private MediaSendViewModel viewModel;
private MediaPickerItemAdapter adapter;
private Controller controller;
private GridLayoutManager layoutManager;
public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection) {
return newInstance(bucketId, folderTitle, maxSelection, true);
}
public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection, boolean forceMultiSelect) {
return newInstance(bucketId, folderTitle, maxSelection, forceMultiSelect, false);
}
public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection, boolean forceMultiSelect, boolean hideCamera) {
Bundle args = new Bundle();
args.putString(KEY_BUCKET_ID, bucketId);
args.putString(KEY_FOLDER_TITLE, folderTitle);
args.putInt(KEY_MAX_SELECTION, maxSelection);
args.putBoolean(KEY_FORCE_MULTI_SELECT, forceMultiSelect);
args.putBoolean(KEY_HIDE_CAMERA, hideCamera);
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);
showCamera = !getArguments().getBoolean(KEY_HIDE_CAMERA);
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());
viewModel.getSelectedMedia().observe(getViewLifecycleOwner(), adapter::setSelected);
viewModel.getMediaInBucket(requireContext(), bucketId).observe(getViewLifecycleOwner(), adapter::setMedia);
}
@Override
public void onResume() {
super.onResume();
viewModel.onItemPickerStarted();
if (requireArguments().getBoolean(KEY_FORCE_MULTI_SELECT)) {
adapter.setForcedMultiSelect(true);
viewModel.onMultiSelectStarted();
}
}
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
if (showCamera) {
requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu);
}
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.mediapicker_menu_camera) { controller.onCameraSelected(); return true; }
return false;
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onScreenWidthChanged(getScreenWidth());
}
@Override
public void onMediaChosen(@NonNull Media media) {
controller.onMediaSelected(media);
}
@Override
public void onMediaSelectionStarted() {
viewModel.onMultiSelectStarted();
}
@Override
public void onMediaSelectionChanged(@NonNull List<Media> selected) {
adapter.notifyDataSetChanged();
viewModel.onSelectedMediaChanged(requireContext(), selected);
}
@Override
public void onMediaSelectionOverflow(int maxSelection) {
Toast.makeText(requireContext(), getResources().getQuantityString(R.plurals.MediaSendActivity_cant_share_more_than_n_items, maxSelection, maxSelection), Toast.LENGTH_SHORT).show();
}
private void initToolbar(Toolbar toolbar) {
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(folderTitle);
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
}
private void onScreenWidthChanged(int newWidth) {
if (layoutManager != null) {
layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_item_width));
}
}
private int getScreenWidth() {
Point size = new Point();
requireActivity().getWindowManager().getDefaultDisplay().getSize(size);
return size.x;
}
public interface Controller {
void onMediaSelected(@NonNull Media media);
void onCameraSelected();
}
}

View File

@@ -49,7 +49,7 @@ public class MediaRepository {
/**
* Retrieves a list of folders that contain media.
*/
void getFolders(@NonNull Context context, @NonNull Callback<List<MediaFolder>> callback) {
public void getFolders(@NonNull Context context, @NonNull Callback<List<MediaFolder>> callback) {
if (!StorageUtil.canReadFromMediaStore()) {
Log.w(TAG, "No storage permissions!", new Throwable());
callback.onComplete(Collections.emptyList());
@@ -76,7 +76,7 @@ public class MediaRepository {
* 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) {
public 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;
@@ -107,7 +107,7 @@ public class MediaRepository {
@NonNull Map<Media, MediaTransform> modelsToTransform,
@NonNull Callback<LinkedHashMap<Media, Media>> callback)
{
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(transformMedia(context, currentMedia, modelsToTransform)));
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(transformMediaSync(context, currentMedia, modelsToTransform)));
}
@WorkerThread
@@ -256,7 +256,7 @@ public class MediaRepository {
}
@WorkerThread
private List<Media> getPopulatedMedia(@NonNull Context context, @NonNull List<Media> media) {
public List<Media> getPopulatedMedia(@NonNull Context context, @NonNull List<Media> media) {
return media.stream()
.map(m -> {
try {
@@ -276,9 +276,9 @@ public class MediaRepository {
}
@WorkerThread
private static LinkedHashMap<Media, Media> transformMedia(@NonNull Context context,
@NonNull List<Media> currentMedia,
@NonNull Map<Media, MediaTransform> modelsToTransform)
public static LinkedHashMap<Media, Media> transformMediaSync(@NonNull Context context,
@NonNull List<Media> currentMedia,
@NonNull Map<Media, MediaTransform> modelsToTransform)
{
LinkedHashMap<Media, Media> updatedMedia = new LinkedHashMap<>(currentMedia.size());
@@ -367,7 +367,7 @@ public class MediaRepository {
}
@VisibleForTesting
static @NonNull Media fixMimeType(@NonNull Context context, @NonNull Media media) {
public static @NonNull Media fixMimeType(@NonNull Context context, @NonNull Media media) {
if (MediaUtil.isOctetStream(media.getMimeType())) {
Log.w(TAG, "Media has mimetype octet stream");
String newMimeType = MediaUtil.getMimeType(context, media.getUri());

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.Intent;
import android.os.Parcel;
import android.os.Parcelable;
@@ -21,6 +22,9 @@ import java.util.List;
* A class that lets us nicely format data that we'll send back to {@link ConversationActivity}.
*/
public class MediaSendActivityResult implements Parcelable {
public static final String EXTRA_RESULT = "result";
private final RecipientId recipientId;
private final Collection<PreUploadResult> uploadResults;
private final Collection<Media> nonUploadedMedia;
@@ -29,23 +33,32 @@ public class MediaSendActivityResult implements Parcelable {
private final boolean viewOnce;
private final Collection<Mention> mentions;
static @NonNull MediaSendActivityResult forPreUpload(@NonNull RecipientId recipientId,
@NonNull Collection<PreUploadResult> uploadResults,
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce,
@NonNull List<Mention> mentions)
public static @NonNull MediaSendActivityResult fromData(@NonNull Intent data) {
MediaSendActivityResult result = data.getParcelableExtra(MediaSendActivityResult.EXTRA_RESULT);
if (result == null) {
throw new IllegalArgumentException();
}
return result;
}
public static @NonNull MediaSendActivityResult forPreUpload(@NonNull RecipientId recipientId,
@NonNull Collection<PreUploadResult> uploadResults,
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce,
@NonNull List<Mention> mentions)
{
Preconditions.checkArgument(uploadResults.size() > 0, "Must supply uploadResults!");
return new MediaSendActivityResult(recipientId, uploadResults, Collections.emptyList(), body, transport, viewOnce, mentions);
}
static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull RecipientId recipientId,
@NonNull List<Media> nonUploadedMedia,
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce,
@NonNull List<Mention> mentions)
public static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull RecipientId recipientId,
@NonNull List<Media> nonUploadedMedia,
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce,
@NonNull List<Mention> mentions)
{
Preconditions.checkArgument(nonUploadedMedia.size() > 0, "Must supply media!");
return new MediaSendActivityResult(recipientId, Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce, mentions);

View File

@@ -1,155 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.viewpager.widget.ViewPager;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ControllableViewPager;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.util.Util;
import java.util.List;
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 ViewGroup playbackControlsContainer;
private ControllableViewPager fragmentPager;
private MediaSendFragmentPagerAdapter fragmentPagerAdapter;
private MediaSendViewModel viewModel;
public static MediaSendFragment newInstance() {
Bundle args = new Bundle();
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(), viewModel.isSms() ? MediaConstraints.getMmsMediaConstraints(-1) : MediaConstraints.getPushMediaConstraints(null));
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(getViewLifecycleOwner(), media -> {
if (Util.isEmpty(media)) {
return;
}
fragmentPagerAdapter.setMedia(media);
});
viewModel.getPosition().observe(getViewLifecycleOwner(), 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();
}
});
}
void pausePlayback() {
fragmentPagerAdapter.pausePlayback();
}
private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
@Override
public void onPageSelected(int position) {
viewModel.onPageChanged(position);
fragmentPagerAdapter.notifyPageChanged(position);
}
}
}

View File

@@ -1,157 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapter;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.mms.MediaConstraints;
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;
private final MediaConstraints mediaConstraints;
MediaSendFragmentPagerAdapter(@NonNull FragmentManager fm, @NonNull MediaConstraints mediaConstraints) {
super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
this.mediaConstraints = mediaConstraints;
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(),
mediaConstraints.getCompressedVideoMaxSize(ApplicationDependencies.getApplication()),
mediaConstraints.getVideoMaxSize(ApplicationDependencies.getApplication()),
mediaItem.isVideoGif());
} 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 pausePlayback() {
for (MediaSendPageFragment fragment : fragments.values()) {
if (fragment instanceof MediaSendVideoFragment) {
((MediaSendVideoFragment)fragment).pausePlayback();
}
}
}
void notifyHidden() {
Stream.of(fragments.values()).forEach(MediaSendPageFragment::notifyHidden);
}
void notifyPageChanged(int currentPage) {
notifyHiddenIfExists(currentPage - 1);
notifyHiddenIfExists(currentPage + 1);
}
private void notifyHiddenIfExists(int position) {
MediaSendPageFragment fragment = fragments.get(position);
if (fragment != null) {
fragment.notifyHidden();
}
}
}

View File

@@ -1,809 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.app.Application;
import android.content.Context;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
import org.thoughtcrime.securesms.mms.SentMediaQuality;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult;
import org.thoughtcrime.securesms.util.DiffHelper;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* Manages the observable datasets available in {@link MediaSendActivity}.
*/
class MediaSendViewModel extends ViewModel {
private static final String TAG = Log.tag(MediaSendViewModel.class);
private final Application application;
private final MediaRepository repository;
private final MediaUploadRepository uploadRepository;
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 MutableLiveData<SentMediaQuality> sentMediaQuality;
private final LiveData<Boolean> showMediaQualityToggle;
private final Map<Uri, Object> savedDrawState;
private TransportOption transport;
private MediaConstraints mediaConstraints;
private CharSequence body;
private boolean sentMedia;
private int maxSelection;
private Page page;
private boolean isSms;
private boolean meteredConnection;
private Optional<Media> lastCameraCapture;
private boolean preUploadEnabled;
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,
@NonNull MediaUploadRepository uploadRepository)
{
this.application = application;
this.repository = repository;
this.uploadRepository = uploadRepository;
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.sentMediaQuality = new MutableLiveData<>(SentMediaQuality.STANDARD);
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;
this.preUploadEnabled = true;
this.showMediaQualityToggle = LiveDataUtil.mapAsync(this.selectedMedia, s -> s.stream().anyMatch(m -> MediaUtil.isImageAndNotGif(m.getMimeType())));
position.setValue(-1);
}
void setTransport(@NonNull TransportOption transport) {
this.transport = transport;
if (transport.isSms()) {
isSms = true;
maxSelection = MediaSendConstants.MAX_SMS;
mediaConstraints = MediaConstraints.getMmsMediaConstraints(transport.getSimSubscriptionId().or(-1));
} else {
isSms = false;
maxSelection = MediaSendConstants.MAX_PUSH;
mediaConstraints = MediaConstraints.getPushMediaConstraints();
}
preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient);
}
void setRecipient(@Nullable Recipient recipient) {
this.recipient = recipient;
this.preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient);
}
void onSelectedMediaChanged(@NonNull Context context, @NonNull List<Media> newMedia) {
List<Media> originalMedia = getSelectedMediaOrDefault();
repository.getPopulatedMedia(context, newMedia, populatedMedia -> {
ThreadUtil.runOnMain(() -> {
List<Media> filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints);
if (filteredMedia.size() != newMedia.size()) {
if (filteredMedia.isEmpty() && newMedia.size() == 1 && page == Page.UNKNOWN) {
if (MediaUtil.isImageOrVideoType(newMedia.get(0).getMimeType())) {
error.setValue(Error.ONLY_ITEM_TOO_LARGE);
} else {
error.setValue(Error.ONLY_ITEM_IS_INVALID_TYPE);
}
} else {
if (newMedia.stream().allMatch(m -> MediaUtil.isImageOrVideoType(m.getMimeType()))) {
error.setValue(Error.ITEM_TOO_LARGE);
} else {
error.setValue(Error.ITEM_TOO_LARGE_OR_INVALID_TYPE);
}
}
}
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());
}
updateAttachmentUploads(originalMedia, getSelectedMediaOrDefault());
});
});
}
void onSingleMediaSelected(@NonNull Context context, @NonNull Media media) {
repository.getPopulatedMedia(context, Collections.singletonList(media), populatedMedia -> {
ThreadUtil.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;
updateViewOnceState();
showViewOnceTooltipIfNecessary(viewOnceState);
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());
cancelUpload(lastCameraCapture.get());
}
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.getDuration(), m.isBorderless(), m.isVideoGif(), m.getBucketId(), Optional.absent(), Optional.absent()))
.toList();
selectedMedia.setValue(uncaptioned);
position.setValue(position.getValue() != null ? position.getValue() : 0);
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());
}
cancelUpload(removed);
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 onVideoBeginEdit(@NonNull Uri uri) {
cancelUpload(new Media(uri, "", 0, 0, 0, 0, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent()));
}
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);
startUpload(media);
}
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 onMeteredConnectivityStatusChanged(boolean metered) {
Log.i(TAG, "Metered connectivity status set to: " + metered);
meteredConnection = metered;
preUploadEnabled = shouldPreUpload(application, metered, isSms, recipient);
}
void saveDrawState(@NonNull Map<Uri, Object> state) {
savedDrawState.clear();
savedDrawState.putAll(state);
}
public void setSentMediaQuality(@NonNull SentMediaQuality newQuality) {
if (newQuality == sentMediaQuality.getValue()) {
return;
}
sentMediaQuality.setValue(newQuality);
preUploadEnabled = false;
uploadRepository.cancelAllUploads();
}
@NonNull LiveData<MediaSendActivityResult> onSendClicked(Map<Media, MediaTransform> modelsToTransform, @NonNull List<Recipient> recipients, @NonNull List<Mention> mentions) {
if (isSms && recipients.size() > 0) {
throw new IllegalStateException("Provided recipients to send to, but this is SMS!");
}
MutableLiveData<MediaSendActivityResult> result = new MutableLiveData<>();
String trimmedBody = isViewOnce() ? "" : body.toString().trim();
List<Media> initialMedia = getSelectedMediaOrDefault();
List<Mention> trimmedMentions = isViewOnce() ? Collections.emptyList() : mentions;
Preconditions.checkState(initialMedia.size() > 0, "No media to send!");
MediaRepository.transformMedia(application, initialMedia, modelsToTransform, (oldToNew) -> {
List<Media> updatedMedia = new ArrayList<>(oldToNew.values());
for (Media media : updatedMedia){
Log.w(TAG, media.getUri().toString() + " : " + media.getTransformProperties().transform(t->"" + t.isVideoTrim()).or("null"));
}
if (isSms || MessageSender.isLocalSelfSend(application, recipient, isSms)) {
Log.i(TAG, "SMS or local self-send. Skipping pre-upload.");
result.postValue(MediaSendActivityResult.forTraditionalSend(recipient.getId(), updatedMedia, trimmedBody, transport, isViewOnce(), trimmedMentions));
return;
}
MessageUtil.SplitResult splitMessage = MessageUtil.getSplitMessage(application, trimmedBody, transport.calculateCharacters(trimmedBody).maxPrimaryMessageSize);
String splitBody = splitMessage.getBody();
if (splitMessage.getTextSlide().isPresent()) {
Slide slide = splitMessage.getTextSlide().get();
uploadRepository.startUpload(new Media(Objects.requireNonNull(slide.getUri()), slide.getContentType(), System.currentTimeMillis(), 0, 0, slide.getFileSize(), 0, slide.isBorderless(), slide.isVideoGif(), Optional.absent(), Optional.absent(), Optional.absent()), recipient);
}
uploadRepository.applyMediaUpdates(oldToNew, recipient);
uploadRepository.updateCaptions(updatedMedia);
uploadRepository.updateDisplayOrder(updatedMedia);
uploadRepository.getPreUploadResults(uploadResults -> {
if (recipients.size() > 0) {
sendMessages(recipients, splitBody, uploadResults, trimmedMentions);
uploadRepository.deleteAbandonedAttachments();
result.postValue(null);
} else {
result.postValue(MediaSendActivityResult.forPreUpload(recipient.getId(), uploadResults, splitBody, transport, isViewOnce(), trimmedMentions));
}
});
});
sentMedia = true;
return result;
}
@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() {
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 LiveData<SentMediaQuality> getSentMediaQuality() {
return sentMediaQuality;
}
@NonNull LiveData<Boolean> getShowMediaQualityToggle() {
return showMediaQualityToggle;
}
@NonNull MediaConstraints getMediaConstraints() {
return mediaConstraints;
}
private void updateViewOnceState() {
if (viewOnceState == ViewOnceState.GONE && viewOnceSupported()) {
showViewOnceTooltipIfNecessary(viewOnceState);
viewOnceState = ViewOnceState.DISABLED;
} else if (!viewOnceSupported()) {
viewOnceState = ViewOnceState.GONE;
}
}
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()));
updateViewOnceState();
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.isSelf()) && 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);
}
}
private void updateAttachmentUploads(@NonNull List<Media> oldMedia, @NonNull List<Media> newMedia) {
if (!preUploadEnabled) return;
DiffHelper.Result<Media> result = DiffHelper.calculate(oldMedia, newMedia);
uploadRepository.cancelUpload(result.getRemoved());
uploadRepository.startUpload(result.getInserted(), recipient);
}
private void cancelUpload(@NonNull Media media) {
uploadRepository.cancelUpload(media);
}
private void startUpload(@NonNull Media media) {
if (!preUploadEnabled) return;
uploadRepository.startUpload(media, recipient);
}
@WorkerThread
private void sendMessages(@NonNull List<Recipient> recipients, @NonNull String body, @NonNull Collection<PreUploadResult> preUploadResults, @NonNull List<Mention> mentions) {
List<OutgoingSecureMediaMessage> messages = new ArrayList<>(recipients.size());
for (Recipient recipient : recipients) {
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
body,
Collections.emptyList(),
System.currentTimeMillis(),
-1,
TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds()),
isViewOnce(),
ThreadDatabase.DistributionTypes.DEFAULT,
null,
Collections.emptyList(),
Collections.emptyList(),
mentions,
Collections.emptyList(),
Collections.emptyList());
messages.add(new OutgoingSecureMediaMessage(message));
// XXX We must do this to avoid sending out messages to the same recipient with the same
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
ThreadUtil.sleep(5);
}
MessageSender.sendMediaBroadcast(application, messages, preUploadResults);
}
private static boolean shouldPreUpload(@NonNull Context context, boolean metered, boolean isSms, @Nullable Recipient recipient) {
return !metered && !isSms && !MessageSender.isLocalSelfSend(context, recipient, isSms);
}
@Override
protected void onCleared() {
if (!sentMedia) {
clearPersistedMedia();
uploadRepository.cancelAllUploads();
uploadRepository.deleteAbandonedAttachments();
}
}
boolean isSms() {
return transport.isSms();
}
enum Error {
ITEM_TOO_LARGE, TOO_MANY_ITEMS, NO_ITEMS, ONLY_ITEM_TOO_LARGE, ONLY_ITEM_IS_INVALID_TYPE, ITEM_TOO_LARGE_OR_INVALID_TYPE
}
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, new MediaUploadRepository(application)));
}
}
}

View File

@@ -45,7 +45,7 @@ import java.util.concurrent.Executor;
* This also means that unlike most repositories, the class itself is stateful. Keep that in mind
* when using it.
*/
class MediaUploadRepository {
public class MediaUploadRepository {
private static final String TAG = Log.tag(MediaUploadRepository.class);
@@ -53,17 +53,17 @@ class MediaUploadRepository {
private final LinkedHashMap<Media, PreUploadResult> uploadResults;
private final Executor executor;
MediaUploadRepository(@NonNull Context context) {
public MediaUploadRepository(@NonNull Context context) {
this.context = context;
this.uploadResults = new LinkedHashMap<>();
this.executor = SignalExecutors.newCachedSingleThreadExecutor("signal-MediaUpload");
}
void startUpload(@NonNull Media media, @Nullable Recipient recipient) {
public void startUpload(@NonNull Media media, @Nullable Recipient recipient) {
executor.execute(() -> uploadMediaInternal(media, recipient));
}
void startUpload(@NonNull Collection<Media> mediaItems, @Nullable Recipient recipient) {
public void startUpload(@NonNull Collection<Media> mediaItems, @Nullable Recipient recipient) {
executor.execute(() -> {
for (Media media : mediaItems) {
cancelUploadInternal(media);
@@ -76,7 +76,7 @@ class MediaUploadRepository {
* Given a map of old->new, cancel medias that were changed and upload their replacements. Will
* also upload any media in the map that wasn't yet uploaded.
*/
void applyMediaUpdates(@NonNull Map<Media, Media> oldToNew, @Nullable Recipient recipient) {
public void applyMediaUpdates(@NonNull Map<Media, Media> oldToNew, @Nullable Recipient recipient) {
executor.execute(() -> {
for (Map.Entry<Media, Media> entry : oldToNew.entrySet()) {
Media oldMedia = entry.getKey();
@@ -101,11 +101,11 @@ class MediaUploadRepository {
return !newProperties.isVideoEdited() && oldProperties.getSentMediaQuality() == newProperties.getSentMediaQuality();
}
void cancelUpload(@NonNull Media media) {
public void cancelUpload(@NonNull Media media) {
executor.execute(() -> cancelUploadInternal(media));
}
void cancelUpload(@NonNull Collection<Media> mediaItems) {
public void cancelUpload(@NonNull Collection<Media> mediaItems) {
executor.execute(() -> {
for (Media media : mediaItems) {
cancelUploadInternal(media);
@@ -113,7 +113,7 @@ class MediaUploadRepository {
});
}
void cancelAllUploads() {
public void cancelAllUploads() {
executor.execute(() -> {
for (Media media : new HashSet<>(uploadResults.keySet())) {
cancelUploadInternal(media);
@@ -121,19 +121,19 @@ class MediaUploadRepository {
});
}
void getPreUploadResults(@NonNull Callback<Collection<PreUploadResult>> callback) {
public void getPreUploadResults(@NonNull Callback<Collection<PreUploadResult>> callback) {
executor.execute(() -> callback.onResult(uploadResults.values()));
}
void updateCaptions(@NonNull List<Media> updatedMedia) {
public void updateCaptions(@NonNull List<Media> updatedMedia) {
executor.execute(() -> updateCaptionsInternal(updatedMedia));
}
void updateDisplayOrder(@NonNull List<Media> mediaInOrder) {
public void updateDisplayOrder(@NonNull List<Media> mediaInOrder) {
executor.execute(() -> updateDisplayOrderInternal(mediaInOrder));
}
void deleteAbandonedAttachments() {
public void deleteAbandonedAttachments() {
executor.execute(() -> {
int deleted = DatabaseFactory.getAttachmentDatabase(context).deleteAbandonedPreuploadedAttachments();
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
@@ -216,7 +216,7 @@ class MediaUploadRepository {
}
}
interface Callback<E> {
public interface Callback<E> {
void onResult(@NonNull E result);
}
}

View File

@@ -1,60 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.core.net.ConnectivityManagerCompat;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.thoughtcrime.securesms.util.ServiceUtil;
/**
* Lifecycle-bound observer for whether or not the active network connection is metered.
*/
class MeteredConnectivityObserver extends BroadcastReceiver implements DefaultLifecycleObserver {
private final Context context;
private final ConnectivityManager connectivityManager;
private final MutableLiveData<Boolean> metered;
@MainThread
MeteredConnectivityObserver(@NonNull Context context, @NonNull LifecycleOwner lifecycleOwner) {
this.context = context;
this.connectivityManager = ServiceUtil.getConnectivityManager(context);
this.metered = new MutableLiveData<>();
this.metered.setValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager));
lifecycleOwner.getLifecycle().addObserver(this);
}
@Override
public void onCreate(@NonNull LifecycleOwner owner) {
context.registerReceiver(this, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
}
@Override
public void onDestroy(@NonNull LifecycleOwner owner) {
context.unregisterReceiver(this);
}
@Override
public void onReceive(Context context, Intent intent) {
metered.postValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager));
}
/**
* @return An observable value that is false when the network is unmetered, and true if the
* network is either metered or unavailable.
*/
@NonNull LiveData<Boolean> isMetered() {
return metered;
}
}

View File

@@ -17,7 +17,7 @@ public final class SentMediaQualityTransform implements MediaTransform {
private final SentMediaQuality sentMediaQuality;
SentMediaQualityTransform(@NonNull SentMediaQuality sentMediaQuality) {
public SentMediaQualityTransform(@NonNull SentMediaQuality sentMediaQuality) {
this.sentMediaQuality = sentMediaQuality;
}

View File

@@ -25,10 +25,10 @@ import org.thoughtcrime.securesms.video.VideoPlayer;
import java.io.IOException;
public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.EventListener,
MediaSendPageFragment {
public class VideoEditorFragment extends Fragment implements VideoEditorHud.EventListener,
MediaSendPageFragment {
private static final String TAG = Log.tag(MediaSendVideoFragment.class);
private static final String TAG = Log.tag(VideoEditorFragment.class);
private static final String KEY_URI = "uri";
private static final String KEY_MAX_OUTPUT = "max_output_size";
@@ -46,14 +46,14 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
@Nullable private VideoEditorHud hud;
private Runnable updatePosition;
public static MediaSendVideoFragment newInstance(@NonNull Uri uri, long maxCompressedVideoSize, long maxAttachmentSize, boolean isVideoGif) {
public static VideoEditorFragment newInstance(@NonNull Uri uri, long maxCompressedVideoSize, long maxAttachmentSize, boolean isVideoGif) {
Bundle args = new Bundle();
args.putParcelable(KEY_URI, uri);
args.putLong(KEY_MAX_OUTPUT, maxCompressedVideoSize);
args.putLong(KEY_MAX_SEND, maxAttachmentSize);
args.putBoolean(KEY_IS_VIDEO_GIF, isVideoGif);
MediaSendVideoFragment fragment = new MediaSendVideoFragment();
VideoEditorFragment fragment = new VideoEditorFragment();
fragment.setArguments(args);
fragment.setUri(uri);
return fragment;
@@ -62,10 +62,13 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (!(getActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement Controller interface.");
if (getActivity() instanceof Controller) {
controller = (Controller) getActivity();
} else if (getParentFragment() instanceof Controller) {
controller = (Controller) getParentFragment();
} else {
throw new IllegalStateException("Parent must implement Controller interface.");
}
controller = (Controller) getActivity();
}
@Override
@@ -116,6 +119,7 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
@Override
public void onPlaying() {
controller.onPlayerReady();
hud.fadePlayButton();
}
@@ -123,6 +127,11 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
public void onStopped() {
hud.showPlayButton();
}
@Override
public void onError() {
controller.onPlayerError();
}
});
}
}
@@ -273,6 +282,10 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
if (!wasEdited && durationEdited) {
controller.onVideoBeginEdit(uri);
}
if (editingComplete) {
controller.onVideoEndEdit(uri);
}
}
@Override
@@ -292,17 +305,47 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
});
}
static class Data {
public static class Data {
boolean durationEdited;
long totalDurationUs;
long startTimeUs;
long endTimeUs;
public boolean isDurationEdited() {
return durationEdited;
}
public @NonNull Bundle getBundle() {
Bundle bundle = new Bundle();
bundle.putByte("EDITED", (byte) (durationEdited ? 1 : 0));
bundle.putLong("TOTAL", totalDurationUs);
bundle.putLong("START", startTimeUs);
bundle.putLong("END", endTimeUs);
return bundle;
}
public static @NonNull Data fromBundle(@NonNull Bundle bundle) {
Data data = new Data();
data.durationEdited = bundle.getByte("EDITED") == (byte) 1;
data.totalDurationUs = bundle.getLong("TOTAL");
data.startTimeUs = bundle.getLong("START");
data.endTimeUs = bundle.getLong("END");
return data;
}
}
public interface Controller {
void onPlayerReady();
void onPlayerError();
void onTouchEventsNeeded(boolean needed);
void onVideoBeginEdit(@NonNull Uri uri);
void onVideoEndEdit(@NonNull Uri uri);
}
}

View File

@@ -11,9 +11,9 @@ import org.whispersystems.libsignal.util.guava.Optional;
public final class VideoTrimTransform implements MediaTransform {
private final MediaSendVideoFragment.Data data;
private final VideoEditorFragment.Data data;
VideoTrimTransform(@NonNull MediaSendVideoFragment.Data data) {
public VideoTrimTransform(@NonNull VideoEditorFragment.Data data) {
this.data = data;
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.view.KeyEvent
sealed class HudCommand {
object StartDraw : HudCommand()
object StartCropAndRotate : HudCommand()
object SaveMedia : HudCommand()
object ResumeEntryTransition : HudCommand()
object OpenEmojiSearch : HudCommand()
object CloseEmojiSearch : HudCommand()
data class EmojiInsert(val emoji: String?) : HudCommand()
data class EmojiKeyEvent(val keyEvent: KeyEvent?) : HudCommand()
}

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.animation.Animator
import android.view.View
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
import org.thoughtcrime.securesms.util.visible
object MediaAnimations {
private const val FADE_ANIMATION_DURATION = 150L
fun fadeIn(view: View) {
if (view.visible) {
return
}
view.visible = true
view.animate()
.setDuration(FADE_ANIMATION_DURATION)
.alpha(1f)
}
fun fadeOut(view: View) {
if (!view.visible) {
return
}
view.animate()
.setDuration(FADE_ANIMATION_DURATION)
.setListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator?) {
view.visible = false
}
})
.alpha(0f)
}
fun fade(view: View, fadeIn: Boolean) {
if (fadeIn) {
fadeIn(view)
} else {
fadeOut(view)
}
}
}

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.net.Uri
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.mediasend.Media
import org.whispersystems.libsignal.util.guava.Optional
object MediaBuilder {
fun buildMedia(
uri: Uri,
mimeType: String = "",
date: Long = 0L,
width: Int = 0,
height: Int = 0,
size: Long = 0L,
duration: Long = 0L,
borderless: Boolean = false,
videoGif: Boolean = false,
bucketId: Optional<String> = Optional.absent(),
caption: Optional<String> = Optional.absent(),
transformProperties: Optional<AttachmentDatabase.TransformProperties> = Optional.absent()
) = Media(uri, mimeType, date, width, height, size, duration, borderless, videoGif, bucketId, caption, transformProperties)
}

View File

@@ -0,0 +1,24 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import org.thoughtcrime.securesms.R
class MediaCountIndicatorButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
init {
inflate(context, R.layout.v2_media_count_indicator_button, this)
}
private val countView: TextView = findViewById(R.id.media_count_indicator_text)
fun setCount(count: Int) {
countView.text = "$count"
}
}

View File

@@ -0,0 +1,266 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.Navigation
import androidx.navigation.fragment.NavHostFragment
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.TransportOption
import org.thoughtcrime.securesms.TransportOptions
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment
import org.thoughtcrime.securesms.recipients.RecipientId
class MediaSelectionActivity :
PassphraseRequiredActivity(),
MediaReviewFragment.Callback,
EmojiKeyboardPageFragment.Callback,
EmojiEventListener,
EmojiSearchFragment.Callback {
lateinit var viewModel: MediaSelectionViewModel
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
super.attachBaseContext(newBase)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContentView(R.layout.fragment_container)
val transportOption: TransportOption = requireNotNull(intent.getParcelableExtra(TRANSPORT_OPTION))
val initialMedia: List<Media> = intent.getParcelableArrayListExtra(MEDIA) ?: listOf()
val destination: MediaSelectionDestination = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION)))
val message: CharSequence? = intent.getCharSequenceExtra(MESSAGE)
val isReply: Boolean = intent.getBooleanExtra(IS_REPLY, false)
val factory = MediaSelectionViewModel.Factory(destination, transportOption, initialMedia, message, isReply, MediaSelectionRepository(this))
viewModel = ViewModelProviders.of(this, factory)[MediaSelectionViewModel::class.java]
if (savedInstanceState == null) {
val navHostFragment = NavHostFragment.create(R.navigation.media)
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, navHostFragment, NAV_HOST_TAG)
.commitNowAllowingStateLoss()
navigateToStartDestination()
} else {
viewModel.onRestoreState(savedInstanceState)
}
onBackPressedDispatcher.addCallback(OnBackPressed())
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewModel.onSaveState(outState)
}
override fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult) {
setResult(
RESULT_OK,
Intent().apply {
putExtra(MediaSendActivityResult.EXTRA_RESULT, mediaSendActivityResult)
}
)
finish()
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom)
}
override fun onSentWithoutResult() {
val intent = Intent()
setResult(RESULT_OK, intent)
finish()
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom)
}
override fun onSendError(error: Throwable) {
setResult(RESULT_CANCELED)
// TODO [alex] - Toast
Log.w(TAG, "Failed to send message.", error)
finish()
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom)
}
override fun onPopFromReview() {
if (isCameraFirst()) {
viewModel.removeCameraFirstCapture()
}
if (!navigateToStartDestination()) {
finish()
}
}
private fun navigateToStartDestination(navHostFragment: NavHostFragment? = null): Boolean {
val hostFragment: NavHostFragment = navHostFragment ?: supportFragmentManager.findFragmentByTag(NAV_HOST_TAG) as NavHostFragment
val startDestination: Int = intent.getIntExtra(START_ACTION, -1)
return if (startDestination > 0) {
hostFragment.navController.navigate(
startDestination,
Bundle().apply {
putBoolean("first", true)
}
)
true
} else {
false
}
}
private fun isCameraFirst(): Boolean = intent.getIntExtra(START_ACTION, -1) == R.id.action_directly_to_mediaCaptureFragment
override fun openEmojiSearch() {
viewModel.sendCommand(HudCommand.OpenEmojiSearch)
}
override fun onEmojiSelected(emoji: String?) {
viewModel.sendCommand(HudCommand.EmojiInsert(emoji))
}
override fun onKeyEvent(keyEvent: KeyEvent?) {
viewModel.sendCommand(HudCommand.EmojiKeyEvent(keyEvent))
}
override fun closeEmojiSearch() {
viewModel.sendCommand(HudCommand.CloseEmojiSearch)
}
private inner class OnBackPressed : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val navController = Navigation.findNavController(this@MediaSelectionActivity, R.id.fragment_container)
if (!navController.popBackStack()) {
finish()
}
}
}
companion object {
private val TAG = Log.tag(MediaSelectionActivity::class.java)
private const val NAV_HOST_TAG = "NAV_HOST"
private const val START_ACTION = "start.action"
private const val TRANSPORT_OPTION = "transport.option"
private const val MEDIA = "media"
private const val MESSAGE = "message"
private const val DESTINATION = "destination"
private const val IS_REPLY = "is_reply"
@JvmStatic
fun camera(context: Context): Intent {
return buildIntent(
context = context,
startAction = R.id.action_directly_to_mediaCaptureFragment
)
}
@JvmStatic
fun camera(
context: Context,
transportOption: TransportOption,
recipientId: RecipientId,
isReply: Boolean
): Intent {
return buildIntent(
context = context,
startAction = R.id.action_directly_to_mediaCaptureFragment,
transportOption = transportOption,
destination = MediaSelectionDestination.SingleRecipient(recipientId),
isReply = isReply
)
}
@JvmStatic
fun gallery(
context: Context,
transportOption: TransportOption,
media: List<Media>,
recipientId: RecipientId,
message: CharSequence?,
isReply: Boolean
): Intent {
return buildIntent(
context = context,
startAction = R.id.action_directly_to_mediaGalleryFragment,
transportOption = transportOption,
media = media,
destination = MediaSelectionDestination.SingleRecipient(recipientId),
message = message,
isReply = isReply
)
}
@JvmStatic
fun editor(
context: Context,
transportOption: TransportOption,
media: List<Media>,
recipientId: RecipientId,
message: CharSequence?
): Intent {
return buildIntent(
context = context,
transportOption = transportOption,
media = media,
destination = MediaSelectionDestination.SingleRecipient(recipientId),
message = message
)
}
@JvmStatic
fun share(
context: Context,
transportOption: TransportOption,
media: List<Media>,
recipientIds: List<RecipientId>,
message: CharSequence?
): Intent {
return buildIntent(
context = context,
transportOption = transportOption,
media = media,
destination = MediaSelectionDestination.MultipleRecipients(recipientIds),
message = message
)
}
private fun buildIntent(
context: Context,
startAction: Int = -1,
transportOption: TransportOption = TransportOptions.getPushTransportOption(context),
media: List<Media> = listOf(),
destination: MediaSelectionDestination = MediaSelectionDestination.ChooseAfterMediaSelection,
message: CharSequence? = null,
isReply: Boolean = false
): Intent {
return Intent(context, MediaSelectionActivity::class.java).apply {
putExtra(START_ACTION, startAction)
putExtra(TRANSPORT_OPTION, transportOption)
putParcelableArrayListExtra(MEDIA, ArrayList(media))
putExtra(MESSAGE, message)
putExtra(DESTINATION, destination.toBundle())
putExtra(IS_REPLY, isReply)
}
}
}
}

View File

@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.os.Bundle
import org.thoughtcrime.securesms.recipients.RecipientId
sealed class MediaSelectionDestination {
object Wallpaper : MediaSelectionDestination() {
override fun toBundle(): Bundle {
return Bundle().apply {
putBoolean(WALLPAPER, true)
}
}
}
object Avatar : MediaSelectionDestination() {
override fun toBundle(): Bundle {
return Bundle().apply {
putBoolean(AVATAR, true)
}
}
}
object ChooseAfterMediaSelection : MediaSelectionDestination() {
override fun toBundle(): Bundle {
return Bundle.EMPTY
}
}
class SingleRecipient(private val id: RecipientId) : MediaSelectionDestination() {
override fun getRecipientId(): RecipientId = id
override fun toBundle(): Bundle {
return Bundle().apply {
putParcelable(RECIPIENT, id)
}
}
}
class MultipleRecipients(val recipientIds: List<RecipientId>) : MediaSelectionDestination() {
override fun getRecipientIdList(): List<RecipientId> = recipientIds
override fun toBundle(): Bundle {
return Bundle().apply {
putParcelableArrayList(RECIPIENT_LIST, ArrayList(recipientIds))
}
}
}
open fun getRecipientId(): RecipientId? = null
open fun getRecipientIdList(): List<RecipientId> = emptyList()
abstract fun toBundle(): Bundle
companion object {
private const val WALLPAPER = "wallpaper"
private const val AVATAR = "avatar"
private const val RECIPIENT = "recipient"
private const val RECIPIENT_LIST = "recipient_list"
fun fromBundle(bundle: Bundle): MediaSelectionDestination {
return when {
bundle.containsKey(WALLPAPER) -> Wallpaper
bundle.containsKey(AVATAR) -> Avatar
bundle.containsKey(RECIPIENT) -> SingleRecipient(requireNotNull(bundle.getParcelable(RECIPIENT)))
bundle.containsKey(RECIPIENT_LIST) -> MultipleRecipients(requireNotNull(bundle.getParcelableArrayList(RECIPIENT_LIST)))
else -> ChooseAfterMediaSelection
}
}
}
}

View File

@@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.Manifest
import android.view.View
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.permissions.Permissions
class MediaSelectionNavigator(
private val toCamera: Int = -1,
private val toGallery: Int = -1
) {
fun goToReview(view: View) {
Navigation.findNavController(view).popBackStack(R.id.mediaReviewFragment, false)
}
fun goToCamera(view: View) {
if (toCamera == -1) return
Navigation.findNavController(view).navigate(toCamera)
}
fun goToGallery(view: View) {
if (toGallery == -1) return
Navigation.findNavController(view).navigate(toGallery)
}
companion object {
fun Fragment.requestPermissionsForCamera(
onGranted: () -> Unit
) {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_camera_24)
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
.onAllGranted(onGranted)
.onAnyDenied { Toast.makeText(requireContext(), R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show() }
.execute()
}
fun Fragment.requestPermissionsForGallery(
onGranted: () -> Unit
) {
Permissions.with(this)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.AttachmentKeyboard_Signal_needs_permission_to_show_your_photos_and_videos))
.onAllGranted(onGranted)
.onAnyDenied { Toast.makeText(this.requireContext(), R.string.AttachmentKeyboard_Signal_needs_permission_to_show_your_photos_and_videos, Toast.LENGTH_LONG).show() }
.execute()
}
}
}

View File

@@ -0,0 +1,208 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.TransportOption
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.imageeditor.model.EditorModel
import org.thoughtcrime.securesms.mediasend.CompositeMediaTransform
import org.thoughtcrime.securesms.mediasend.ImageEditorModelRenderMediaTransform
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.MediaTransform
import org.thoughtcrime.securesms.mediasend.MediaUploadRepository
import org.thoughtcrime.securesms.mediasend.SentMediaQualityTransform
import org.thoughtcrime.securesms.mediasend.VideoEditorFragment
import org.thoughtcrime.securesms.mediasend.VideoTrimTransform
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult
import org.thoughtcrime.securesms.util.MessageUtil
import java.util.ArrayList
import java.util.concurrent.TimeUnit
private val TAG = Log.tag(MediaSelectionRepository::class.java)
class MediaSelectionRepository(context: Context) {
private val context: Context = context.applicationContext
private val mediaRepository = MediaRepository()
val uploadRepository = MediaUploadRepository(context)
val isMetered: Observable<Boolean> = MeteredConnectivity.isMetered(context)
fun populateAndFilterMedia(media: List<Media>, mediaConstraints: MediaConstraints, maxSelection: Int): Single<MediaValidator.FilterResult> {
return Single.fromCallable {
val populatedMedia = mediaRepository.getPopulatedMedia(context, media)
MediaValidator.filterMedia(context, populatedMedia, mediaConstraints, maxSelection)
}.subscribeOn(Schedulers.io())
}
/**
* Tries to send the selected media, performing proper transformations for edited images and videos.
*/
fun send(
selectedMedia: List<Media>,
stateMap: Map<Uri, Any>,
quality: SentMediaQuality,
message: CharSequence?,
isSms: Boolean,
isViewOnce: Boolean,
singleRecipientId: RecipientId?,
recipientIds: List<RecipientId>,
mentions: List<Mention>,
transport: TransportOption
): Maybe<MediaSendActivityResult> {
if (isSms && recipientIds.isNotEmpty()) {
throw IllegalStateException("Provided recipients to send to, but this is SMS!")
}
return Maybe.create<MediaSendActivityResult> { emitter ->
val trimmedBody: String = if (isViewOnce) "" else message?.toString()?.trim() ?: ""
val trimmedMentions: List<Mention> = if (isViewOnce) emptyList() else mentions
val modelsToTransform: Map<Media, MediaTransform> = buildModelsToTransform(selectedMedia, stateMap, quality)
val oldToNewMediaMap: Map<Media, Media> = MediaRepository.transformMediaSync(context, selectedMedia, modelsToTransform)
val updatedMedia = oldToNewMediaMap.values.toList()
for (media in updatedMedia) {
Log.w(TAG, media.uri.toString() + " : " + media.transformProperties.transform { t: TransformProperties -> "" + t.isVideoTrim }.or("null"))
}
val singleRecipient = singleRecipientId?.let { Recipient.resolved(it) }
if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, isSms)) {
Log.i(TAG, "SMS or local self-send. Skipping pre-upload.")
emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(requireNotNull(singleRecipient).id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions))
} else {
val splitMessage = MessageUtil.getSplitMessage(context, trimmedBody, transport.calculateCharacters(trimmedBody).maxPrimaryMessageSize)
val splitBody = splitMessage.body
if (splitMessage.textSlide.isPresent) {
val slide: Slide = splitMessage.textSlide.get()
uploadRepository.startUpload(
MediaBuilder.buildMedia(
uri = requireNotNull(slide.uri),
mimeType = slide.contentType,
date = System.currentTimeMillis(),
size = slide.fileSize,
borderless = slide.isBorderless,
videoGif = slide.isVideoGif
),
singleRecipient
)
}
uploadRepository.applyMediaUpdates(oldToNewMediaMap, singleRecipient)
uploadRepository.updateCaptions(updatedMedia)
uploadRepository.updateDisplayOrder(updatedMedia)
uploadRepository.getPreUploadResults { uploadResults ->
if (recipientIds.isNotEmpty()) {
val recipients = recipientIds.map { Recipient.resolved(it) }
sendMessages(recipients, splitBody, uploadResults, trimmedMentions, isViewOnce)
uploadRepository.deleteAbandonedAttachments()
emitter.onComplete()
} else {
emitter.onSuccess(MediaSendActivityResult.forPreUpload(requireNotNull(singleRecipient).id, uploadResults, splitBody, transport, isViewOnce, trimmedMentions))
}
}
}
}.subscribeOn(Schedulers.io()).cast(MediaSendActivityResult::class.java)
}
fun deleteBlobs(media: List<Media>) {
media
.map(Media::getUri)
.filter(BlobProvider::isAuthority)
.forEach { BlobProvider.getInstance().delete(context, it) }
}
fun cleanUp(selectedMedia: List<Media>) {
deleteBlobs(selectedMedia)
uploadRepository.cancelAllUploads()
uploadRepository.deleteAbandonedAttachments()
}
fun isLocalSelfSend(recipient: Recipient?, isSms: Boolean): Boolean {
return !MessageSender.isLocalSelfSend(context, recipient, isSms)
}
@WorkerThread
private fun buildModelsToTransform(
selectedMedia: List<Media>,
stateMap: Map<Uri, Any>,
quality: SentMediaQuality
): Map<Media, MediaTransform> {
val modelsToRender: MutableMap<Media, MediaTransform> = mutableMapOf()
selectedMedia.forEach {
val state = stateMap[it.uri]
if (state is ImageEditorFragment.Data) {
val model: EditorModel? = state.readModel()
if (model != null && model.isChanged) {
modelsToRender[it] = ImageEditorModelRenderMediaTransform(model)
}
}
if (state is VideoEditorFragment.Data && state.isDurationEdited) {
modelsToRender[it] = VideoTrimTransform(state)
}
if (quality == SentMediaQuality.HIGH) {
val existingTransform: MediaTransform? = modelsToRender[it]
modelsToRender[it] = if (existingTransform == null) {
SentMediaQualityTransform(quality)
} else {
CompositeMediaTransform(existingTransform, SentMediaQualityTransform(quality))
}
}
}
return modelsToRender
}
@WorkerThread
private fun sendMessages(recipients: List<Recipient>, body: String, preUploadResults: Collection<PreUploadResult>, mentions: List<Mention>, isViewOnce: Boolean) {
val messages: MutableList<OutgoingSecureMediaMessage> = ArrayList(recipients.size)
for (recipient in recipients) {
val message = OutgoingMediaMessage(
recipient,
body, emptyList(),
System.currentTimeMillis(),
-1,
TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
isViewOnce,
ThreadDatabase.DistributionTypes.DEFAULT,
null, emptyList(), emptyList(),
mentions, emptyList(), emptyList()
)
messages.add(OutgoingSecureMediaMessage(message))
// XXX We must do this to avoid sending out messages to the same recipient with the same
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
ThreadUtil.sleep(5)
}
MessageSender.sendMediaBroadcast(context, messages, preUploadResults)
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.net.Uri
import org.thoughtcrime.securesms.TransportOption
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendConstants
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.recipients.Recipient
data class MediaSelectionState(
val transportOption: TransportOption,
val selectedMedia: List<Media> = listOf(),
val focusedMedia: Media? = null,
val recipient: Recipient? = null,
val quality: SentMediaQuality = SentMediaQuality.STANDARD,
val message: CharSequence? = null,
val viewOnceToggleState: ViewOnceToggleState = ViewOnceToggleState.INFINITE,
val isTouchEnabled: Boolean = true,
val isSent: Boolean = false,
val isPreUploadEnabled: Boolean = false,
val isMeteredConnection: Boolean = false,
val editorStateMap: Map<Uri, Any> = mapOf(),
val cameraFirstCapture: Media? = null
) {
val maxSelection = if (transportOption.isSms) {
MediaSendConstants.MAX_SMS
} else {
MediaSendConstants.MAX_PUSH
}
enum class ViewOnceToggleState(val code: Int) {
INFINITE(0),
ONCE(1);
fun next(): ViewOnceToggleState {
return when (this) {
INFINITE -> ONCE
ONCE -> INFINITE
}
}
companion object {
fun fromCode(code: Int): ViewOnceToggleState {
return when (code) {
1 -> ONCE
else -> INFINITE
}
}
}
}
}

View File

@@ -0,0 +1,363 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.net.Uri
import android.os.Bundle
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import org.thoughtcrime.securesms.TransportOption
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.VideoEditorFragment
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.livedata.Store
/**
* ViewModel which maintains the list of selected media and other shared values.
*/
class MediaSelectionViewModel(
val destination: MediaSelectionDestination,
transportOption: TransportOption,
initialMedia: List<Media>,
initialMessage: CharSequence?,
val isReply: Boolean,
private val repository: MediaSelectionRepository
) : ViewModel() {
private val store: Store<MediaSelectionState> = Store(
MediaSelectionState(
transportOption = transportOption,
message = initialMessage
)
)
val isContactSelectionRequired = destination == MediaSelectionDestination.ChooseAfterMediaSelection
val state: LiveData<MediaSelectionState> = store.stateLiveData
private val internalHudCommands = PublishSubject.create<HudCommand>()
private val internalFilterErrors = PublishSubject.create<MediaValidator.FilterError>()
val hudCommands: Observable<HudCommand> = internalHudCommands
private val disposables = CompositeDisposable()
private val isMeteredDisposable: Disposable = repository.isMetered.subscribe { metered ->
store.update {
it.copy(
isMeteredConnection = metered,
isPreUploadEnabled = shouldPreUpload(metered, it.transportOption.isSms, it.recipient)
)
}
}
init {
val recipientId = destination.getRecipientId()
if (recipientId != null) {
store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(recipient = r) }
}
if (initialMedia.isNotEmpty()) {
addMedia(initialMedia)
}
}
override fun onCleared() {
isMeteredDisposable.dispose()
disposables.clear()
}
fun kick() {
store.update { it }
}
fun sendCommand(hudCommand: HudCommand) {
internalHudCommands.onNext(hudCommand)
}
fun setTouchEnabled(isEnabled: Boolean) {
store.update { it.copy(isTouchEnabled = isEnabled) }
}
fun addMedia(media: Media) {
addMedia(listOf(media))
}
private fun addMedia(media: List<Media>) {
val newSelectionList: List<Media> = linkedSetOf<Media>().apply {
addAll(store.state.selectedMedia)
addAll(media)
}.toList()
disposables.add(
repository
.populateAndFilterMedia(newSelectionList, getMediaConstraints(), store.state.maxSelection)
.subscribe { filterResult ->
if (filterResult.filteredMedia.isNotEmpty()) {
store.update {
it.copy(
selectedMedia = filterResult.filteredMedia,
focusedMedia = it.focusedMedia ?: filterResult.filteredMedia.first()
)
}
val newMedia = filterResult.filteredMedia.toSet().intersect(media).toList()
startUpload(newMedia)
}
if (filterResult.filterError != null) {
internalFilterErrors.onNext(filterResult.filterError)
}
}
)
}
fun removeMedia(media: Media) {
val snapshot = store.state
val newMediaList = snapshot.selectedMedia - media
val oldFocusIndex = snapshot.selectedMedia.indexOf(media)
val newFocus = when {
newMediaList.isEmpty() -> null
media == snapshot.focusedMedia -> newMediaList[Util.clamp(oldFocusIndex, 0, newMediaList.size - 1)]
else -> snapshot.focusedMedia
}
store.update {
it.copy(
selectedMedia = newMediaList,
focusedMedia = newFocus,
editorStateMap = it.editorStateMap - media.uri,
cameraFirstCapture = if (media == it.cameraFirstCapture) null else it.cameraFirstCapture
)
}
if (newMediaList.isEmpty()) {
internalFilterErrors.onNext(MediaValidator.FilterError.NO_ITEMS)
}
repository.deleteBlobs(listOf(media))
cancelUpload(media)
}
fun addCameraFirstCapture(media: Media) {
store.update { state ->
state.copy(cameraFirstCapture = media)
}
addMedia(media)
}
fun removeCameraFirstCapture() {
val cameraFirstCapture: Media? = store.state.cameraFirstCapture
if (cameraFirstCapture != null) {
removeMedia(cameraFirstCapture)
}
}
fun setFocusedMedia(media: Media) {
store.update { it.copy(focusedMedia = media) }
}
fun setFocusedMedia(position: Int) {
store.update {
if (position >= it.selectedMedia.size) {
it.copy(focusedMedia = null)
} else {
it.copy(focusedMedia = it.selectedMedia[position])
}
}
}
fun getMediaConstraints(): MediaConstraints {
return if (store.state.transportOption.isSms) {
MediaConstraints.getMmsMediaConstraints(store.state.transportOption.simSubscriptionId.or(-1))
} else {
MediaConstraints.getPushMediaConstraints()
}
}
fun setSentMediaQuality(sentMediaQuality: SentMediaQuality) {
if (sentMediaQuality == store.state.quality) {
return
}
store.update { it.copy(quality = sentMediaQuality, isPreUploadEnabled = false) }
repository.uploadRepository.cancelAllUploads()
}
fun setMessage(text: CharSequence?) {
store.update { it.copy(message = text) }
}
fun incrementViewOnceState() {
store.update { it.copy(viewOnceToggleState = it.viewOnceToggleState.next()) }
}
fun getEditorState(uri: Uri): Any? {
return store.state.editorStateMap[uri]
}
fun setEditorState(uri: Uri, state: Any) {
store.update {
it.copy(editorStateMap = it.editorStateMap + (uri to state))
}
}
fun onVideoBeginEdit(uri: Uri) {
cancelUpload(MediaBuilder.buildMedia(uri))
}
fun send(
selectedRecipientIds: List<RecipientId> = emptyList(),
): Maybe<MediaSendActivityResult> {
return repository.send(
store.state.selectedMedia,
store.state.editorStateMap,
store.state.quality,
store.state.message,
store.state.transportOption.isSms,
isViewOnceEnabled(),
destination.getRecipientId(),
if (selectedRecipientIds.isNotEmpty()) selectedRecipientIds else destination.getRecipientIdList(),
emptyList(), // TODO [alex] -- mentions
store.state.transportOption
)
}
private fun isViewOnceEnabled(): Boolean {
return !store.state.transportOption.isSms &&
store.state.selectedMedia.size == 1 &&
store.state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE
}
private fun startUpload(media: List<Media>) {
if (!store.state.isPreUploadEnabled) {
return
}
repository.uploadRepository.startUpload(media, store.state.recipient)
}
private fun cancelUpload(media: Media) {
repository.uploadRepository.cancelUpload(media)
}
private fun shouldPreUpload(metered: Boolean, isSms: Boolean, recipient: Recipient?): Boolean {
return !metered && !isSms && !repository.isLocalSelfSend(recipient, isSms)
}
fun onSaveState(outState: Bundle) {
val snapshot = store.state
outState.putParcelableArrayList(STATE_SELECTION, ArrayList(snapshot.selectedMedia))
outState.putParcelable(STATE_FOCUSED, snapshot.focusedMedia)
outState.putInt(STATE_QUALITY, snapshot.quality.code)
outState.putCharSequence(STATE_MESSAGE, snapshot.message)
outState.putInt(STATE_VIEW_ONCE, snapshot.viewOnceToggleState.code)
outState.putBoolean(STATE_TOUCH_ENABLED, snapshot.isTouchEnabled)
outState.putBoolean(STATE_SENT, snapshot.isSent)
outState.putParcelable(STATE_CAMERA_FIRST_CAPTURE, snapshot.cameraFirstCapture)
val editorStates: List<Bundle> = store.state.editorStateMap.entries.map { it.toBundleStateEntry() }
outState.putParcelableArrayList(STATE_EDITORS, ArrayList(editorStates))
}
fun onRestoreState(savedInstanceState: Bundle) {
val selection: List<Media> = savedInstanceState.getParcelableArrayList(STATE_SELECTION) ?: emptyList()
val focused: Media? = savedInstanceState.getParcelable(STATE_FOCUSED)
val quality: SentMediaQuality = SentMediaQuality.fromCode(savedInstanceState.getInt(STATE_QUALITY))
val message: CharSequence? = savedInstanceState.getCharSequence(STATE_MESSAGE)
val viewOnce: MediaSelectionState.ViewOnceToggleState = MediaSelectionState.ViewOnceToggleState.fromCode(savedInstanceState.getInt(STATE_VIEW_ONCE))
val touchEnabled: Boolean = savedInstanceState.getBoolean(STATE_TOUCH_ENABLED)
val sent: Boolean = savedInstanceState.getBoolean(STATE_SENT)
val cameraFirstCapture: Media? = savedInstanceState.getParcelable(STATE_CAMERA_FIRST_CAPTURE)
val editorStates: List<Bundle> = savedInstanceState.getParcelableArrayList(STATE_EDITORS) ?: emptyList()
val editorStateMap = editorStates.associate { it.toAssociation() }
store.update { state ->
state.copy(
selectedMedia = selection,
focusedMedia = focused,
quality = quality,
message = message,
viewOnceToggleState = viewOnce,
isTouchEnabled = touchEnabled,
isSent = sent,
cameraFirstCapture = cameraFirstCapture,
editorStateMap = editorStateMap
)
}
}
private fun Bundle.toAssociation(): Pair<Uri, Any> {
val key: Uri = requireNotNull(getParcelable(BUNDLE_URI))
val value: Any = if (getBoolean(BUNDLE_IS_IMAGE)) {
ImageEditorFragment.Data(this)
} else {
VideoEditorFragment.Data.fromBundle(this)
}
return key to value
}
private fun Map.Entry<Uri, Any>.toBundleStateEntry(): Bundle {
return when (val value = this.value) {
is ImageEditorFragment.Data -> {
value.bundle.apply {
putParcelable(BUNDLE_URI, key)
putBoolean(BUNDLE_IS_IMAGE, true)
}
}
is VideoEditorFragment.Data -> {
value.bundle.apply {
putParcelable(BUNDLE_URI, key)
putBoolean(BUNDLE_IS_IMAGE, false)
}
}
else -> {
throw IllegalStateException()
}
}
}
companion object {
private const val STATE_PREFIX = "selection.view.model"
private const val BUNDLE_URI = "$STATE_PREFIX.uri"
private const val BUNDLE_IS_IMAGE = "$STATE_PREFIX.is_image"
private const val STATE_SELECTION = "$STATE_PREFIX.selection"
private const val STATE_FOCUSED = "$STATE_PREFIX.focused"
private const val STATE_QUALITY = "$STATE_PREFIX.quality"
private const val STATE_MESSAGE = "$STATE_PREFIX.message"
private const val STATE_VIEW_ONCE = "$STATE_PREFIX.viewOnce"
private const val STATE_TOUCH_ENABLED = "$STATE_PREFIX.touchEnabled"
private const val STATE_SENT = "$STATE_PREFIX.sent"
private const val STATE_CAMERA_FIRST_CAPTURE = "$STATE_PREFIX.camera_first_capture"
private const val STATE_EDITORS = "$STATE_PREFIX.editors"
}
class Factory(
private val destination: MediaSelectionDestination,
private val transportOption: TransportOption,
private val initialMedia: List<Media>,
private val initialMessage: CharSequence?,
private val isReply: Boolean,
private val repository: MediaSelectionRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MediaSelectionViewModel(destination, transportOption, initialMedia, initialMessage, isReply, repository)))
}
}
}

View File

@@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.content.Context
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.Util
object MediaValidator {
fun filterMedia(context: Context, media: List<Media>, mediaConstraints: MediaConstraints, maxSelection: Int): FilterResult {
val filteredMedia = filterForValidMedia(context, media, mediaConstraints)
val isAllMediaValid = filteredMedia.size == media.size
var error: FilterError? = null
if (!isAllMediaValid) {
error = if (media.all { MediaUtil.isImageOrVideoType(it.mimeType) }) {
FilterError.ITEM_TOO_LARGE
} else {
FilterError.ITEM_INVALID_TYPE
}
}
if (filteredMedia.size > maxSelection) {
error = FilterError.TOO_MANY_ITEMS
}
val truncatedMedia = filteredMedia.take(maxSelection)
val bucketId = if (truncatedMedia.isNotEmpty()) {
truncatedMedia.drop(1).fold(truncatedMedia.first().bucketId.or(Media.ALL_MEDIA_BUCKET_ID)) { acc, media ->
if (Util.equals(acc, media.bucketId.or(Media.ALL_MEDIA_BUCKET_ID))) {
acc
} else {
Media.ALL_MEDIA_BUCKET_ID
}
}
} else {
Media.ALL_MEDIA_BUCKET_ID
}
if (truncatedMedia.isEmpty()) {
error = FilterError.NO_ITEMS
}
return FilterResult(truncatedMedia, error, bucketId)
}
private fun filterForValidMedia(context: Context, media: List<Media>, mediaConstraints: MediaConstraints): List<Media> {
return media
.filter { m -> isSupportedMediaType(m.mimeType) }
.filter { m ->
MediaUtil.isImageAndNotGif(m.mimeType) || isValidGif(context, m, mediaConstraints) || isValidVideo(context, m, mediaConstraints)
}
}
private fun isValidGif(context: Context, media: Media, mediaConstraints: MediaConstraints): Boolean {
return MediaUtil.isGif(media.mimeType) && media.size < mediaConstraints.getGifMaxSize(context)
}
private fun isValidVideo(context: Context, media: Media, mediaConstraints: MediaConstraints): Boolean {
return MediaUtil.isVideoType(media.mimeType) && media.size < mediaConstraints.getUncompressedVideoMaxSize(context)
}
private fun isSupportedMediaType(mimeType: String): Boolean {
return MediaUtil.isGif(mimeType) || MediaUtil.isImageType(mimeType) || MediaUtil.isVideoType(mimeType)
}
data class FilterResult(val filteredMedia: List<Media>, val filterError: FilterError?, val bucketId: String?)
enum class FilterError {
ITEM_TOO_LARGE,
ITEM_INVALID_TYPE,
TOO_MANY_ITEMS,
NO_ITEMS
}
}

View File

@@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import androidx.core.net.ConnectivityManagerCompat
import io.reactivex.rxjava3.core.Observable
import org.thoughtcrime.securesms.util.ServiceUtil
object MeteredConnectivity {
fun isMetered(context: Context): Observable<Boolean> = Observable.create { emitter ->
val connectivityManager = ServiceUtil.getConnectivityManager(context)
emitter.onNext(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager))
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
emitter.onNext(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager))
}
}
context.registerReceiver(receiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
emitter.setCancellable {
context.unregisterReceiver(receiver)
}
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import org.thoughtcrime.securesms.mediasend.Media
sealed class MediaCaptureEvent {
data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent()
object MediaCaptureRenderFailed : MediaCaptureEvent()
}

View File

@@ -0,0 +1,157 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import app.cash.exhaustive.Exhaustive
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.CameraFragment
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator.Companion.requestPermissionsForGallery
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.permissions.Permissions
import org.whispersystems.libsignal.util.guava.Optional
import java.io.FileDescriptor
private val TAG = Log.tag(MediaCaptureFragment::class.java)
/**
* Fragment which displays the proper camera fragment.
*/
class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragment.Controller {
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val viewModel: MediaCaptureViewModel by viewModels(
factoryProducer = { MediaCaptureViewModel.Factory(MediaCaptureRepository(requireContext())) }
)
private lateinit var captureChildFragment: CameraFragment
private lateinit var navigator: MediaSelectionNavigator
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
captureChildFragment = CameraFragment.newInstance() as CameraFragment
navigator = MediaSelectionNavigator(
toGallery = R.id.action_mediaCaptureFragment_to_mediaGalleryFragment
)
childFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, captureChildFragment as Fragment)
.commitNowAllowingStateLoss()
viewModel.events.observe(viewLifecycleOwner) { event ->
@Exhaustive
when (event) {
MediaCaptureEvent.MediaCaptureRenderFailed -> {
Log.w(TAG, "Failed to render captured media.")
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
}
is MediaCaptureEvent.MediaCaptureRendered -> {
captureChildFragment.fadeOutControls {
if (isFirst()) {
sharedViewModel.addCameraFirstCapture(event.media)
} else {
sharedViewModel.addMedia(event.media)
}
navigator.goToReview(view)
}
}
}
}
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
captureChildFragment.presentHud(state.selectedMedia.size)
}
if (isFirst()) {
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
requireActivity().finish()
}
}
)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun onResume() {
super.onResume()
captureChildFragment.fadeInControls()
}
override fun onCameraError() {
Log.w(TAG, "Camera Error.")
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
}
override fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
viewModel.onImageCaptured(data, width, height)
}
override fun onVideoCaptured(fd: FileDescriptor) {
viewModel.onVideoCaptured(fd)
}
override fun onVideoCaptureError() {
Log.w(TAG, "Video capture error.")
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
}
override fun onGalleryClicked() {
requestPermissionsForGallery {
captureChildFragment.fadeOutControls {
navigator.goToGallery(requireView())
}
}
}
override fun getDisplayRotation(): Int {
return if (Build.VERSION.SDK_INT >= 30) {
requireContext().display?.rotation ?: 0
} else {
@Suppress("DEPRECATION")
requireActivity().windowManager.defaultDisplay.rotation
}
}
override fun onCameraCountButtonClicked() {
captureChildFragment.fadeOutControls {
navigator.goToReview(requireView())
}
}
override fun getMostRecentMediaItem(): LiveData<Optional<Media>> {
return viewModel.getMostRecentMedia()
}
override fun getMediaConstraints(): MediaConstraints {
return sharedViewModel.getMediaConstraints()
}
private fun isFirst(): Boolean {
return arguments?.getBoolean("first") == true
}
companion object {
const val CAPTURE_RESULT = "capture_result"
const val CAPTURE_RESULT_OK = "capture_result_ok"
}
}

View File

@@ -0,0 +1,185 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.WorkerThread
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.CursorUtil
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.StorageUtil
import org.thoughtcrime.securesms.video.VideoUtil
import org.whispersystems.libsignal.util.guava.Optional
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.IOException
import java.util.LinkedList
private val TAG = Log.tag(MediaCaptureRepository::class.java)
class MediaCaptureRepository(context: Context) {
private val context: Context = context.applicationContext
fun getMostRecentItem(callback: (Media?) -> Unit) {
if (!StorageUtil.canReadFromMediaStore()) {
Log.w(TAG, "Cannot read from storage.")
callback(null)
}
SignalExecutors.BOUNDED.execute {
val media: List<Media> = getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
callback(media.firstOrNull())
}
}
fun renderImageToMedia(data: ByteArray, width: Int, height: Int, onMediaRendered: (Media) -> Unit, onFailedToRender: () -> Unit) {
SignalExecutors.BOUNDED.execute {
val media: Media? = renderCaptureToMedia(
dataSupplier = { data },
getLength = { data.size.toLong() },
createBlobBuilder = { blobProvider, bytes, _ -> blobProvider.forData(bytes) },
mimeType = MediaUtil.IMAGE_JPEG,
width = width,
height = height
)
if (media != null) {
onMediaRendered(media)
} else {
onFailedToRender()
}
}
}
fun renderVideoToMedia(fileDescriptor: FileDescriptor, onMediaRendered: (Media) -> Unit, onFailedToRender: () -> Unit) {
SignalExecutors.BOUNDED.execute {
val media: Media? = renderCaptureToMedia(
dataSupplier = { FileInputStream(fileDescriptor) },
getLength = { it.channel.size() },
createBlobBuilder = BlobProvider::forData,
mimeType = VideoUtil.RECORDED_VIDEO_CONTENT_TYPE,
width = 0,
height = 0
)
if (media != null) {
onMediaRendered(media)
} else {
onFailedToRender()
}
}
}
private fun <T> renderCaptureToMedia(
dataSupplier: () -> T,
getLength: (T) -> Long,
createBlobBuilder: (BlobProvider, T, Long) -> BlobProvider.BlobBuilder,
mimeType: String,
width: Int,
height: Int
): Media? {
return try {
val data: T = dataSupplier()
val length: Long = getLength(data)
val uri: Uri = createBlobBuilder(BlobProvider.getInstance(), data, length)
.withMimeType(mimeType)
.createForSingleSessionOnDisk(context)
Media(
uri,
mimeType,
System.currentTimeMillis(),
width,
height,
length,
0,
false,
false,
Optional.of(Media.ALL_MEDIA_BUCKET_ID),
Optional.absent(),
Optional.absent()
)
} catch (e: IOException) {
return null
}
}
companion object {
@SuppressLint("VisibleForTests")
@WorkerThread
private fun getMediaInBucket(context: Context, bucketId: String, contentUri: Uri, isImage: Boolean): List<Media> {
val media: MutableList<Media> = LinkedList()
var selection: String? = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending()
var selectionArgs: Array<String>? = arrayOf(bucketId)
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
val projection: Array<String> = if (isImage) {
arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.ORIENTATION, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.SIZE)
} else {
arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.SIZE, MediaStore.Video.Media.DURATION)
}
if (Media.ALL_MEDIA_BUCKET_ID == bucketId) {
selection = isNotPending()
selectionArgs = null
}
context.contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val rowId = CursorUtil.requireLong(cursor, projection[0])
val uri = ContentUris.withAppendedId(contentUri, rowId)
val mimetype = CursorUtil.requireString(cursor, MediaStore.Images.Media.MIME_TYPE)
val date = CursorUtil.requireLong(cursor, MediaStore.Images.Media.DATE_MODIFIED)
val orientation = if (isImage) CursorUtil.requireInt(cursor, MediaStore.Images.Media.ORIENTATION) else 0
val width = CursorUtil.requireInt(cursor, getWidthColumn(orientation))
val height = CursorUtil.requireInt(cursor, getHeightColumn(orientation))
val size = CursorUtil.requireLong(cursor, MediaStore.Images.Media.SIZE)
val duration = if (!isImage) CursorUtil.requireInt(cursor, MediaStore.Video.Media.DURATION).toLong() else 0.toLong()
media.add(
MediaRepository.fixMimeType(
context,
Media(
uri,
mimetype,
date,
width,
height,
size,
duration,
false,
false,
Optional.of(bucketId),
Optional.absent(),
Optional.absent()
)
)
)
}
}
return media
}
private fun getWidthColumn(orientation: Int): String {
return if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT
}
private fun getHeightColumn(orientation: Int): String {
return if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
}
@Suppress("DEPRECATION")
private fun isNotPending(): String {
return if (Build.VERSION.SDK_INT <= 28) MediaStore.Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1"
}
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import org.thoughtcrime.securesms.mediasend.Media
data class MediaCaptureState(
val mostRecentMedia: Media? = null
)

View File

@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
import java.io.FileDescriptor
class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : ViewModel() {
private val store: Store<MediaCaptureState> = Store(MediaCaptureState())
private val internalEvents: SingleLiveEvent<MediaCaptureEvent> = SingleLiveEvent()
val events: LiveData<MediaCaptureEvent> = internalEvents
init {
repository.getMostRecentItem { media ->
store.update { state ->
state.copy(mostRecentMedia = media)
}
}
}
fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
repository.renderImageToMedia(data, width, height, this::onMediaRendered, this::onMediaRenderFailed)
}
fun onVideoCaptured(fd: FileDescriptor) {
repository.renderVideoToMedia(fd, this::onMediaRendered, this::onMediaRenderFailed)
}
fun getMostRecentMedia(): LiveData<Optional<Media>> {
return Transformations.map(store.stateLiveData) { Optional.fromNullable(it.mostRecentMedia) }
}
private fun onMediaRendered(media: Media) {
internalEvents.postValue(MediaCaptureEvent.MediaCaptureRendered(media))
}
private fun onMediaRenderFailed() {
internalEvents.postValue(MediaCaptureEvent.MediaCaptureRenderFailed)
}
class Factory(private val repository: MediaCaptureRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MediaCaptureViewModel(repository)))
}
}
}

View File

@@ -0,0 +1,154 @@
package org.thoughtcrime.securesms.mediasend.v2.gallery
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.keyboard.findListener
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.visible
/**
* Displays a collection of files and folders to the user to allow them to select
* media to send.
*/
class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
private val viewModel: MediaGalleryViewModel by viewModels(
factoryProducer = { MediaGalleryViewModel.Factory(null, null, MediaGalleryRepository(requireContext(), MediaRepository())) }
)
private lateinit var callbacks: Callbacks
private lateinit var toolbar: Toolbar
private lateinit var galleryRecycler: RecyclerView
private lateinit var countButton: MediaCountIndicatorButton
private lateinit var bottomBarGroup: View
private lateinit var selectedRecycler: RecyclerView
private val galleryAdapter = MappingAdapter()
private val selectedAdapter = MappingAdapter()
private val viewStateLiveData = MutableLiveData(ViewState())
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
callbacks = requireNotNull(findListener())
toolbar = view.findViewById(R.id.media_gallery_toolbar)
galleryRecycler = view.findViewById(R.id.media_gallery_grid)
selectedRecycler = view.findViewById(R.id.media_gallery_selected)
countButton = view.findViewById(R.id.media_gallery_count_button)
bottomBarGroup = view.findViewById(R.id.media_gallery_bottom_bar_group)
(galleryRecycler.layoutManager as GridLayoutManager).spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val isFolder: Boolean = (galleryRecycler.adapter as MappingAdapter).getModel(position).map { it is MediaGallerySelectableItem.FolderModel }.orElse(false)
return if (isFolder) 2 else 1
}
}
toolbar.setNavigationOnClickListener {
if (viewModel.pop()) {
callbacks.onToolbarNavigationClicked()
}
}
toolbar.setOnMenuItemClickListener { item ->
if (item.itemId == R.id.action_camera) {
callbacks.onNavigateToCamera()
true
} else {
false
}
}
countButton.setOnClickListener {
callbacks.onSubmit()
}
MediaGallerySelectedItem.register(selectedAdapter) { media ->
callbacks.onSelectedMediaClicked(media)
}
selectedRecycler.adapter = selectedAdapter
MediaGallerySelectableItem.registerAdapter(
mappingAdapter = galleryAdapter,
onMediaFolderClicked = {
viewModel.setMediaFolder(it)
},
onMediaClicked = { media, selected ->
if (selected) {
callbacks.onMediaUnselected(media)
} else {
callbacks.onMediaSelected(media)
}
},
callbacks.isMultiselectEnabled()
)
galleryRecycler.adapter = galleryAdapter
galleryRecycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(2)))
viewStateLiveData.observe(viewLifecycleOwner) { state ->
bottomBarGroup.visible = state.selectedMedia.isNotEmpty()
countButton.setCount(state.selectedMedia.size)
selectedAdapter.submitList(state.selectedMedia.map { MediaGallerySelectedItem.Model(it) }) {
if (state.selectedMedia.isNotEmpty()) {
selectedRecycler.smoothScrollToPosition(state.selectedMedia.size - 1)
}
}
}
viewModel.state.observe(viewLifecycleOwner) { state ->
toolbar.title = state.bucketTitle
}
val galleryItemsWithSelection = LiveDataUtil.combineLatest(
Transformations.map(viewModel.state) { it.items },
Transformations.map(viewStateLiveData) { it.selectedMedia }
) { galleryItems, selectedMedia ->
galleryItems.map {
if (it is MediaGallerySelectableItem.FileModel) {
it.copy(isSelected = selectedMedia.contains(it.media))
} else {
it
}
}
}
galleryItemsWithSelection.observe(viewLifecycleOwner) {
galleryAdapter.submitList(it)
}
}
fun onViewStateUpdated(state: ViewState) {
viewStateLiveData.value = state
}
data class ViewState(
val selectedMedia: List<Media> = listOf()
)
interface Callbacks {
fun isMultiselectEnabled(): Boolean = false
fun onMediaSelected(media: Media)
fun onMediaUnselected(media: Media): Unit = throw UnsupportedOperationException()
fun onSelectedMediaClicked(media: Media): Unit = throw UnsupportedOperationException()
fun onNavigateToCamera(): Unit = throw UnsupportedOperationException()
fun onSubmit(): Unit = throw UnsupportedOperationException()
fun onToolbarNavigationClicked(): Unit = throw UnsupportedOperationException()
}
}

View File

@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.mediasend.v2.gallery
import android.content.Context
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaFolder
import org.thoughtcrime.securesms.mediasend.MediaRepository
class MediaGalleryRepository(context: Context, private val mediaRepository: MediaRepository) {
private val context: Context = context.applicationContext
fun getFolders(onFoldersRetrieved: (List<MediaFolder>) -> Unit) {
mediaRepository.getFolders(context) { onFoldersRetrieved(it) }
}
fun getMedia(bucketId: String, onMediaRetrieved: (List<Media>) -> Unit) {
mediaRepository.getMediaInBucket(context, bucketId) { onMediaRetrieved(it) }
}
}

View File

@@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.mediasend.v2.gallery
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaFolder
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.visible
typealias OnMediaFolderClicked = (MediaFolder) -> Unit
typealias OnMediaClicked = (Media, Boolean) -> Unit
object MediaGallerySelectableItem {
fun registerAdapter(
mappingAdapter: MappingAdapter,
onMediaFolderClicked: OnMediaFolderClicked,
onMediaClicked: OnMediaClicked,
isMultiselectEnabled: Boolean
) {
mappingAdapter.registerFactory(FolderModel::class.java, MappingAdapter.LayoutFactory({ FolderViewHolder(it, onMediaFolderClicked) }, R.layout.v2_media_gallery_folder_item))
mappingAdapter.registerFactory(FileModel::class.java, MappingAdapter.LayoutFactory({ FileViewHolder(it, onMediaClicked) }, if (isMultiselectEnabled) R.layout.v2_media_gallery_item else R.layout.v2_media_gallery_item_no_check))
}
class FolderModel(val mediaFolder: MediaFolder) : MappingModel<FolderModel> {
override fun areItemsTheSame(newItem: FolderModel): Boolean {
return mediaFolder.bucketId == newItem.mediaFolder.bucketId
}
override fun areContentsTheSame(newItem: FolderModel): Boolean {
return mediaFolder.bucketId == newItem.mediaFolder.bucketId &&
mediaFolder.thumbnailUri == newItem.mediaFolder.thumbnailUri
}
}
abstract class BaseViewHolder<T : MappingModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
protected val imageView: ImageView = itemView.findViewById(R.id.media_gallery_image)
protected val playOverlay: ImageView = itemView.findViewById(R.id.media_gallery_play_overlay)
protected val checkView: ImageView? = itemView.findViewById(R.id.media_gallery_check)
protected val title: TextView? = itemView.findViewById(R.id.media_gallery_title)
init {
(itemView as AspectRatioFrameLayout).setAspectRatio(1f)
}
}
class FolderViewHolder(itemView: View, private val onMediaFolderClicked: OnMediaFolderClicked) : BaseViewHolder<FolderModel>(itemView) {
override fun bind(model: FolderModel) {
GlideApp.with(imageView)
.load(DecryptableStreamUriLoader.DecryptableUri(model.mediaFolder.thumbnailUri))
.into(imageView)
playOverlay.visible = false
itemView.setOnClickListener { onMediaFolderClicked(model.mediaFolder) }
title?.text = model.mediaFolder.title
title?.visible = true
}
}
data class FileModel(val media: Media, val isSelected: Boolean) : MappingModel<FileModel> {
override fun areItemsTheSame(newItem: FileModel): Boolean {
return newItem.media == media
}
override fun areContentsTheSame(newItem: FileModel): Boolean {
return newItem.media == media && isSelected == newItem.isSelected
}
}
class FileViewHolder(itemView: View, private val onMediaClicked: OnMediaClicked) : BaseViewHolder<FileModel>(itemView) {
override fun bind(model: FileModel) {
GlideApp.with(imageView)
.load(DecryptableStreamUriLoader.DecryptableUri(model.media.uri))
.into(imageView)
checkView?.isSelected = model.isSelected
playOverlay.visible = MediaUtil.isVideo(model.media.mimeType) && !model.media.isVideoGif
itemView.setOnClickListener { onMediaClicked(model.media, model.isSelected) }
title?.visible = false
}
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.mediasend.v2.gallery
import android.view.View
import android.widget.ImageView
import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.visible
typealias OnSelectedMediaClicked = (Media) -> Unit
object MediaGallerySelectedItem {
fun register(mappingAdapter: MappingAdapter, onSelectedMediaClicked: OnSelectedMediaClicked) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onSelectedMediaClicked) }, R.layout.v2_media_selection_item))
}
class Model(val media: Media) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return media.uri == newItem.media.uri
}
override fun areContentsTheSame(newItem: Model): Boolean {
return media.uri == newItem.media.uri
}
}
class ViewHolder(itemView: View, private val onSelectedMediaClicked: OnSelectedMediaClicked) : MappingViewHolder<Model>(itemView) {
private val imageView: ImageView = itemView.findViewById(R.id.media_selection_image)
private val videoOverlay: ImageView = itemView.findViewById(R.id.media_selection_play_overlay)
override fun bind(model: Model) {
Glide.with(imageView)
.load(DecryptableStreamUriLoader.DecryptableUri(model.media.uri))
.centerCrop()
.into(imageView)
videoOverlay.visible = MediaUtil.isVideo(model.media.mimeType) && !model.media.isVideoGif
itemView.setOnClickListener { onSelectedMediaClicked(model.media) }
}
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.mediasend.v2.gallery
import org.thoughtcrime.securesms.util.MappingModel
data class MediaGalleryState(
val bucketId: String?,
val bucketTitle: String?,
val items: List<MappingModel<*>> = listOf()
)

View File

@@ -0,0 +1,67 @@
package org.thoughtcrime.securesms.mediasend.v2.gallery
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.mediasend.MediaFolder
import org.thoughtcrime.securesms.util.livedata.Store
class MediaGalleryViewModel(bucketId: String?, bucketTitle: String?, private val repository: MediaGalleryRepository) : ViewModel() {
private val store = Store(MediaGalleryState(bucketId, bucketTitle))
val state: LiveData<MediaGalleryState> = store.stateLiveData
init {
loadItemsForBucket(bucketId, bucketTitle)
}
fun pop(): Boolean {
return if (store.state.bucketId == null) {
true
} else {
loadItemsForBucket(null, null)
false
}
}
fun setMediaFolder(mediaFolder: MediaFolder) {
loadItemsForBucket(mediaFolder.bucketId, mediaFolder.title)
}
private fun loadItemsForBucket(bucketId: String?, bucketTitle: String?) {
if (bucketId == null) {
repository.getFolders { folders ->
store.update { state ->
state.copy(
bucketId = bucketId, bucketTitle = bucketTitle,
items = folders.map {
MediaGallerySelectableItem.FolderModel(it)
}
)
}
}
} else {
repository.getMedia(bucketId) { media ->
store.update { state ->
state.copy(
bucketId = bucketId, bucketTitle = bucketTitle,
items = media.map {
MediaGallerySelectableItem.FileModel(it, false)
}
)
}
}
}
}
class Factory(
private val bucketId: String?,
private val bucketTitle: String?,
private val repository: MediaGalleryRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MediaGalleryViewModel(bucketId, bucketTitle, repository)))
}
}
}

View File

@@ -0,0 +1,103 @@
package org.thoughtcrime.securesms.mediasend.v2.gallery
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator.Companion.requestPermissionsForCamera
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.permissions.Permissions
private const val MEDIA_GALLERY_TAG = "MEDIA_GALLERY"
class MediaSelectionGalleryFragment : Fragment(R.layout.fragment_container), MediaGalleryFragment.Callbacks {
private lateinit var mediaGalleryFragment: MediaGalleryFragment
private val navigator = MediaSelectionNavigator(
toCamera = R.id.action_mediaGalleryFragment_to_mediaCaptureFragment
)
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
mediaGalleryFragment = ensureMediaGalleryFragment()
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
mediaGalleryFragment.onViewStateUpdated(MediaGalleryFragment.ViewState(state.selectedMedia))
}
if (arguments?.containsKey("first") == true) {
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
requireActivity().finish()
}
}
)
}
}
private fun ensureMediaGalleryFragment(): MediaGalleryFragment {
val fragmentInManager: MediaGalleryFragment? = childFragmentManager.findFragmentByTag(MEDIA_GALLERY_TAG) as? MediaGalleryFragment
return if (fragmentInManager != null) {
fragmentInManager
} else {
val mediaGalleryFragment = MediaGalleryFragment()
childFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
mediaGalleryFragment,
MEDIA_GALLERY_TAG
)
.commitNowAllowingStateLoss()
mediaGalleryFragment
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun isMultiselectEnabled(): Boolean {
return true
}
override fun onMediaSelected(media: Media) {
sharedViewModel.addMedia(media)
}
override fun onMediaUnselected(media: Media) {
sharedViewModel.removeMedia(media)
}
override fun onSelectedMediaClicked(media: Media) {
sharedViewModel.setFocusedMedia(media)
navigator.goToReview(requireView())
}
override fun onNavigateToCamera() {
requestPermissionsForCamera {
navigator.goToCamera(requireView())
}
}
override fun onSubmit() {
navigator.goToReview(requireView())
}
override fun onToolbarNavigationClicked() {
Navigation.findNavController(requireView()).popBackStack()
}
}

View File

@@ -0,0 +1,166 @@
package org.thoughtcrime.securesms.mediasend.v2.images
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import io.reactivex.rxjava3.disposables.Disposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.scribbles.ImageEditorHudV2
import java.util.concurrent.TimeUnit
private const val IMAGE_EDITOR_TAG = "image.editor.fragment"
private val MODE_DELAY = TimeUnit.MILLISECONDS.toMillis(300)
/**
* Displays the chosen image within the image editor. Also manages the "touch enabled" state of the shared
* view model. We utilize delays here to help with Animation choreography.
*/
class MediaReviewImagePageFragment : Fragment(R.layout.fragment_container), ImageEditorFragment.Controller {
private lateinit var imageEditorFragment: ImageEditorFragment
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
private lateinit var hudCommandDisposable: Disposable
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
imageEditorFragment = ensureImageEditorFragment()
}
override fun onPause() {
super.onPause()
hudCommandDisposable.dispose()
}
override fun onResume() {
super.onResume()
hudCommandDisposable = sharedViewModel.hudCommands.subscribe { command ->
if (isResumed) {
when (command) {
HudCommand.StartDraw -> {
sharedViewModel.setTouchEnabled(false)
requireView().postDelayed(
{
imageEditorFragment.setMode(ImageEditorHudV2.Mode.DRAW)
},
MODE_DELAY
)
}
HudCommand.StartCropAndRotate -> {
sharedViewModel.setTouchEnabled(false)
requireView().postDelayed(
{
imageEditorFragment.setMode(ImageEditorHudV2.Mode.CROP)
},
MODE_DELAY
)
}
HudCommand.SaveMedia -> imageEditorFragment.onSave()
else -> Unit
}
}
}
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
restoreImageEditorState()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
sharedViewModel.setEditorState(requireUri(), requireNotNull(imageEditorFragment.saveState()))
}
private fun ensureImageEditorFragment(): ImageEditorFragment {
val fragmentInManager: ImageEditorFragment? = childFragmentManager.findFragmentByTag(IMAGE_EDITOR_TAG) as? ImageEditorFragment
return if (fragmentInManager != null) {
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
fragmentInManager
} else {
val imageEditorFragment = ImageEditorFragment.newInstance(
requireUri()
)
childFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
imageEditorFragment,
IMAGE_EDITOR_TAG
)
.commitAllowingStateLoss()
imageEditorFragment
}
}
private fun requireUri(): Uri = requireNotNull(requireArguments().getParcelable(ARG_URI))
override fun onTouchEventsNeeded(needed: Boolean) {
if (isResumed) {
if (!needed) {
requireView().postDelayed(
{
sharedViewModel.setTouchEnabled(!needed)
},
MODE_DELAY
)
} else {
sharedViewModel.setTouchEnabled(!needed)
}
}
}
override fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) = Unit
override fun onDoneEditing() {
imageEditorFragment.setMode(ImageEditorHudV2.Mode.NONE)
if (isResumed) {
sharedViewModel.setEditorState(requireUri(), requireNotNull(imageEditorFragment.saveState()))
}
}
override fun onCancelEditing() {
restoreImageEditorState()
}
override fun onMainImageLoaded() {
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
}
override fun onMainImageFailedToLoad() {
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
}
private fun restoreImageEditorState() {
val data = sharedViewModel.getEditorState(requireUri()) as? ImageEditorFragment.Data
if (data != null) {
imageEditorFragment.restoreState(data)
} else {
imageEditorFragment.onClearAll()
}
}
companion object {
private const val ARG_URI = "arg.uri"
fun newInstance(uri: Uri): Fragment {
return MediaReviewImagePageFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_URI, uri)
}
}
}
}
}

View File

@@ -0,0 +1,227 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.components.InputAwareLayout
import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment
import org.thoughtcrime.securesms.components.emoji.EmojiToggle
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerFragment
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel
import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.views.Stub
import org.thoughtcrime.securesms.util.visible
class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_add_message_dialog_fragment) {
private val viewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val mentionsViewModel: MentionsPickerViewModel by viewModels(
ownerProducer = { requireActivity() },
factoryProducer = { MentionsPickerViewModel.Factory() }
)
private lateinit var input: ComposeText
private lateinit var emojiDrawerToggle: EmojiToggle
private lateinit var emojiDrawerStub: Stub<MediaKeyboard>
private lateinit var hud: InputAwareLayout
private lateinit var mentionsContainer: ViewGroup
private var requestedEmojiDrawer: Boolean = false
private val disposables = CompositeDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val themeWrapper = ContextThemeWrapper(inflater.context, R.style.TextSecure_DarkTheme)
val themedInflater = LayoutInflater.from(themeWrapper)
return super.onCreateView(themedInflater, container, savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
input = view.findViewById(R.id.add_a_message_input)
input.setText(requireArguments().getCharSequence(ARG_INITIAL_TEXT))
emojiDrawerToggle = view.findViewById(R.id.emoji_toggle)
emojiDrawerStub = Stub(view.findViewById(R.id.emoji_drawer_stub))
if (SignalStore.settings().isPreferSystemEmoji) {
emojiDrawerToggle.visible = false
} else {
emojiDrawerToggle.setOnClickListener { onEmojiToggleClicked() }
}
hud = view.findViewById(R.id.hud)
hud.setOnClickListener { dismissAllowingStateLoss() }
val confirm: View = view.findViewById(R.id.confirm_button)
confirm.setOnClickListener {
viewModel.setMessage(input.text)
dismissAllowingStateLoss()
}
disposables.add(
viewModel.hudCommands.observeOn(AndroidSchedulers.mainThread()).subscribe {
when (it) {
HudCommand.OpenEmojiSearch -> openEmojiSearch()
HudCommand.CloseEmojiSearch -> closeEmojiSearch()
is HudCommand.EmojiKeyEvent -> onKeyEvent(it.keyEvent)
is HudCommand.EmojiInsert -> onEmojiSelected(it.emoji)
else -> Unit
}
}
)
initializeMentions()
}
override fun onResume() {
super.onResume()
requestedEmojiDrawer = false
ViewUtil.focusAndShowKeyboard(input)
}
override fun onPause() {
super.onPause()
ViewUtil.hideKeyboard(requireContext(), input)
}
override fun onKeyboardHidden() {
if (!requestedEmojiDrawer) {
super.onKeyboardHidden()
}
}
override fun onDestroyView() {
super.onDestroyView()
disposables.dispose()
input.setMentionQueryChangedListener(null)
input.setMentionValidator(null)
}
private fun initializeMentions() {
val recipientId: RecipientId = viewModel.destination.getRecipientId() ?: return
mentionsContainer = requireView().findViewById(R.id.mentions_picker_container)
Recipient.live(recipientId).observe(viewLifecycleOwner) { recipient ->
mentionsViewModel.onRecipientChange(recipient)
input.setMentionQueryChangedListener { query ->
if (recipient.isPushV2Group) {
ensureMentionsContainerFilled()
mentionsViewModel.onQueryChange(query)
}
}
input.setMentionValidator { annotations ->
if (!recipient.isPushV2Group) {
annotations
} else {
val validRecipientIds: Set<String> = recipient.participants
.map { r -> MentionAnnotation.idToMentionAnnotationValue(r.id) }
.toSet()
annotations
.filter { !validRecipientIds.contains(it.value) }
.toList()
}
}
}
mentionsViewModel.selectedRecipient.observe(viewLifecycleOwner) { recipient ->
input.replaceTextWithMention(recipient.getDisplayName(requireContext()), recipient.id)
}
}
private fun ensureMentionsContainerFilled() {
val mentionsFragment = childFragmentManager.findFragmentById(R.id.mentions_picker_container)
if (mentionsFragment == null) {
childFragmentManager
.beginTransaction()
.replace(R.id.mentions_picker_container, MentionsPickerFragment())
.commitNowAllowingStateLoss()
}
}
private fun onEmojiToggleClicked() {
if (!emojiDrawerStub.resolved()) {
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
emojiDrawerStub.get().setFragmentManager(childFragmentManager)
emojiDrawerToggle.attach(emojiDrawerStub.get())
}
if (hud.currentInput == emojiDrawerStub.get()) {
hud.showSoftkey(input)
} else {
requestedEmojiDrawer = true
hud.hideSoftkey(input) {
hud.post {
hud.show(input, emojiDrawerStub.get())
}
}
}
}
private fun openEmojiSearch() {
if (emojiDrawerStub.resolved()) {
emojiDrawerStub.get().onOpenEmojiSearch()
}
}
private fun closeEmojiSearch() {
if (emojiDrawerStub.resolved()) {
emojiDrawerStub.get().onCloseEmojiSearch()
}
}
private fun onEmojiSelected(emoji: String?) {
input.insertEmoji(emoji)
}
private fun onKeyEvent(keyEvent: KeyEvent?) {
input.dispatchKeyEvent(keyEvent)
}
companion object {
const val TAG = "ADD_MESSAGE_DIALOG_FRAGMENT"
private const val ARG_INITIAL_TEXT = "arg.initial.text"
fun show(fragmentManager: FragmentManager, initialText: CharSequence?) {
AddMessageDialogFragment().apply {
arguments = Bundle().apply {
putCharSequence(ARG_INITIAL_TEXT, initialText)
}
}.show(fragmentManager, TAG)
}
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
typealias OnAddMediaItemClicked = () -> Unit
object MediaReviewAddItem {
fun register(mappingAdapter: MappingAdapter, onAddMediaItemClicked: OnAddMediaItemClicked) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAddMediaItemClicked) }, R.layout.v2_media_review_add_media_item))
}
object Model : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return true
}
}
class ViewHolder(itemView: View, onAddMediaItemClicked: OnAddMediaItemClicked) : MappingViewHolder<Model>(itemView) {
init {
itemView.setOnClickListener { onAddMediaItemClicked() }
}
override fun bind(model: Model) = Unit
}
}

View File

@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import android.animation.Animator
import android.animation.ObjectAnimator
import android.view.View
import androidx.core.animation.doOnEnd
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
object MediaReviewAnimatorController {
fun getSlideInAnimator(view: View): Animator {
return ObjectAnimator.ofFloat(view, "translationY", view.translationY, 0f)
}
fun getSlideOutAnimator(view: View): Animator {
return ObjectAnimator.ofFloat(view, "translationY", view.translationX, ViewUtil.dpToPx(48).toFloat())
}
fun getFadeInAnimator(view: View): Animator {
view.visible = true
view.isEnabled = true
return ObjectAnimator.ofFloat(view, "alpha", view.alpha, 1f)
}
fun getFadeOutAnimator(view: View): Animator {
view.isEnabled = false
val animator = ObjectAnimator.ofFloat(view, "alpha", view.alpha, 0f)
animator.doOnEnd { view.visible = false }
return animator
}
}

View File

@@ -0,0 +1,417 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import android.animation.Animator
import android.animation.AnimatorSet
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import android.widget.ViewSwitcher
import androidx.activity.OnBackPressedCallback
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.keyboard.findListener
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator.Companion.requestPermissionsForGallery
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MediaUtil
/**
* Allows the user to view and edit selected media.
*/
class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private lateinit var callback: Callback
private lateinit var drawToolButton: View
private lateinit var cropAndRotateButton: View
private lateinit var qualityButton: ImageView
private lateinit var saveButton: View
private lateinit var sendButton: View
private lateinit var addMediaButton: View
private lateinit var viewOnceButton: ViewSwitcher
private lateinit var viewOnceMessage: TextView
private lateinit var addMessageButton: TextView
private lateinit var addMessageEntry: TextView
private lateinit var recipientDisplay: TextView
private lateinit var pager: ViewPager2
private lateinit var controls: ConstraintLayout
private lateinit var selectionRecycler: RecyclerView
private lateinit var controlsShade: View
private val navigator = MediaSelectionNavigator(
toGallery = R.id.action_mediaReviewFragment_to_mediaGalleryFragment,
)
private var animatorSet: AnimatorSet? = null
private var disposables: CompositeDisposable? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
postponeEnterTransition()
callback = requireNotNull(findListener())
drawToolButton = view.findViewById(R.id.draw_tool)
cropAndRotateButton = view.findViewById(R.id.crop_and_rotate_tool)
qualityButton = view.findViewById(R.id.quality_selector)
saveButton = view.findViewById(R.id.save_to_media)
sendButton = view.findViewById(R.id.send)
addMediaButton = view.findViewById(R.id.add_media)
viewOnceButton = view.findViewById(R.id.view_once_toggle)
addMessageButton = view.findViewById(R.id.add_a_message)
addMessageEntry = view.findViewById(R.id.add_a_message_entry)
recipientDisplay = view.findViewById(R.id.recipient)
pager = view.findViewById(R.id.media_pager)
controls = view.findViewById(R.id.controls)
selectionRecycler = view.findViewById(R.id.selection_recycler)
controlsShade = view.findViewById(R.id.controls_shade)
viewOnceMessage = view.findViewById(R.id.view_once_message)
val pagerAdapter = MediaReviewFragmentPagerAdapter(this)
disposables = CompositeDisposable()
disposables?.add(
sharedViewModel.hudCommands.subscribe {
when (it) {
HudCommand.ResumeEntryTransition -> startPostponedEnterTransition()
else -> Unit
}
}
)
pager.adapter = pagerAdapter
drawToolButton.setOnClickListener {
sharedViewModel.sendCommand(HudCommand.StartDraw)
}
cropAndRotateButton.setOnClickListener {
sharedViewModel.sendCommand(HudCommand.StartCropAndRotate)
}
qualityButton.setOnClickListener {
QualitySelectorBottomSheetDialog.show(parentFragmentManager)
}
saveButton.setOnClickListener {
sharedViewModel.sendCommand(HudCommand.SaveMedia)
}
setFragmentResultListener(MultiselectForwardFragment.RESULT_SELECTION) { _, bundle ->
val recipientIds: List<RecipientId> = requireNotNull(bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION_RECIPIENTS))
performSend(recipientIds)
}
sendButton.setOnClickListener {
if (sharedViewModel.isContactSelectionRequired) {
val args = MultiselectForwardFragmentArgs(false, title = R.string.MediaReviewFragment__send_to)
MultiselectForwardFragment.show(parentFragmentManager, args)
} else {
performSend()
}
}
addMediaButton.setOnClickListener {
launchGallery()
}
viewOnceButton.setOnClickListener {
sharedViewModel.incrementViewOnceState()
}
addMessageButton.setOnClickListener {
AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message)
}
addMessageEntry.setOnClickListener {
AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message)
}
if (sharedViewModel.isReply) {
addMessageButton.setText(R.string.MediaReviewFragment__add_a_reply)
}
pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
sharedViewModel.setFocusedMedia(position)
}
})
val selectionAdapter = MappingAdapter()
MediaReviewAddItem.register(selectionAdapter) {
launchGallery()
}
MediaReviewSelectedItem.register(selectionAdapter) { media, isSelected ->
if (isSelected) {
sharedViewModel.removeMedia(media)
} else {
sharedViewModel.setFocusedMedia(media)
}
}
selectionRecycler.adapter = selectionAdapter
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
pagerAdapter.submitMedia(state.selectedMedia)
selectionAdapter.submitList(
state.selectedMedia.map { MediaReviewSelectedItem.Model(it, state.focusedMedia == it) } + MediaReviewAddItem.Model
)
presentPager(state)
presentAddMessageEntry(state.message)
presentImageQualityToggle(state.quality)
viewOnceButton.displayedChild = if (state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE) 1 else 0
sendButton.isEnabled = !state.isSent && state.selectedMedia.isNotEmpty()
computeViewStateAndAnimate(state)
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
callback.onPopFromReview()
}
}
)
}
override fun onResume() {
super.onResume()
sharedViewModel.kick()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun onDestroyView() {
disposables?.dispose()
super.onDestroyView()
}
private fun launchGallery() {
requestPermissionsForGallery {
navigator.goToGallery(requireView())
}
}
private fun performSend(selection: List<RecipientId> = listOf()) {
sharedViewModel
.send(selection)
.subscribe(
{ result -> callback.onSentWithResult(result) },
{ error -> callback.onSendError(error) },
{ callback.onSentWithoutResult() }
)
}
private fun presentAddMessageEntry(message: CharSequence?) {
addMessageEntry.text = message
}
private fun presentImageQualityToggle(quality: SentMediaQuality) {
qualityButton.setImageResource(
when (quality) {
SentMediaQuality.STANDARD -> R.drawable.ic_sq_36
SentMediaQuality.HIGH -> R.drawable.ic_hq_36
}
)
}
private fun presentPager(state: MediaSelectionState) {
pager.isUserInputEnabled = state.isTouchEnabled
val indexOfSelectedItem = state.selectedMedia.indexOf(state.focusedMedia)
if (pager.currentItem == indexOfSelectedItem) {
return
}
if (indexOfSelectedItem != -1) {
pager.setCurrentItem(indexOfSelectedItem, false)
} else {
pager.setCurrentItem(0, false)
}
}
private fun computeViewStateAndAnimate(state: MediaSelectionState) {
this.animatorSet?.cancel()
val animators = mutableListOf<Animator>()
animators.addAll(computeAddMessageAnimators(state))
animators.addAll(computeViewOnceButtonAnimators(state))
animators.addAll(computeAddMediaButtonsAnimators(state))
animators.addAll(computeSendAndSaveButtonAnimators(state))
animators.addAll(computeQualityButtonAnimators(state))
animators.addAll(computeCropAndRotateButtonAnimators(state))
animators.addAll(computeDrawToolButtonAnimators(state))
animators.addAll(computeRecipientDisplayAnimators(state))
animators.addAll(computeControlsShadeAnimators(state))
val animatorSet = AnimatorSet()
animatorSet.playTogether(animators)
animatorSet.start()
this.animatorSet = animatorSet
}
private fun computeControlsShadeAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(controlsShade))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(controlsShade))
}
}
private fun computeAddMessageAnimators(state: MediaSelectionState): List<Animator> {
return when {
!state.isTouchEnabled -> {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(viewOnceMessage),
MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton),
MediaReviewAnimatorController.getFadeOutAnimator(addMessageEntry)
)
}
state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE -> {
listOf(
MediaReviewAnimatorController.getFadeInAnimator(viewOnceMessage),
MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton),
MediaReviewAnimatorController.getFadeOutAnimator(addMessageEntry)
)
}
state.message.isNullOrEmpty() -> {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(viewOnceMessage),
MediaReviewAnimatorController.getFadeInAnimator(addMessageButton),
MediaReviewAnimatorController.getFadeOutAnimator(addMessageEntry)
)
}
else -> {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(viewOnceMessage),
MediaReviewAnimatorController.getFadeInAnimator(addMessageEntry),
MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton)
)
}
}
}
private fun computeViewOnceButtonAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled && state.selectedMedia.size == 1) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(viewOnceButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(viewOnceButton))
}
}
private fun computeAddMediaButtonsAnimators(state: MediaSelectionState): List<Animator> {
return when {
!state.isTouchEnabled || state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE -> {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(addMediaButton),
MediaReviewAnimatorController.getFadeOutAnimator(selectionRecycler)
)
}
state.selectedMedia.size > 1 -> {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(addMediaButton),
MediaReviewAnimatorController.getFadeInAnimator(selectionRecycler)
)
}
else -> {
listOf(
MediaReviewAnimatorController.getFadeInAnimator(addMediaButton),
MediaReviewAnimatorController.getFadeOutAnimator(selectionRecycler)
)
}
}
}
private fun computeSendAndSaveButtonAnimators(state: MediaSelectionState): List<Animator> {
val slideIn = listOf(
MediaReviewAnimatorController.getSlideInAnimator(sendButton),
MediaReviewAnimatorController.getSlideInAnimator(saveButton)
)
return slideIn + if (state.isTouchEnabled) {
listOf(
MediaReviewAnimatorController.getFadeInAnimator(sendButton),
MediaReviewAnimatorController.getFadeInAnimator(saveButton)
)
} else {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(sendButton),
MediaReviewAnimatorController.getFadeOutAnimator(saveButton)
)
}
}
private fun computeQualityButtonAnimators(state: MediaSelectionState): List<Animator> {
val slide = listOf(MediaReviewAnimatorController.getSlideInAnimator(qualityButton))
return slide + if (state.isTouchEnabled && state.selectedMedia.any { MediaUtil.isImageType(it.mimeType) }) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(qualityButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(qualityButton))
}
}
private fun computeCropAndRotateButtonAnimators(state: MediaSelectionState): List<Animator> {
val slide = listOf(MediaReviewAnimatorController.getSlideInAnimator(cropAndRotateButton))
return slide + if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.mimeType ?: "")) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(cropAndRotateButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(cropAndRotateButton))
}
}
private fun computeDrawToolButtonAnimators(state: MediaSelectionState): List<Animator> {
val slide = listOf(MediaReviewAnimatorController.getSlideInAnimator(drawToolButton))
return slide + if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.mimeType ?: "")) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(drawToolButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(drawToolButton))
}
}
private fun computeRecipientDisplayAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled && state.recipient != null) {
recipientDisplay.text = state.recipient.getDisplayName(requireContext())
listOf(MediaReviewAnimatorController.getFadeInAnimator(recipientDisplay))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(recipientDisplay))
}
}
interface Callback {
fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult)
fun onSentWithoutResult()
fun onSendError(error: Throwable)
fun onPopFromReview()
}
}

View File

@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendGifFragment
import org.thoughtcrime.securesms.mediasend.v2.images.MediaReviewImagePageFragment
import org.thoughtcrime.securesms.mediasend.v2.videos.MediaReviewVideoPageFragment
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.LinkedList
class MediaReviewFragmentPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
private val mediaList: MutableList<Media> = mutableListOf()
fun submitMedia(media: List<Media>) {
val oldMedia: List<Media> = LinkedList(mediaList)
mediaList.clear()
mediaList.addAll(media)
DiffUtil
.calculateDiff(Callback(oldMedia, mediaList))
.dispatchUpdatesTo(this)
}
override fun getItemId(position: Int): Long {
if (position > mediaList.size || position < 0) {
return RecyclerView.NO_ID
}
return mediaList[position].uri.hashCode().toLong()
}
override fun containsItem(itemId: Long): Boolean {
return mediaList.any { it.uri.hashCode().toLong() == itemId }
}
override fun getItemCount(): Int = mediaList.size
override fun createFragment(position: Int): Fragment {
val mediaItem: Media = mediaList[position]
return when {
MediaUtil.isGif(mediaItem.mimeType) -> MediaSendGifFragment.newInstance(mediaItem.uri)
MediaUtil.isImageType(mediaItem.mimeType) -> MediaReviewImagePageFragment.newInstance(mediaItem.uri)
MediaUtil.isVideoType(mediaItem.mimeType) -> MediaReviewVideoPageFragment.newInstance(mediaItem.uri, mediaItem.isVideoGif)
else -> {
throw UnsupportedOperationException("Can only render images and videos. Found mimetype: '" + mediaItem.mimeType + "'")
}
}
}
private class Callback(
private val oldList: List<Media>,
private val newList: List<Media>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition].uri == newList[newItemPosition].uri
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition] == newList[newItemPosition]
}
}
}

View File

@@ -0,0 +1,56 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import android.view.View
import android.widget.ImageView
import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.visible
typealias OnSelectedMediaClicked = (Media, Boolean) -> Unit
object MediaReviewSelectedItem {
fun register(mappingAdapter: MappingAdapter, onSelectedMediaClicked: OnSelectedMediaClicked) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onSelectedMediaClicked) }, R.layout.v2_media_review_selected_item))
}
class Model(val media: Media, val isSelected: Boolean) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return media == newItem.media
}
override fun areContentsTheSame(newItem: Model): Boolean {
return media == newItem.media && isSelected == newItem.isSelected
}
}
class ViewHolder(itemView: View, private val onSelectedMediaClicked: OnSelectedMediaClicked) : MappingViewHolder<Model>(itemView) {
private val imageView: ImageView = itemView.findViewById(R.id.media_review_selected_image)
private val playOverlay: ImageView = itemView.findViewById(R.id.media_review_play_overlay)
private val selectedOverlay: ImageView = itemView.findViewById(R.id.media_review_selected_overlay)
override fun bind(model: Model) {
Glide.with(imageView)
.load(DecryptableStreamUriLoader.DecryptableUri(model.media.uri))
.centerCrop()
.into(imageView)
playOverlay.visible = MediaUtil.isNonGifVideo(model.media) && !model.isSelected
selectedOverlay.isSelected = model.isSelected
itemView.contentDescription = if (model.isSelected) {
context.getString(R.string.MediaReviewSelectedItem__tap_to_remove)
} else {
context.getString(R.string.MediaReviewSelectedItem__tap_to_select)
}
itemView.setOnClickListener { onSelectedMediaClicked(model.media, model.isSelected) }
}
}
}

View File

@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.mediasend;
package org.thoughtcrime.securesms.mediasend.v2.review;
import android.os.Bundle;
import android.view.ContextThemeWrapper;
@@ -15,18 +15,20 @@ import androidx.lifecycle.ViewModelProviders;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState;
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel;
import org.thoughtcrime.securesms.mms.SentMediaQuality;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.views.CheckedLinearLayout;
/**
* Dialog for selecting media quality, tightly coupled with {@link MediaSendViewModel}.
* Dialog for selecting media quality, tightly coupled with {@link MediaSelectionViewModel}.
*/
public final class QualitySelectorBottomSheetDialog extends BottomSheetDialogFragment {
private MediaSendViewModel viewModel;
private CheckedLinearLayout standard;
private CheckedLinearLayout high;
private MediaSelectionViewModel viewModel;
private CheckedLinearLayout standard;
private CheckedLinearLayout high;
public static void show(@NonNull FragmentManager manager) {
QualitySelectorBottomSheetDialog fragment = new QualitySelectorBottomSheetDialog();
@@ -60,17 +62,13 @@ public final class QualitySelectorBottomSheetDialog extends BottomSheetDialogFra
standard.setOnClickListener(listener);
high.setOnClickListener(listener);
viewModel = ViewModelProviders.of(requireActivity()).get(MediaSelectionViewModel.class);
viewModel.getState().observe(getViewLifecycleOwner(), this::updateQuality);
}
@Override
public void onActivityCreated(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
viewModel = ViewModelProviders.of(requireActivity()).get(MediaSendViewModel.class);
viewModel.getSentMediaQuality().observe(getViewLifecycleOwner(), this::updateQuality);
}
private void updateQuality(@NonNull SentMediaQuality sentMediaQuality) {
select(sentMediaQuality == SentMediaQuality.STANDARD ? standard : high);
private void updateQuality(@NonNull MediaSelectionState selectionState) {
select(selectionState.getQuality() == SentMediaQuality.STANDARD ? standard : high);
}
private void select(@NonNull View view) {
@@ -83,5 +81,4 @@ public final class QualitySelectorBottomSheetDialog extends BottomSheetDialogFra
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
BottomSheetUtil.show(manager, tag, this);
}
}

View File

@@ -0,0 +1,117 @@
package org.thoughtcrime.securesms.mediasend.v2.videos
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.VideoEditorFragment
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
private const val VIDEO_EDITOR_TAG = "video.editor.fragment"
/**
* Page fragment which displays a single editable video (non-gif) to the user. Has an embedded MediaSendVideoFragment
* and adds some extra support for saving and restoring state, as well as saving a video to disk.
*/
class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), VideoEditorFragment.Controller {
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
private lateinit var videoEditorFragment: VideoEditorFragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
videoEditorFragment = ensureVideoEditorFragment()
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
restoreVideoEditorState()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
saveEditorState()
}
private fun saveEditorState() {
val saveState = videoEditorFragment.saveState()
if (saveState != null) {
sharedViewModel.setEditorState(requireUri(), saveState)
}
}
override fun onPlayerReady() {
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
}
override fun onPlayerError() {
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
}
override fun onTouchEventsNeeded(needed: Boolean) {
sharedViewModel.setTouchEnabled(!needed)
}
override fun onVideoBeginEdit(uri: Uri) {
sharedViewModel.onVideoBeginEdit(uri)
}
override fun onVideoEndEdit(uri: Uri) {
saveEditorState()
}
private fun restoreVideoEditorState() {
val data = sharedViewModel.getEditorState(requireUri()) as? VideoEditorFragment.Data
if (data != null) {
videoEditorFragment.restoreState(data)
}
}
private fun ensureVideoEditorFragment(): VideoEditorFragment {
val fragmentInManager: VideoEditorFragment? = childFragmentManager.findFragmentByTag(VIDEO_EDITOR_TAG) as? VideoEditorFragment
return if (fragmentInManager != null) {
fragmentInManager
} else {
val videoEditorFragment = VideoEditorFragment.newInstance(
requireUri(),
requireMaxCompressedVideoSize(),
requireMaxAttachmentSize(),
requireIsVideoGif()
)
childFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
videoEditorFragment,
VIDEO_EDITOR_TAG
)
.commitAllowingStateLoss()
videoEditorFragment
}
}
private fun requireUri(): Uri = requireNotNull(requireArguments().getParcelable(ARG_URI))
private fun requireMaxCompressedVideoSize(): Long = sharedViewModel.getMediaConstraints().getCompressedVideoMaxSize(requireContext()).toLong()
private fun requireMaxAttachmentSize(): Long = sharedViewModel.getMediaConstraints().getVideoMaxSize(requireContext()).toLong()
private fun requireIsVideoGif(): Boolean = requireNotNull(requireArguments().getBoolean(ARG_IS_VIDEO_GIF))
companion object {
private const val ARG_URI = "arg.uri"
private const val ARG_IS_VIDEO_GIF = "arg.is.video.gif"
fun newInstance(uri: Uri, isVideoGif: Boolean): Fragment {
return MediaReviewVideoPageFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_URI, uri)
putBoolean(ARG_IS_VIDEO_GIF, isVideoGif)
}
}
}
}
}