mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 01:40:07 +01:00
Refresh media selection and sending flow with a shiny new UX.
This commit is contained in:
committed by
Greyson Parrelli
parent
a940487611
commit
664d6475d9
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -13,7 +13,7 @@ public final class CompositeMediaTransform implements MediaTransform {
|
||||
|
||||
private final MediaTransform[] transforms;
|
||||
|
||||
CompositeMediaTransform(MediaTransform ...transforms) {
|
||||
public CompositeMediaTransform(MediaTransform ...transforms) {
|
||||
this.transforms = transforms;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.capture
|
||||
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
|
||||
data class MediaCaptureState(
|
||||
val mostRecentMedia: Media? = null
|
||||
)
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user