Update the media send flow with a persistent rail.

This commit is contained in:
Greyson Parrelli 2019-07-18 10:57:52 -04:00
parent b58faf4fd1
commit 9f7bb69341
22 changed files with 1240 additions and 921 deletions

View file

@ -13,7 +13,7 @@
<item>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
<corners android:radius="5dp" />
<solid android:color="@color/transparent_black_70"/>
<solid android:color="@color/transparent_white_40"/>
</shape>
</item>
</ripple>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
<corners android:radius="5dp" />
<solid android:color="@color/transparent_black_70"/>
<solid android:color="@color/transparent_white_40"/>
</shape>

View file

@ -1,32 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="16dp"
android:layout_gravity="end">
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/camera_capture_button"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:background="@drawable/ic_camera_shutter" />
android:layout_marginEnd="24dp"
android:background="@drawable/ic_camera_shutter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<ImageButton
android:id="@+id/camera_flip_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@+id/camera_capture_button"
android:layout_marginBottom="40dp"
android:layout_centerHorizontal="true"
android:layout_marginStart="16dp"
android:layout_marginTop="14dp"
android:src="@drawable/ic_switch_camera_32"
android:scaleType="fitCenter"
android:background="?selectableItemBackgroundBorderless"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</RelativeLayout>
<com.makeramen.roundedimageview.RoundedImageView
android:id="@+id/camera_gallery_button"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginBottom="42dp"
app:layout_constraintBottom_toTopOf="@id/camera_capture_button"
app:layout_constraintStart_toStartOf="@id/camera_capture_button"
app:layout_constraintEnd_toEndOf="@id/camera_capture_button"
app:riv_corner_radius="4dp"
app:riv_border_color="@color/core_white"
app:riv_border_width="2dp"/>
<include
android:id="@+id/camera_count_button"
layout="@layout/mediasend_count_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="11dp"
android:layout_marginBottom="14dp"
android:layout_gravity="bottom|end"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/camera_capture_button"
app:layout_constraintEnd_toEndOf="@id/camera_capture_button"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,32 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_gravity="bottom">
android:layout_height="match_parent">
<Button
android:id="@+id/camera_capture_button"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:background="@drawable/ic_camera_shutter" />
android:layout_marginBottom="24dp"
android:background="@drawable/ic_camera_shutter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageButton
android:id="@+id/camera_flip_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toStartOf="@+id/camera_capture_button"
android:layout_marginEnd="40dp"
android:layout_centerVertical="true"
android:layout_marginEnd="16dp"
android:layout_marginTop="14dp"
android:src="@drawable/ic_switch_camera_32"
android:scaleType="fitCenter"
android:background="?selectableItemBackgroundBorderless"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible" />
<com.makeramen.roundedimageview.RoundedImageView
android:id="@+id/camera_gallery_button"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="32dp"
app:layout_constraintTop_toTopOf="@id/camera_capture_button"
app:layout_constraintBottom_toBottomOf="@id/camera_capture_button"
app:layout_constraintStart_toStartOf="parent"
app:riv_corner_radius="4dp"
app:riv_border_color="@color/core_white"
app:riv_border_width="2dp"/>
<include
android:id="@+id/camera_count_button"
layout="@layout/mediasend_count_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="11dp"
android:layout_marginBottom="14dp"
android:layout_marginEnd="32dp"
android:layout_gravity="bottom|end"
android:visibility="gone"
app:layout_constraintTop_toTopOf="@id/camera_capture_button"
app:layout_constraintBottom_toBottomOf="@id/camera_capture_button"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible" />
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -19,6 +19,8 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="2dp"
android:layout_marginTop="2dp" />
android:layout_marginTop="2dp"
android:paddingBottom="@dimen/media_picker_rail_padding_affordance"
android:clipToPadding="false" />
</LinearLayout>

View file

@ -19,6 +19,8 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="2dp"
android:layout_marginTop="2dp" />
android:layout_marginTop="2dp"
android:paddingBottom="@dimen/media_picker_rail_padding_affordance"
android:clipToPadding="false"/>
</LinearLayout>

View file

@ -2,6 +2,7 @@
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -11,59 +12,152 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/mediasend_count_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:layout_marginEnd="32dp"
android:layout_gravity="bottom|end"
android:padding="8dp"
android:gravity="center"
android:orientation="horizontal"
android:background="@drawable/media_count_button_background"
android:elevation="4dp"
android:visibility="gone"
tools:visibility="visible">
<org.thoughtcrime.securesms.components.InputAwareLayout
android:id="@+id/mediasend_hud"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true">
<TextView
android:id="@+id/mediasend_count_button_text"
android:layout_width="wrap_content"
<LinearLayout
android:id="@+id/mediasend_caption_and_rail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minWidth="28dp"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:gravity="center"
android:background="@drawable/media_count_number_background"
android:textColor="@color/signal_primary"
android:textSize="18sp"
tools:text="3" />
android:layout_gravity="bottom"
android:orientation="vertical"
android:background="@color/transparent_black_70">
<org.thoughtcrime.securesms.components.emoji.EmojiEditText
android:id="@+id/mediasend_caption"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:paddingTop="6dp"
android:paddingBottom="6dp"
style="@style/Signal.Text.Body"
android:maxLines="3"
android:maxLength="240"
android:hint="@string/MediaSendActivity_add_a_caption"
android:autoText="true"
android:inputType="textAutoCorrect|textCapSentences|textMultiLine"
android:background="@null"/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="20dp"
android:layout_marginStart="2dp"
android:src="@drawable/ic_arrow_right"
android:tint="@color/core_white"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/mediasend_media_rail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
tools:layout_height="64dp"/>
</LinearLayout>
<LinearLayout
android:id="@+id/mediasend_compose_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:orientation="horizontal">
<ImageView
android:id="@+id/mediasend_camera_button"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginBottom="32dp"
android:layout_marginStart="32dp"
android:layout_gravity="bottom|start"
android:padding="12dp"
android:src="@drawable/ic_camera_filled_24"
android:tint="@color/core_grey_60"
android:background="@drawable/media_camera_button_background"
android:elevation="4dp"
android:visibility="gone"
tools:visibility="visible"/>
<LinearLayout
android:id="@+id/mediasend_compose_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:orientation="horizontal"
android:background="@drawable/compose_background_camera">
<org.thoughtcrime.securesms.components.emoji.EmojiToggle
android:id="@+id/mediasend_emoji_toggle"
android:layout_width="wrap_content"
android:layout_height="@dimen/conversation_compose_height"
android:layout_gravity="bottom"
android:paddingStart="4dp"
android:paddingEnd="6dp"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/conversation_activity__emoji_toggle_description" />
<Space
android:layout_width="0dp"
android:layout_height="@dimen/conversation_compose_height" />
<org.thoughtcrime.securesms.components.ComposeText
style="@style/ComposeEditText"
android:id="@+id/mediasend_compose_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:nextFocusForward="@+id/send_button"
android:nextFocusRight="@+id/send_button"
tools:hint="Send TextSecure message" >
<requestFocus />
</org.thoughtcrime.securesms.components.ComposeText>
</LinearLayout>
<FrameLayout
android:id="@+id/mediasend_send_button_bkg"
android:layout_width="@dimen/conversation_compose_height"
android:layout_height="@dimen/conversation_compose_height"
android:layout_marginStart="12dp"
android:layout_gravity="bottom"
android:background="@drawable/circle_tintable"
tools:backgroundTint="@color/core_blue">
<org.thoughtcrime.securesms.components.SendButton
android:id="@+id/mediasend_send_button"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingTop="6dp"
android:paddingEnd="6dp"
android:paddingBottom="6dp"
android:paddingStart="11dp"
android:scaleType="fitCenter"
android:contentDescription="@string/conversation_activity__send"
android:src="?conversation_transport_sms_indicator"
android:background="@drawable/circle_touch_highlight_background" />
</FrameLayout>
</LinearLayout>
<include
android:id="@+id/mediasend_count_button"
layout="@layout/mediasend_count_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="11dp"
android:layout_marginBottom="12dp"
android:layout_marginEnd="16dp"
android:layout_gravity="bottom|end"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/mediasend_characters_left"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:paddingBottom="12dp"
android:visibility="gone"
tools:visibility="visible"
tools:text="160/160 (1)" />
<ViewStub
android:id="@+id/mediasend_emoji_drawer_stub"
android:layout="@layout/scribble_fragment_emojidrawer_stub"
android:inflatedId="@+id/emoji_drawer"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
</org.thoughtcrime.securesms.components.InputAwareLayout>
</FrameLayout>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="6dp"
android:gravity="center"
android:orientation="horizontal"
android:background="@drawable/media_count_button_background"
android:elevation="4dp">
<TextView
android:id="@+id/mediasend_count_button_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="28dp"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:gravity="center"
android:background="@drawable/media_count_number_background"
android:textColor="@color/signal_primary"
android:textSize="18dp"
tools:text="3" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="20dp"
android:layout_marginStart="2dp"
android:src="@drawable/ic_arrow_right"
android:tint="@color/core_white"/>
</LinearLayout>

View file

@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/core_black">
@ -19,138 +17,4 @@
android:animateLayoutChanges="true"
android:layout_gravity="top"/>
<org.thoughtcrime.securesms.components.InputAwareLayout
android:id="@+id/mediasend_hud"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/mediasend_caption_and_rail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="vertical"
android:background="@color/transparent_black_70">
<org.thoughtcrime.securesms.components.emoji.EmojiEditText
android:id="@+id/mediasend_caption"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:paddingTop="6dp"
android:paddingBottom="6dp"
style="@style/Signal.Text.Body"
android:maxLines="3"
android:maxLength="240"
android:hint="@string/MediaSendActivity_add_a_caption"
android:autoText="true"
android:inputType="textAutoCorrect|textCapSentences|textMultiLine"
android:background="@null"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/mediasend_media_rail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
tools:layout_height="64dp"/>
<LinearLayout
android:id="@+id/mediasend_compose_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:orientation="horizontal"
android:background="@drawable/compose_background_camera">
<org.thoughtcrime.securesms.components.emoji.EmojiToggle
android:id="@+id/mediasend_emoji_toggle"
android:layout_width="wrap_content"
android:layout_height="@dimen/conversation_compose_height"
android:layout_gravity="bottom"
android:paddingStart="4dp"
android:paddingEnd="6dp"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/conversation_activity__emoji_toggle_description" />
<Space
android:layout_width="0dp"
android:layout_height="@dimen/conversation_compose_height" />
<org.thoughtcrime.securesms.components.ComposeText
style="@style/ComposeEditText"
android:id="@+id/mediasend_compose_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:nextFocusForward="@+id/send_button"
android:nextFocusRight="@+id/send_button"
tools:hint="Send TextSecure message" >
<requestFocus />
</org.thoughtcrime.securesms.components.ComposeText>
</LinearLayout>
<FrameLayout
android:id="@+id/mediasend_send_button_bkg"
android:layout_width="@dimen/conversation_compose_height"
android:layout_height="@dimen/conversation_compose_height"
android:layout_marginStart="12dp"
android:layout_gravity="bottom"
android:background="@drawable/circle_tintable"
tools:backgroundTint="@color/core_blue">
<org.thoughtcrime.securesms.components.SendButton
android:id="@+id/mediasend_send_button"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingTop="6dp"
android:paddingEnd="6dp"
android:paddingBottom="6dp"
android:paddingStart="11dp"
android:scaleType="fitCenter"
android:contentDescription="@string/conversation_activity__send"
android:src="?conversation_transport_sms_indicator"
android:background="@drawable/circle_touch_highlight_background" />
</FrameLayout>
</LinearLayout>
<TextView
android:id="@+id/mediasend_characters_left"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:paddingBottom="12dp"
android:visibility="gone"
tools:visibility="visible"
tools:text="160/160 (1)" />
<ViewStub
android:id="@+id/mediasend_emoji_drawer_stub"
android:layout="@layout/scribble_fragment_emojidrawer_stub"
android:inflatedId="@+id/emoji_drawer"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
</org.thoughtcrime.securesms.components.InputAwareLayout>
</FrameLayout>

View file

@ -5,9 +5,9 @@
<item
android:title=""
android:id="@+id/mediapicker_menu_add"
android:id="@+id/mediapicker_menu_camera"
android:visible="true"
android:icon="@drawable/ic_create_album_outline_32"
android:icon="@drawable/ic_camera_alt_white_24dp"
app:showAsAction="always" />
</menu>

View file

@ -46,6 +46,7 @@
<dimen name="media_picker_folder_width">175dp</dimen>
<dimen name="media_picker_item_width">85dp</dimen>
<dimen name="media_picker_rail_padding_affordance">130dp</dimen>
<dimen name="media_keyboard_provider_icon_padding">5dp</dimen>
<dimen name="media_keyboard_provider_icon_margin">4dp</dimen>

View file

@ -24,11 +24,12 @@ public class MediaRailAdapter extends RecyclerView.Adapter<MediaRailAdapter.Medi
private final GlideRequests glideRequests;
private final List<Media> media;
private final RailItemListener listener;
private final boolean editable;
private final StableIdGenerator<Media> stableIdGenerator;
private RailItemAddListener addListener;
private int activePosition;
private int activePosition;
private boolean editable;
private boolean interactive;
public MediaRailAdapter(@NonNull GlideRequests glideRequests, @NonNull RailItemListener listener, boolean editable) {
this.glideRequests = glideRequests;
@ -36,6 +37,7 @@ public class MediaRailAdapter extends RecyclerView.Adapter<MediaRailAdapter.Medi
this.listener = listener;
this.editable = editable;
this.stableIdGenerator = new StableIdGenerator<>();
this.interactive = true;
setHasStableIds(true);
}
@ -57,7 +59,7 @@ public class MediaRailAdapter extends RecyclerView.Adapter<MediaRailAdapter.Medi
public void onBindViewHolder(@NonNull MediaRailViewHolder viewHolder, int i) {
switch (getItemViewType(i)) {
case TYPE_MEDIA:
((MediaViewHolder) viewHolder).bind(media.get(i), i == activePosition, glideRequests, listener, i - activePosition, editable);
((MediaViewHolder) viewHolder).bind(media.get(i), i == activePosition, glideRequests, listener, i - activePosition, editable, interactive);
break;
case TYPE_BUTTON:
((ButtonViewHolder) viewHolder).bind(addListener);
@ -121,6 +123,16 @@ public class MediaRailAdapter extends RecyclerView.Adapter<MediaRailAdapter.Medi
notifyDataSetChanged();
}
public void setEditable(boolean editable) {
this.editable = editable;
notifyDataSetChanged();
}
public void setInteractive(boolean interactive) {
this.interactive = interactive;
notifyDataSetChanged();
}
static abstract class MediaRailViewHolder extends RecyclerView.ViewHolder {
public MediaRailViewHolder(@NonNull View itemView) {
super(itemView);
@ -145,16 +157,17 @@ public class MediaRailAdapter extends RecyclerView.Adapter<MediaRailAdapter.Medi
}
void bind(@NonNull Media media, boolean isActive, @NonNull GlideRequests glideRequests,
@NonNull RailItemListener railItemListener, int distanceFromActive, boolean editable)
@NonNull RailItemListener railItemListener, int distanceFromActive, boolean editable,
boolean interactive)
{
image.setImageResource(glideRequests, media.getUri());
image.setOnClickListener(v -> railItemListener.onRailItemClicked(distanceFromActive));
outline.setVisibility(isActive ? View.VISIBLE : View.GONE);
outline.setVisibility(isActive && interactive ? View.VISIBLE : View.GONE);
captionIndicator.setVisibility(media.getCaption().isPresent() ? View.VISIBLE : View.GONE);
if (editable && isActive) {
if (editable && isActive && interactive) {
deleteButton.setVisibility(View.VISIBLE);
deleteButton.setOnClickListener(v -> railItemListener.onRailItemDeleteClicked(distanceFromActive));
} else {

View file

@ -27,7 +27,10 @@ import android.view.animation.DecelerateInterpolator;
import android.view.animation.RotateAnimation;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.MultiTransformation;
import com.bumptech.glide.load.Transformation;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
@ -36,10 +39,13 @@ import com.bumptech.glide.request.transition.Transition;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.ByteArrayOutputStream;
@ -106,6 +112,9 @@ public class Camera1Fragment extends Fragment implements CameraFragment,
GestureDetector gestureDetector = new GestureDetector(flipGestureListener);
cameraPreview.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event));
viewModel.getMostRecentMediaItem(requireContext()).observe(this, this::presentRecentItemThumbnail);
viewModel.getHudState().observe(this, this::presentHud);
}
@Override
@ -128,9 +137,6 @@ public class Camera1Fragment extends Fragment implements CameraFragment,
});
orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale);
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
}
@Override
@ -180,10 +186,46 @@ public class Camera1Fragment extends Fragment implements CameraFragment,
controller.onCameraError();
}
private void presentRecentItemThumbnail(Optional<Media> media) {
if (media == null) {
return;
}
ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button);
if (media.isPresent()) {
thumbnail.setVisibility(View.VISIBLE);
Glide.with(this)
.load(new DecryptableUri(media.get().getUri()))
.centerCrop()
.into(thumbnail);
} else {
thumbnail.setVisibility(View.GONE);
thumbnail.setImageResource(0);
}
}
private void presentHud(@Nullable MediaSendViewModel.HudState state) {
if (state == null) return;
View countButton = controlsContainer.findViewById(R.id.camera_count_button);
TextView countButtonText = controlsContainer.findViewById(R.id.mediasend_count_button_text);
if (state.getButtonState() == MediaSendViewModel.ButtonState.COUNT) {
countButton.setVisibility(View.VISIBLE);
countButtonText.setText(String.valueOf(state.getSelectionCount()));
} else {
countButton.setVisibility(View.GONE);
}
}
@SuppressLint("ClickableViewAccessibility")
private void initControls() {
flipButton = getView().findViewById(R.id.camera_flip_button);
captureButton = getView().findViewById(R.id.camera_capture_button);
flipButton = requireView().findViewById(R.id.camera_flip_button);
captureButton = requireView().findViewById(R.id.camera_capture_button);
View galleryButton = requireView().findViewById(R.id.camera_gallery_button);
View countButton = requireView().findViewById(R.id.camera_count_button);
captureButton.setOnTouchListener((v, event) -> {
switch (event.getAction()) {
@ -223,6 +265,11 @@ public class Camera1Fragment extends Fragment implements CameraFragment,
flipButton.setVisibility(View.GONE);
}
});
galleryButton.setOnClickListener(v -> controller.onGalleryClicked());
countButton.setOnClickListener(v -> controller.onContinueClicked());
viewModel.onCameraControlsInitialized();
}
private void onCaptureClicked() {

View file

@ -21,6 +21,8 @@ public interface CameraFragment {
interface Controller {
void onCameraError();
void onImageCaptured(@NonNull byte[] data, int width, int height);
void onGalleryClicked();
int getDisplayRotation();
void onContinueClicked();
}
}

View file

@ -4,6 +4,7 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.GestureDetector;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
@ -13,6 +14,8 @@ import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.RotateAnimation;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -23,13 +26,17 @@ import androidx.camera.core.ImageProxy;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import com.bumptech.glide.Glide;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXView;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
@ -84,6 +91,9 @@ public class CameraXFragment extends Fragment implements CameraFragment {
camera.setCameraLensFacing(CameraXUtil.toLensFacing(TextSecurePreferences.getDirectCaptureCameraId(requireContext())));
onOrientationChanged(getResources().getConfiguration().orientation);
viewModel.getMostRecentMediaItem(requireContext()).observe(this, this::presentRecentItemThumbnail);
viewModel.getHudState().observe(this, this::presentHud);
}
@Override
@ -116,10 +126,45 @@ public class CameraXFragment extends Fragment implements CameraFragment {
initControls();
}
private void presentRecentItemThumbnail(Optional<Media> media) {
if (media == null) {
return;
}
ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button);
if (media.isPresent()) {
thumbnail.setVisibility(View.VISIBLE);
Glide.with(this)
.load(new DecryptableUri(media.get().getUri()))
.centerCrop()
.into(thumbnail);
} else {
thumbnail.setVisibility(View.GONE);
thumbnail.setImageResource(0);
}
}
private void presentHud(@Nullable MediaSendViewModel.HudState state) {
if (state == null) return;
View countButton = controlsContainer.findViewById(R.id.camera_count_button);
TextView countButtonText = controlsContainer.findViewById(R.id.mediasend_count_button_text);
if (state.getButtonState() == MediaSendViewModel.ButtonState.COUNT) {
countButton.setVisibility(View.VISIBLE);
countButtonText.setText(String.valueOf(state.getSelectionCount()));
} else {
countButton.setVisibility(View.GONE);
}
}
@SuppressLint({"ClickableViewAccessibility", "MissingPermission"})
private void initControls() {
View flipButton = requireView().findViewById(R.id.camera_flip_button);
View captureButton = requireView().findViewById(R.id.camera_capture_button);
View galleryButton = requireView().findViewById(R.id.camera_gallery_button);
View countButton = requireView().findViewById(R.id.camera_count_button);
captureButton.setOnTouchListener((v, event) -> {
switch (event.getAction()) {
@ -154,9 +199,26 @@ public class CameraXFragment extends Fragment implements CameraFragment {
animation.setInterpolator(new DecelerateInterpolator());
flipButton.startAnimation(animation);
});
GestureDetector gestureDetector = new GestureDetector(requireContext(), new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDoubleTap(MotionEvent e) {
flipButton.performClick();
return true;
}
});
camera.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event));
} else {
flipButton.setVisibility(View.GONE);
}
galleryButton.setOnClickListener(v -> controller.onGalleryClicked());
countButton.setOnClickListener(v -> controller.onContinueClicked());
viewModel.onCameraControlsInitialized();
}
private void onCaptureClicked() {

View file

@ -13,6 +13,8 @@ import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.appcompat.widget.Toolbar;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
@ -51,6 +53,8 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
recipientName = getArguments().getString(KEY_RECIPIENT_NAME);
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
}
@ -92,14 +96,26 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo
@Override
public void onResume() {
super.onResume();
viewModel.onFolderPickerStarted();
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
public void onPrepareOptionsMenu(@NonNull Menu menu) {
requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
switch (item.getItemId()) {
case R.id.mediapicker_menu_camera:
controller.onCameraSelected();
return true;
}
return false;
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onScreenWidthChanged(getScreenWidth());
}
@ -131,5 +147,6 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo
public interface Controller {
void onFolderSelected(@NonNull MediaFolder folder);
void onCameraSelected();
}
}

View file

@ -104,41 +104,33 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem
}
viewModel.getMediaInBucket(requireContext(), bucketId).observe(this, adapter::setMedia);
initMediaObserver(viewModel);
}
@Override
public void onResume() {
super.onResume();
viewModel.onItemPickerStarted();
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
adapter.setForcedMultiSelect(true);
viewModel.onMultiSelectStarted();
}
@Override
public void onPrepareOptionsMenu(Menu menu) {
public void onPrepareOptionsMenu(@NonNull Menu menu) {
requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu);
if (viewModel.getCountButtonState().getValue() != null && viewModel.getCountButtonState().getValue().isVisible()) {
menu.findItem(R.id.mediapicker_menu_add).setVisible(false);
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
switch (item.getItemId()) {
case R.id.mediapicker_menu_add:
adapter.setForcedMultiSelect(true);
viewModel.onMultiSelectStarted();
case R.id.mediapicker_menu_camera:
controller.onCameraSelected();
return true;
}
return false;
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onScreenWidthChanged(getScreenWidth());
}
@ -172,12 +164,6 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
}
private void initMediaObserver(@NonNull MediaSendViewModel viewModel) {
viewModel.getCountButtonState().observe(this, media -> {
requireActivity().invalidateOptionsMenu();
});
}
private void onScreenWidthChanged(int newWidth) {
if (layoutManager != null) {
layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_item_width));
@ -192,5 +178,6 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem
public interface Controller {
void onMediaSelected(@NonNull Media media);
void onCameraSelected();
}
}

View file

@ -4,7 +4,6 @@ import android.annotation.TargetApi;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Environment;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Video;
@ -20,6 +19,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.File;
@ -40,14 +40,14 @@ class MediaRepository {
* Retrieves a list of folders that contain media.
*/
void getFolders(@NonNull Context context, @NonNull Callback<List<MediaFolder>> callback) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getFolders(context)));
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getFolders(context)));
}
/**
* Retrieves a list of media items (images and videos) that are present int he specified bucket.
*/
void getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Callback<List<Media>> callback) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getMediaInBucket(context, bucketId)));
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getMediaInBucket(context, bucketId)));
}
/**
@ -60,7 +60,11 @@ class MediaRepository {
return;
}
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getPopulatedMedia(context, media)));
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getPopulatedMedia(context, media)));
}
void getMostRecentItem(@NonNull Context context, @NonNull Callback<Optional<Media>> callback) {
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getMostRecentItem(context)));
}
@WorkerThread
@ -158,7 +162,7 @@ class MediaRepository {
}
@WorkerThread
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean hasOrienation) {
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean hasOrientation) {
List<Media> media = new LinkedList<>();
String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL";
String[] selectionArgs = new String[] { bucketId };
@ -166,7 +170,7 @@ class MediaRepository {
String[] projection;
if (hasOrienation) {
if (hasOrientation) {
projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE};
} else {
projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE};
@ -182,7 +186,7 @@ class MediaRepository {
Uri uri = Uri.withAppendedPath(contentUri, cursor.getString(cursor.getColumnIndexOrThrow(Images.Media._ID)));
String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE));
long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_TAKEN));
int orientation = hasOrienation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0;
int orientation = hasOrientation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0;
int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)));
int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)));
long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE));
@ -211,6 +215,12 @@ class MediaRepository {
}).toList();
}
@WorkerThread
private Optional<Media> getMostRecentItem(@NonNull Context context) {
List<Media> media = getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, Images.Media.EXTERNAL_CONTENT_URI, true);
return media.size() > 0 ? Optional.of(media.get(0)) : Optional.absent();
}
@TargetApi(16)
@SuppressWarnings("SuspiciousNameCombination")
private String getWidthColumn(int orientation) {

View file

@ -1,45 +1,76 @@
package org.thoughtcrime.securesms.mediasend;
import android.Manifest;
import androidx.fragment.app.FragmentTransaction;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.view.ContextThemeWrapper;
import androidx.lifecycle.ViewModelProviders;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.View;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.Animation;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.OvershootInterpolator;
import android.view.animation.ScaleAnimation;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.inputmethod.EditorInfo;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.components.ComposeText;
import org.thoughtcrime.securesms.components.InputAwareLayout;
import org.thoughtcrime.securesms.components.SendButton;
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.TimerState;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.views.Stub;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Encompasses the entire flow of sending media, starting from the selection process to the actual
@ -50,15 +81,19 @@ import java.util.Locale;
*/
public class MediaSendActivity extends PassphraseRequiredActionBarActivity implements MediaPickerFolderFragment.Controller,
MediaPickerItemFragment.Controller,
MediaSendFragment.Controller,
ImageEditorFragment.Controller,
CameraFragment.Controller
CameraFragment.Controller,
ViewTreeObserver.OnGlobalLayoutListener,
MediaRailAdapter.RailItemListener,
InputAwareLayout.OnKeyboardShownListener,
InputAwareLayout.OnKeyboardHiddenListener
{
private static final String TAG = MediaSendActivity.class.getSimpleName();
public static final String EXTRA_MEDIA = "media";
public static final String EXTRA_MESSAGE = "message";
public static final String EXTRA_TRANSPORT = "transport";
public static final String EXTRA_MEDIA = "media";
public static final String EXTRA_MESSAGE = "message";
public static final String EXTRA_TRANSPORT = "transport";
public static final String EXTRA_REVEAL_DURATION = "reveal_duration";
private static final String KEY_ADDRESS = "address";
@ -78,9 +113,25 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
private TransportOption transport;
private MediaSendViewModel viewModel;
private View countButton;
private TextView countButtonText;
private View cameraButton;
private InputAwareLayout hud;
private View captionAndRail;
private SendButton sendButton;
private ViewGroup sendButtonContainer;
private ComposeText composeText;
private ViewGroup composeRow;
private ViewGroup composeContainer;
private ViewGroup countButton;
private TextView countButtonText;
private EmojiEditText captionText;
private EmojiToggle emojiToggle;
private Stub<MediaKeyboard> emojiDrawer;
private TextView charactersLeft;
private RecyclerView mediaRail;
private MediaRailAdapter mediaRailAdapter;
private int visibleHeight;
private final Rect visibleBounds = new Rect();
/**
* Get an intent to launch the media send flow starting with the picker.
@ -131,15 +182,27 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
return;
}
countButton = findViewById(R.id.mediasend_count_button);
countButtonText = findViewById(R.id.mediasend_count_button_text);
cameraButton = findViewById(R.id.mediasend_camera_button);
hud = findViewById(R.id.mediasend_hud);
captionAndRail = findViewById(R.id.mediasend_caption_and_rail);
sendButton = findViewById(R.id.mediasend_send_button);
sendButtonContainer = findViewById(R.id.mediasend_send_button_bkg);
composeText = findViewById(R.id.mediasend_compose_text);
composeRow = findViewById(R.id.mediasend_compose_row);
composeContainer = findViewById(R.id.mediasend_compose_container);
countButton = findViewById(R.id.mediasend_count_button);
countButtonText = findViewById(R.id.mediasend_count_button_text);
captionText = findViewById(R.id.mediasend_caption);
emojiToggle = findViewById(R.id.mediasend_emoji_toggle);
charactersLeft = findViewById(R.id.mediasend_characters_left);
mediaRail = findViewById(R.id.mediasend_media_rail);
emojiDrawer = new Stub<>(findViewById(R.id.mediasend_emoji_drawer_stub));
viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
recipient = Recipient.from(this, Address.fromSerialized(getIntent().getStringExtra(KEY_ADDRESS)), true);
transport = getIntent().getParcelableExtra(KEY_TRANSPORT);
viewModel.setTransport(transport);
viewModel.setRecipient(recipient);
viewModel.onBodyChanged(getIntent().getStringExtra(KEY_BODY));
List<Media> media = getIntent().getParcelableArrayListExtra(KEY_MEDIA);
@ -165,19 +228,80 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
.commit();
}
initializeCountButtonObserver(transport, dynamicLanguage.getCurrentLocale());
initializeCameraButtonObserver();
initializeErrorObserver();
sendButton.setOnClickListener(v -> {
if (hud.isKeyboardOpen()) {
hud.hideSoftkey(composeText, null);
}
cameraButton.setOnClickListener(v -> {
int maxSelection = viewModel.getMaxSelection();
MediaSendFragment fragment = getMediaSendFragment();
if (viewModel.getSelectedMedia().getValue() != null && viewModel.getSelectedMedia().getValue().size() >= maxSelection) {
Toast.makeText(this, getResources().getQuantityString(R.plurals.MediaSendActivity_cant_share_more_than_n_items, maxSelection, maxSelection), Toast.LENGTH_SHORT).show();
if (fragment != null) {
processMedia(fragment.getAllMedia(), fragment.getSavedState());
} else {
navigateToCamera();
throw new AssertionError("No send fragment available!");
}
});
sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> {
presentCharactersRemaining();
composeText.setTransport(newTransport);
sendButtonContainer.getBackground().setColorFilter(newTransport.getBackgroundColor(), PorterDuff.Mode.MULTIPLY);
sendButtonContainer.getBackground().invalidateSelf();
});
ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener();
composeText.setOnKeyListener(composeKeyPressedListener);
composeText.addTextChangedListener(composeKeyPressedListener);
composeText.setOnClickListener(composeKeyPressedListener);
composeText.setOnFocusChangeListener(composeKeyPressedListener);
captionText.clearFocus();
composeText.requestFocus();
mediaRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, true);
mediaRail.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
mediaRail.setAdapter(mediaRailAdapter);
hud.getRootView().getViewTreeObserver().addOnGlobalLayoutListener(this);
hud.addOnKeyboardShownListener(this);
hud.addOnKeyboardHiddenListener(this);
captionText.addTextChangedListener(new SimpleTextWatcher() {
@Override
public void onTextChanged(String text) {
viewModel.onCaptionChanged(text);
}
});
sendButton.setTransport(transport);
sendButton.disableTransport(transport.getType() == TransportOption.Type.SMS ? TransportOption.Type.TEXTSECURE : TransportOption.Type.SMS);
countButton.setOnClickListener(v -> navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale()));
composeText.append(viewModel.getBody());
if (recipient.isLocalNumber()) {
composeText.setHint(getString(R.string.note_to_self), null);
} else {
String displayName = Optional.fromNullable(recipient.getName())
.or(Optional.fromNullable(recipient.getProfileName())
.or(recipient.getAddress().serialize()));
composeText.setHint(getString(R.string.MediaSendActivity_message_to_s, displayName), null);
}
composeText.setOnEditorActionListener((v, actionId, event) -> {
boolean isSend = actionId == EditorInfo.IME_ACTION_SEND;
if (isSend) sendButton.performClick();
return isSend;
});
if (TextSecurePreferences.isSystemEmojiPreferred(this)) {
emojiToggle.setVisibility(View.GONE);
} else {
emojiToggle.setOnClickListener(this::onEmojiToggleClicked);
}
initViewModel();
}
@Override
@ -188,13 +312,12 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
@Override
public void onBackPressed() {
MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
if (sendFragment == null || !sendFragment.isVisible() || !sendFragment.handleBackPress()) {
super.onBackPressed();
MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
if (getIntent().getBooleanExtra(KEY_IS_CAMERA, false) && getSupportFragmentManager().getBackStackEntryCount() == 0) {
viewModel.onImageCaptureUndo(this);
}
if (sendFragment == null || !sendFragment.isVisible() || !hud.isInputOpen()) {
super.onBackPressed();
} else {
hud.hideCurrentInput(composeText);
}
}
@ -209,7 +332,6 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
MediaPickerItemFragment fragment = MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), viewModel.getMaxSelection());
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right)
.replace(R.id.mediasend_fragment_container, fragment, TAG_ITEM_PICKER)
.addToBackStack(null)
.commit();
@ -218,48 +340,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
@Override
public void onMediaSelected(@NonNull Media media) {
viewModel.onSingleMediaSelected(this, media);
navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale(), false);
}
@Override
public void onAddMediaClicked(@NonNull String bucketId) {
// TODO: Get actual folder title somehow
MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient);
MediaPickerItemFragment itemFragment = MediaPickerItemFragment.newInstance(bucketId, "", viewModel.getMaxSelection());
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.stationary, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right)
.replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER)
.addToBackStack(null)
.commit();
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.slide_from_right, R.anim.stationary, R.anim.slide_from_left, R.anim.slide_to_right)
.replace(R.id.mediasend_fragment_container, itemFragment, TAG_ITEM_PICKER)
.addToBackStack(null)
.commit();
}
@Override
public void onSendClicked(@NonNull List<Media> media, @NonNull String message, @NonNull TransportOption transport) {
viewModel.onSendClicked();
ArrayList<Media> mediaList = new ArrayList<>(media);
Intent intent = new Intent();
intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList);
intent.putExtra(EXTRA_MESSAGE, message);
intent.putExtra(EXTRA_TRANSPORT, transport);
setResult(RESULT_OK, intent);
finish();
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom);
}
@Override
public void onNoMediaAvailable() {
setResult(RESULT_CANCELED);
finish();
navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale());
}
@Override
@ -307,7 +388,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
Log.i(TAG, "Camera capture stored: " + media.getUri().toString());
viewModel.onImageCaptured(media);
navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale(), true);
navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale());
});
}
@ -316,37 +397,203 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
return getWindowManager().getDefaultDisplay().getRotation();
}
private void initializeCountButtonObserver(@NonNull TransportOption transport, @NonNull Locale locale) {
viewModel.getCountButtonState().observe(this, buttonState -> {
if (buttonState == null) return;
@Override
public void onContinueClicked() {
navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale());
}
countButtonText.setText(String.valueOf(buttonState.getCount()));
countButton.setEnabled(buttonState.isVisible());
animateButtonVisibility(countButton, countButton.getVisibility(), buttonState.isVisible() ? View.VISIBLE : View.GONE);
@Override
public void onGalleryClicked() {
MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient);
if (buttonState.getCount() > 0) {
countButton.setOnClickListener(v -> navigateToMediaSend(recipient, transport, locale, false));
if (buttonState.isVisible()) {
animateButtonTextChange(countButton);
}
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER)
.setCustomAnimations(R.anim.slide_from_bottom, R.anim.stationary, R.anim.slide_to_bottom, R.anim.stationary)
.addToBackStack(null)
.commit();
}
@Override
public void onRequestFullScreen(boolean fullScreen) {
captionAndRail.setVisibility(fullScreen ? View.GONE : View.VISIBLE);
}
@Override
public void onGlobalLayout() {
hud.getRootView().getWindowVisibleDisplayFrame(visibleBounds);
int currentVisibleHeight = visibleBounds.height();
if (currentVisibleHeight != visibleHeight) {
hud.getLayoutParams().height = currentVisibleHeight;
hud.layout(visibleBounds.left, visibleBounds.top, visibleBounds.right, visibleBounds.bottom);
hud.requestLayout();
visibleHeight = currentVisibleHeight;
}
}
@Override
public void onKeyboardHidden() {
viewModel.onKeyboardHidden(sendButton.getSelectedTransport().isSms());
}
@Override
public void onKeyboardShown() {
viewModel.onKeyboardShown(composeText.hasFocus(), captionText.hasFocus(), sendButton.getSelectedTransport().isSms());
}
@Override
public void onRailItemClicked(int distanceFromActive) {
if (getMediaSendFragment() != null) {
viewModel.onPageChanged(getMediaSendFragment().getCurrentImagePosition() + distanceFromActive);
}
}
@Override
public void onRailItemDeleteClicked(int distanceFromActive) {
if (getMediaSendFragment() != null) {
viewModel.onMediaItemRemoved(this, getMediaSendFragment().getCurrentImagePosition() + distanceFromActive);
}
}
@Override
public void onCameraSelected() {
navigateToCamera();
}
public void onAddMediaClicked(@NonNull String bucketId) {
// TODO: Get actual folder title somehow
MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient);
MediaPickerItemFragment itemFragment = MediaPickerItemFragment.newInstance(bucketId, "", viewModel.getMaxSelection());
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER)
.addToBackStack(null)
.commit();
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediasend_fragment_container, itemFragment, TAG_ITEM_PICKER)
.addToBackStack(null)
.commit();
}
public void onSendClicked(@NonNull List<Media> media, @NonNull String message, @NonNull TransportOption transport) {
viewModel.onSendClicked();
ArrayList<Media> mediaList = new ArrayList<>(media);
if (mediaList.size() > 0) {
Intent intent = new Intent();
intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList);
intent.putExtra(EXTRA_MESSAGE, viewModel.getRevealDuration() == 0 ? message : "");
intent.putExtra(EXTRA_TRANSPORT, transport);
intent.putExtra(EXTRA_REVEAL_DURATION, viewModel.getRevealDuration());
setResult(RESULT_OK, intent);
} else {
setResult(RESULT_CANCELED);
}
finish();
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom);
}
public void onNoMediaAvailable() {
setResult(RESULT_CANCELED);
finish();
}
private void initViewModel() {
viewModel.getHudState().observe(this, state -> {
if (state == null) return;
hud.setVisibility(state.isHudVisible() ? View.VISIBLE : View.GONE);
composeContainer.setVisibility(state.isComposeVisible() ? View.VISIBLE : (state.getTimerState() == TimerState.GONE ? View.GONE : View.INVISIBLE));
captionText.setVisibility(state.isCaptionVisible() ? View.VISIBLE : View.GONE);
int captionBackground;
if (state.getRailState() == MediaSendViewModel.RailState.VIEWABLE) {
captionBackground = R.color.core_grey_90;
} else if (state.getTimerState() == TimerState.ENABLED) {
captionBackground = 0;
} else {
countButton.setOnClickListener(null);
captionBackground = R.color.transparent_black_70;
}
captionAndRail.setBackgroundResource(captionBackground);
switch (state.getButtonState()) {
case SEND:
sendButtonContainer.setVisibility(View.VISIBLE);
countButton.setVisibility(View.GONE);
break;
case COUNT:
sendButtonContainer.setVisibility(View.GONE);
countButton.setVisibility(View.VISIBLE);
countButtonText.setText(String.valueOf(state.getSelectionCount()));
break;
case GONE:
sendButtonContainer.setVisibility(View.GONE);
countButton.setVisibility(View.GONE);
break;
}
switch (state.getRailState()) {
case INTERACTIVE:
mediaRail.setVisibility(View.VISIBLE);
mediaRailAdapter.setEditable(true);
mediaRailAdapter.setInteractive(true);
break;
case VIEWABLE:
mediaRail.setVisibility(View.VISIBLE);
mediaRailAdapter.setEditable(false);
mediaRailAdapter.setInteractive(false);
break;
case GONE:
mediaRail.setVisibility(View.GONE);
break;
}
if (composeContainer.getVisibility() == View.GONE && sendButtonContainer.getVisibility() == View.GONE) {
composeRow.setVisibility(View.GONE);
} else {
composeRow.setVisibility(View.VISIBLE);
}
});
}
private void initializeCameraButtonObserver() {
viewModel.getCameraButtonVisibility().observe(this, visible -> {
if (visible == null) return;
animateButtonVisibility(cameraButton, cameraButton.getVisibility(), visible ? View.VISIBLE : View.GONE);
viewModel.getSelectedMedia().observe(this, media -> {
mediaRailAdapter.setMedia(media);
});
viewModel.getPosition().observe(this, position -> {
if (position == null || position < 0) return;
MediaSendFragment fragment = getMediaSendFragment();
if (fragment != null && fragment.getAllMedia().size() > position) {
captionText.setText(fragment.getAllMedia().get(position).getCaption().or(""));
}
mediaRailAdapter.setActivePosition(position);
mediaRail.smoothScrollToPosition(position);
});
viewModel.getBucketId().observe(this, bucketId -> {
if (bucketId == null) return;
mediaRailAdapter.setAddButtonListener(() -> onAddMediaClicked(bucketId));
});
}
private void initializeErrorObserver() {
viewModel.getError().observe(this, error -> {
if (error == null) return;
switch (error) {
case NO_ITEMS:
onNoMediaAvailable();
break;
case ITEM_TOO_LARGE:
Toast.makeText(this, R.string.MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit, Toast.LENGTH_LONG).show();
break;
@ -358,7 +605,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
});
}
private void navigateToMediaSend(@NonNull Recipient recipient, @NonNull TransportOption transport, @NonNull Locale locale, boolean fade) {
private void navigateToMediaSend(@NonNull Recipient recipient, @NonNull TransportOption transport, @NonNull Locale locale) {
MediaSendFragment fragment = MediaSendFragment.newInstance(recipient, transport, locale);
String backstackTag = null;
@ -367,17 +614,11 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
backstackTag = TAG_SEND;
}
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
if (fade) {
transaction.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_out, R.anim.fade_in);
} else {
transaction.setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right);
}
transaction.replace(R.id.mediasend_fragment_container, fragment, TAG_SEND)
.addToBackStack(backstackTag)
.commit();
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediasend_fragment_container, fragment, TAG_SEND)
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
.addToBackStack(backstackTag)
.commit();
}
private void navigateToCamera() {
@ -389,7 +630,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
.onAllGranted(() -> {
Fragment fragment = getOrCreateCameraFragment();
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right)
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
.replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA)
.addToBackStack(null)
.commit();
@ -400,66 +641,182 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
private Fragment getOrCreateCameraFragment() {
Fragment fragment = getSupportFragmentManager().findFragmentByTag(TAG_CAMERA);
return fragment != null ? fragment
: CameraFragment.newInstance();
return fragment != null ? fragment : CameraFragment.newInstance();
}
private void animateButtonVisibility(@NonNull View button, int oldVisibility, int newVisibility) {
if (oldVisibility == newVisibility) return;
private EmojiEditText getActiveInputField() {
if (captionText.hasFocus()) return captionText;
else return composeText;
}
if (button.getAnimation() != null) {
button.clearAnimation();
button.setVisibility(newVisibility);
} else if (newVisibility == View.VISIBLE) {
button.setVisibility(View.VISIBLE);
Animation animation = new ScaleAnimation(0, 1, 0, 1, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
animation.setDuration(250);
animation.setInterpolator(new OvershootInterpolator());
button.startAnimation(animation);
private void presentCharactersRemaining() {
String messageBody = composeText.getTextTrimmed();
TransportOption transportOption = sendButton.getSelectedTransport();
CharacterState characterState = transportOption.calculateCharacters(messageBody);
if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) {
charactersLeft.setText(String.format(dynamicLanguage.getCurrentLocale(),
"%d/%d (%d)",
characterState.charactersRemaining,
characterState.maxTotalMessageSize,
characterState.messagesSpent));
charactersLeft.setVisibility(View.VISIBLE);
} else {
Animation animation = new ScaleAnimation(1, 0, 1, 0, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
animation.setDuration(150);
animation.setInterpolator(new AccelerateDecelerateInterpolator());
animation.setAnimationListener(new SimpleAnimationListener() {
charactersLeft.setVisibility(View.GONE);
}
}
private void onEmojiToggleClicked(View v) {
if (!emojiDrawer.resolved()) {
emojiDrawer.get().setProviders(0, new EmojiKeyboardProvider(this, new EmojiKeyboardProvider.EmojiEventListener() {
@Override
public void onAnimationEnd(Animation animation) {
button.clearAnimation();
button.setVisibility(View.GONE);
public void onKeyEvent(KeyEvent keyEvent) {
getActiveInputField().dispatchKeyEvent(keyEvent);
}
});
button.startAnimation(animation);
@Override
public void onEmojiSelected(String emoji) {
getActiveInputField().insertEmoji(emoji);
}
}));
emojiToggle.attach(emojiDrawer.get());
}
if (hud.getCurrentInput() == emojiDrawer.get()) {
hud.showSoftkey(composeText);
} else {
hud.hideSoftkey(composeText, () -> hud.post(() -> hud.show(composeText, emojiDrawer.get())));
}
}
private void animateButtonTextChange(@NonNull View button) {
if (button.getAnimation() != null) {
button.clearAnimation();
}
@SuppressLint("StaticFieldLeak")
private void processMedia(@NonNull List<Media> mediaList, @NonNull Map<Uri, Object> savedState) {
Map<Media, EditorModel> modelsToRender = new HashMap<>();
Animation grow = new ScaleAnimation(1f, 1.3f, 1f, 1.3f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
grow.setDuration(125);
grow.setInterpolator(new AccelerateInterpolator());
grow.setAnimationListener(new SimpleAnimationListener() {
@Override
public void onAnimationEnd(Animation animation) {
Animation shrink = new ScaleAnimation(1.3f, 1f, 1.3f, 1f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
shrink.setDuration(125);
shrink.setInterpolator(new DecelerateInterpolator());
button.startAnimation(shrink);
for (Media media : mediaList) {
Object state = savedState.get(media.getUri());
if (state instanceof ImageEditorFragment.Data) {
EditorModel model = ((ImageEditorFragment.Data) state).readModel();
if (model != null && model.isChanged()) {
modelsToRender.put(media, model);
}
}
});
}
button.startAnimation(grow);
new AsyncTask<Void, Void, List<Media>>() {
private Stopwatch renderTimer;
private Runnable progressTimer;
private AlertDialog dialog;
@Override
protected void onPreExecute() {
renderTimer = new Stopwatch("ProcessMedia");
progressTimer = () -> {
dialog = new AlertDialog.Builder(new ContextThemeWrapper(MediaSendActivity.this, R.style.TextSecure_MediaSendProgressDialog))
.setView(R.layout.progress_dialog)
.setCancelable(false)
.create();
dialog.show();
dialog.getWindow().setLayout(getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size),
getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size));
};
Util.runOnMainDelayed(progressTimer, 250);
}
@Override
protected List<Media> doInBackground(Void... voids) {
Context context = MediaSendActivity.this;
List<Media> updatedMedia = new ArrayList<>(mediaList.size());
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
for (Media media : mediaList) {
EditorModel modelToRender = modelsToRender.get(media);
if (modelToRender != null) {
Bitmap bitmap = modelToRender.render(context);
try {
outputStream.reset();
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream);
Uri uri = BlobProvider.getInstance()
.forData(outputStream.toByteArray())
.withMimeType(MediaUtil.IMAGE_JPEG)
.createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Failed to write to disk.", e));
Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), outputStream.size(), media.getBucketId(), media.getCaption());
updatedMedia.add(updated);
renderTimer.split("item");
} catch (IOException e) {
Log.w(TAG, "Failed to render image. Using base image.");
updatedMedia.add(media);
} finally {
bitmap.recycle();
}
} else {
updatedMedia.add(media);
}
}
return updatedMedia;
}
@Override
protected void onPostExecute(List<Media> media) {
onSendClicked(media, composeText.getTextTrimmed(), sendButton.getSelectedTransport());
Util.cancelRunnableOnMain(progressTimer);
if (dialog != null) {
dialog.dismiss();
}
renderTimer.stop(TAG);
}
}.executeOnExecutor(SignalExecutors.BOUNDED);
}
@Override
public void onRequestFullScreen(boolean fullScreen) {
MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
if (sendFragment != null && sendFragment.isVisible()) {
sendFragment.onRequestFullScreen(fullScreen);
private @Nullable MediaSendFragment getMediaSendFragment() {
return (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
}
private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener {
int beforeLength;
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
if (keyCode == KeyEvent.KEYCODE_ENTER) {
if (TextSecurePreferences.isEnterSendsEnabled(getApplicationContext())) {
sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER));
sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER));
return true;
}
}
}
return false;
}
@Override
public void onClick(View v) {
hud.showSoftkey(composeText);
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count,int after) {
beforeLength = composeText.getTextTrimmed().length();
}
@Override
public void afterTextChanged(Editable s) {
presentCharactersRemaining();
viewModel.onBodyChanged(s);
}
@Override
public void onTextChanged(CharSequence s, int start, int before,int count) {}
@Override
public void onFocusChange(View v, boolean hasFocus) {}
}
}

View file

@ -1,80 +1,31 @@
package org.thoughtcrime.securesms.mediasend;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import androidx.lifecycle.ViewModelProviders;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.viewpager.widget.ViewPager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.view.ContextThemeWrapper;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.components.ComposeText;
import org.thoughtcrime.securesms.components.ControllableViewPager;
import org.thoughtcrime.securesms.components.InputAwareLayout;
import org.thoughtcrime.securesms.components.SendButton;
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.thoughtcrime.securesms.util.views.Stub;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutionException;
/**
* Allows the user to edit and caption a set of media items before choosing to send them.
*/
public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGlobalLayoutListener,
MediaRailAdapter.RailItemListener,
InputAwareLayout.OnKeyboardShownListener,
InputAwareLayout.OnKeyboardHiddenListener
{
public class MediaSendFragment extends Fragment {
private static final String TAG = MediaSendFragment.class.getSimpleName();
@ -82,28 +33,12 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
private static final String KEY_TRANSPORT = "transport";
private static final String KEY_LOCALE = "locale";
private InputAwareLayout hud;
private View captionAndRail;
private SendButton sendButton;
private ComposeText composeText;
private ViewGroup composeContainer;
private EmojiEditText captionText;
private EmojiToggle emojiToggle;
private Stub<MediaKeyboard> emojiDrawer;
private ViewGroup playbackControlsContainer;
private TextView charactersLeft;
private ViewGroup playbackControlsContainer;
private ControllableViewPager fragmentPager;
private MediaSendFragmentPagerAdapter fragmentPagerAdapter;
private RecyclerView mediaRail;
private MediaRailAdapter mediaRailAdapter;
private int visibleHeight;
private MediaSendViewModel viewModel;
private Controller controller;
private Locale locale;
private final Rect visibleBounds = new Rect();
public static MediaSendFragment newInstance(@NonNull Recipient recipient, @NonNull TransportOption transport, @NonNull Locale locale) {
Bundle args = new Bundle();
@ -116,17 +51,6 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
return fragment;
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
if (!(requireActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement controller interface.");
}
controller = (Controller) requireActivity();
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return ThemeUtil.getThemedInflater(requireActivity(), inflater, R.style.TextSecure_DarkTheme)
@ -137,52 +61,14 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
locale = (Locale) getArguments().getSerializable(KEY_LOCALE);
initViewModel();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
hud = view.findViewById(R.id.mediasend_hud);
captionAndRail = view.findViewById(R.id.mediasend_caption_and_rail);
sendButton = view.findViewById(R.id.mediasend_send_button);
composeText = view.findViewById(R.id.mediasend_compose_text);
composeContainer = view.findViewById(R.id.mediasend_compose_container);
captionText = view.findViewById(R.id.mediasend_caption);
emojiToggle = view.findViewById(R.id.mediasend_emoji_toggle);
emojiDrawer = new Stub<>(view.findViewById(R.id.mediasend_emoji_drawer_stub));
fragmentPager = view.findViewById(R.id.mediasend_pager);
mediaRail = view.findViewById(R.id.mediasend_media_rail);
playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container);
charactersLeft = view.findViewById(R.id.mediasend_characters_left);
View sendButtonBkg = view.findViewById(R.id.mediasend_send_button_bkg);
sendButton.setOnClickListener(v -> {
if (hud.isKeyboardOpen()) {
hud.hideSoftkey(composeText, null);
}
processMedia(fragmentPagerAdapter.getAllMedia(), fragmentPagerAdapter.getSavedState());
});
sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> {
presentCharactersRemaining();
composeText.setTransport(newTransport);
sendButtonBkg.getBackground().setColorFilter(newTransport.getBackgroundColor(), PorterDuff.Mode.MULTIPLY);
sendButtonBkg.getBackground().invalidateSelf();
});
ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener();
composeText.setOnKeyListener(composeKeyPressedListener);
composeText.addTextChangedListener(composeKeyPressedListener);
composeText.setOnClickListener(composeKeyPressedListener);
composeText.setOnFocusChangeListener(composeKeyPressedListener);
captionText.clearFocus();
composeText.requestFocus();
fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager());
fragmentPager.setAdapter(fragmentPagerAdapter);
@ -190,45 +76,6 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
FragmentPageChangeListener pageChangeListener = new FragmentPageChangeListener();
fragmentPager.addOnPageChangeListener(pageChangeListener);
fragmentPager.post(() -> pageChangeListener.onPageSelected(fragmentPager.getCurrentItem()));
mediaRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, true);
mediaRail.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false));
mediaRail.setAdapter(mediaRailAdapter);
hud.getRootView().getViewTreeObserver().addOnGlobalLayoutListener(this);
hud.addOnKeyboardShownListener(this);
hud.addOnKeyboardHiddenListener(this);
captionText.addTextChangedListener(new SimpleTextWatcher() {
@Override
public void onTextChanged(String text) {
viewModel.onCaptionChanged(text);
}
});
TransportOption transportOption = getArguments().getParcelable(KEY_TRANSPORT);
sendButton.setTransport(transportOption);
sendButton.disableTransport(transportOption.getType() == TransportOption.Type.SMS ? TransportOption.Type.TEXTSECURE : TransportOption.Type.SMS);
composeText.append(viewModel.getBody());
Recipient recipient = Recipient.from(requireContext(), getArguments().getParcelable(KEY_ADDRESS), false);
String displayName = Optional.fromNullable(recipient.getName())
.or(Optional.fromNullable(recipient.getProfileName())
.or(recipient.getAddress().serialize()));
composeText.setHint(getString(R.string.MediaSendActivity_message_to_s, displayName), null);
composeText.setOnEditorActionListener((v, actionId, event) -> {
boolean isSend = actionId == EditorInfo.IME_ACTION_SEND;
if (isSend) sendButton.performClick();
return isSend;
});
if (TextSecurePreferences.isSystemEmojiPreferred(getContext())) {
emojiToggle.setVisibility(View.GONE);
} else {
emojiToggle.setOnClickListener(this::onEmojiToggleClicked);
}
}
@Override
@ -237,9 +84,6 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
fragmentPagerAdapter.restoreState(viewModel.getDrawState());
viewModel.onImageEditorStarted();
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
}
@Override
@ -254,82 +98,22 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
viewModel.saveDrawState(fragmentPagerAdapter.getSavedState());
}
@Override
public void onGlobalLayout() {
hud.getRootView().getWindowVisibleDisplayFrame(visibleBounds);
int currentVisibleHeight = visibleBounds.height();
if (currentVisibleHeight != visibleHeight) {
hud.getLayoutParams().height = currentVisibleHeight;
hud.layout(visibleBounds.left, visibleBounds.top, visibleBounds.right, visibleBounds.bottom);
hud.requestLayout();
visibleHeight = currentVisibleHeight;
}
}
@Override
public void onRailItemClicked(int distanceFromActive) {
viewModel.onPageChanged(fragmentPager.getCurrentItem() + distanceFromActive);
}
@Override
public void onRailItemDeleteClicked(int distanceFromActive) {
viewModel.onMediaItemRemoved(requireContext(), fragmentPager.getCurrentItem() + distanceFromActive);
}
@Override
public void onKeyboardShown() {
if (sendButton.getSelectedTransport().isSms()) {
mediaRail.setVisibility(View.GONE);
composeContainer.setVisibility(View.VISIBLE);
captionText.setVisibility(View.GONE);
} else {
if (captionText.hasFocus()) {
mediaRail.setVisibility(View.VISIBLE);
composeContainer.setVisibility(View.GONE);
captionText.setVisibility(View.VISIBLE);
} else if (composeText.hasFocus()) {
mediaRail.setVisibility(View.VISIBLE);
composeContainer.setVisibility(View.VISIBLE);
captionText.setVisibility(View.GONE);
} else {
mediaRail.setVisibility(View.GONE);
composeContainer.setVisibility(View.VISIBLE);
captionText.setVisibility(View.GONE);
}
}
}
@Override
public void onKeyboardHidden() {
composeContainer.setVisibility(View.VISIBLE);
if (sendButton.getSelectedTransport().isSms()) {
mediaRail.setVisibility(View.GONE);
captionText.setVisibility(View.GONE);
} else {
mediaRail.setVisibility(View.VISIBLE);
if (!Util.isEmpty(viewModel.getSelectedMedia().getValue()) && viewModel.getSelectedMedia().getValue().size() > 1) {
captionText.setVisibility(View.VISIBLE);
}
}
}
public void onTouchEventsNeeded(boolean needed) {
if (fragmentPager != null) {
fragmentPager.setEnabled(!needed);
}
}
public boolean handleBackPress() {
if (hud.isInputOpen()) {
hud.hideCurrentInput(composeText);
return true;
}
return false;
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() {
@ -337,27 +121,16 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
viewModel.getSelectedMedia().observe(this, media -> {
if (Util.isEmpty(media)) {
controller.onNoMediaAvailable();
return;
}
fragmentPagerAdapter.setMedia(media);
mediaRail.setVisibility(sendButton.getSelectedTransport().isSms() ? View.GONE : View.VISIBLE);
captionText.setVisibility((media.size() > 1 || media.get(0).getCaption().isPresent()) ? View.VISIBLE : View.GONE);
mediaRailAdapter.setMedia(media);
});
viewModel.getPosition().observe(this, position -> {
if (position == null || position < 0) return;
fragmentPager.setCurrentItem(position, true);
mediaRailAdapter.setActivePosition(position);
mediaRail.smoothScrollToPosition(position);
if (fragmentPagerAdapter.getAllMedia().size() > position) {
captionText.setText(fragmentPagerAdapter.getAllMedia().get(position).getCaption().or(""));
}
View playbackControls = fragmentPagerAdapter.getPlaybackControls(position);
@ -370,146 +143,6 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
playbackControlsContainer.removeAllViews();
}
});
viewModel.getBucketId().observe(this, bucketId -> {
if (bucketId == null) return;
mediaRailAdapter.setAddButtonListener(() -> controller.onAddMediaClicked(bucketId));
});
}
private EmojiEditText getActiveInputField() {
if (captionText.hasFocus()) return captionText;
else return composeText;
}
private void presentCharactersRemaining() {
String messageBody = composeText.getTextTrimmed();
TransportOption transportOption = sendButton.getSelectedTransport();
CharacterState characterState = transportOption.calculateCharacters(messageBody);
if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) {
charactersLeft.setText(String.format(locale,
"%d/%d (%d)",
characterState.charactersRemaining,
characterState.maxTotalMessageSize,
characterState.messagesSpent));
charactersLeft.setVisibility(View.VISIBLE);
} else {
charactersLeft.setVisibility(View.GONE);
}
}
private void onEmojiToggleClicked(View v) {
if (!emojiDrawer.resolved()) {
emojiDrawer.get().setProviders(0, new EmojiKeyboardProvider(requireContext(), new EmojiKeyboardProvider.EmojiEventListener() {
@Override
public void onKeyEvent(KeyEvent keyEvent) {
getActiveInputField().dispatchKeyEvent(keyEvent);
}
@Override
public void onEmojiSelected(String emoji) {
getActiveInputField().insertEmoji(emoji);
}
}));
emojiToggle.attach(emojiDrawer.get());
}
if (hud.getCurrentInput() == emojiDrawer.get()) {
hud.showSoftkey(composeText);
} else {
hud.hideSoftkey(composeText, () -> hud.post(() -> hud.show(composeText, emojiDrawer.get())));
}
}
@SuppressLint("StaticFieldLeak")
private void processMedia(@NonNull List<Media> mediaList, @NonNull Map<Uri, Object> savedState) {
Map<Media, EditorModel> modelsToRender = new HashMap<>();
for (Media media : mediaList) {
Object state = savedState.get(media.getUri());
if (state instanceof ImageEditorFragment.Data) {
EditorModel model = ((ImageEditorFragment.Data) state).readModel();
if (model != null && model.isChanged()) {
modelsToRender.put(media, model);
}
}
}
new AsyncTask<Void, Void, List<Media>>() {
private Stopwatch renderTimer;
private Runnable progressTimer;
private AlertDialog dialog;
@Override
protected void onPreExecute() {
renderTimer = new Stopwatch("ProcessMedia");
progressTimer = () -> {
dialog = new AlertDialog.Builder(new ContextThemeWrapper(requireContext(), R.style.TextSecure_MediaSendProgressDialog))
.setView(R.layout.progress_dialog)
.setCancelable(false)
.create();
dialog.show();
dialog.getWindow().setLayout(getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size),
getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size));
};
Util.runOnMainDelayed(progressTimer, 250);
}
@Override
protected List<Media> doInBackground(Void... voids) {
Context context = requireContext();
List<Media> updatedMedia = new ArrayList<>(mediaList.size());
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
for (Media media : mediaList) {
EditorModel modelToRender = modelsToRender.get(media);
if (modelToRender != null) {
Bitmap bitmap = modelToRender.render(context);
try {
outputStream.reset();
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream);
Uri uri = BlobProvider.getInstance()
.forData(outputStream.toByteArray())
.withMimeType(MediaUtil.IMAGE_JPEG)
.createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Failed to write to disk.", e));
Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), outputStream.size(), media.getBucketId(), media.getCaption());
updatedMedia.add(updated);
renderTimer.split("item");
} catch (IOException e) {
Log.w(TAG, "Failed to render image. Using base image.");
updatedMedia.add(media);
} finally {
bitmap.recycle();
}
} else {
updatedMedia.add(media);
}
}
return updatedMedia;
}
@Override
protected void onPostExecute(List<Media> media) {
controller.onSendClicked(media, composeText.getTextTrimmed(), sendButton.getSelectedTransport());
Util.cancelRunnableOnMain(progressTimer);
if (dialog != null) {
dialog.dismiss();
}
renderTimer.stop(TAG);
}
}.execute();
}
public void onRequestFullScreen(boolean fullScreen) {
captionAndRail.setVisibility(fullScreen ? View.GONE : View.VISIBLE);
}
private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
@ -518,51 +151,4 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
viewModel.onPageChanged(position);
}
}
private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener {
int beforeLength;
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
if (keyCode == KeyEvent.KEYCODE_ENTER) {
if (TextSecurePreferences.isEnterSendsEnabled(requireContext())) {
sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER));
sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER));
return true;
}
}
}
return false;
}
@Override
public void onClick(View v) {
hud.showSoftkey(composeText);
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count,int after) {
beforeLength = composeText.getTextTrimmed().length();
}
@Override
public void afterTextChanged(Editable s) {
presentCharactersRemaining();
viewModel.onBodyChanged(s);
}
@Override
public void onTextChanged(CharSequence s, int start, int before,int count) {}
@Override
public void onFocusChange(View v, boolean hasFocus) {}
}
public interface Controller {
void onAddMediaClicked(@NonNull String bucketId);
void onSendClicked(@NonNull List<Media> media, @NonNull String body, @NonNull TransportOption transport);
void onNoMediaAvailable();
}
}

View file

@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.Util;
@ -41,52 +42,69 @@ class MediaSendViewModel extends ViewModel {
private final MediaRepository repository;
private final MutableLiveData<List<Media>> selectedMedia;
private final MutableLiveData<List<Media>> bucketMedia;
private final MutableLiveData<Optional<Media>> mostRecentMedia;
private final MutableLiveData<Integer> position;
private final MutableLiveData<String> bucketId;
private final MutableLiveData<List<MediaFolder>> folders;
private final MutableLiveData<CountButtonState> countButtonState;
private final MutableLiveData<Boolean> cameraButtonVisibility;
private final MutableLiveData<HudState> hudState;
private final SingleLiveEvent<Error> error;
private final Map<Uri, Object> savedDrawState;
private MediaConstraints mediaConstraints;
private CharSequence body;
private CountButtonState.Visibility countButtonVisibility;
private boolean sentMedia;
private Optional<Media> lastImageCapture;
private int maxSelection;
private MediaConstraints mediaConstraints;
private CharSequence body;
private boolean sentMedia;
private int maxSelection;
private Page page;
private boolean isSms;
private boolean isNoteToSelf;
private Optional<Media> lastCameraCapture;
private boolean hudVisible;
private boolean composeVisible;
private boolean captionVisible;
private ButtonState buttonState;
private RailState railState;
private TimerState timerState;
private MediaSendViewModel(@NonNull Application application, @NonNull MediaRepository repository) {
this.application = application;
this.repository = repository;
this.selectedMedia = new MutableLiveData<>();
this.bucketMedia = new MutableLiveData<>();
this.mostRecentMedia = new MutableLiveData<>();
this.position = new MutableLiveData<>();
this.bucketId = new MutableLiveData<>();
this.folders = new MutableLiveData<>();
this.countButtonState = new MutableLiveData<>();
this.cameraButtonVisibility = new MutableLiveData<>();
this.hudState = new MutableLiveData<>();
this.error = new SingleLiveEvent<>();
this.savedDrawState = new HashMap<>();
this.countButtonVisibility = CountButtonState.Visibility.FORCED_OFF;
this.lastImageCapture = Optional.absent();
this.lastCameraCapture = Optional.absent();
this.body = "";
this.buttonState = ButtonState.GONE;
this.railState = RailState.GONE;
this.timerState = TimerState.GONE;
this.page = Page.UNKNOWN;
position.setValue(-1);
countButtonState.setValue(new CountButtonState(0, countButtonVisibility));
cameraButtonVisibility.setValue(false);
}
void setTransport(@NonNull TransportOption transport) {
if (transport.isSms()) {
isSms = true;
maxSelection = MAX_SMS;
mediaConstraints = MediaConstraints.getMmsMediaConstraints(transport.getSimSubscriptionId().or(-1));
} else {
isSms = false;
maxSelection = MAX_PUSH;
mediaConstraints = MediaConstraints.getPushMediaConstraints();
}
}
void setRecipient(@NonNull Recipient recipient) {
isNoteToSelf = recipient.isLocalNumber();
}
void onSelectedMediaChanged(@NonNull Context context, @NonNull List<Media> newMedia) {
repository.getPopulatedMedia(context, newMedia, populatedMedia -> {
Util.runOnMain(() -> {
@ -113,11 +131,19 @@ class MediaSendViewModel extends ViewModel {
bucketId.setValue(computedId);
} else {
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
countButtonVisibility = CountButtonState.Visibility.CONDITIONAL;
}
selectedMedia.setValue(filteredMedia);
countButtonState.setValue(new CountButtonState(filteredMedia.size(), countButtonVisibility));
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());
}
});
});
}
@ -134,41 +160,131 @@ class MediaSendViewModel extends ViewModel {
bucketId.setValue(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID));
}
countButtonVisibility = CountButtonState.Visibility.FORCED_OFF;
selectedMedia.setValue(filteredMedia);
countButtonState.setValue(new CountButtonState(filteredMedia.size(), countButtonVisibility));
});
});
}
void onMultiSelectStarted() {
countButtonVisibility = CountButtonState.Visibility.FORCED_ON;
countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility));
hudVisible = true;
composeVisible = false;
captionVisible = false;
buttonState = ButtonState.COUNT;
railState = RailState.VIEWABLE;
timerState = TimerState.GONE;
hudState.setValue(buildHudState());
}
void onImageEditorStarted() {
countButtonVisibility = CountButtonState.Visibility.FORCED_OFF;
countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility));
cameraButtonVisibility.setValue(false);
page = Page.EDITOR;
hudVisible = true;
composeVisible = timerState != TimerState.ENABLED;
captionVisible = getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent());
buttonState = ButtonState.SEND;
railState = !isSms ? RailState.INTERACTIVE : RailState.GONE;
hudState.setValue(buildHudState());
}
void onCameraStarted() {
countButtonVisibility = CountButtonState.Visibility.CONDITIONAL;
countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility));
cameraButtonVisibility.setValue(false);
Page previous = page;
page = Page.CAMERA;
hudVisible = false;
timerState = TimerState.GONE;
buttonState = ButtonState.COUNT;
List<Media> selected = getSelectedMediaOrDefault();
if (previous == Page.EDITOR && lastCameraCapture.isPresent() && selected.contains(lastCameraCapture.get()) && selected.size() == 1) {
selected.remove(lastCameraCapture.get());
selectedMedia.setValue(selected);
BlobProvider.getInstance().delete(application, lastCameraCapture.get().getUri());
}
hudState.setValue(buildHudState());
}
void onItemPickerStarted() {
countButtonVisibility = CountButtonState.Visibility.CONDITIONAL;
countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility));
cameraButtonVisibility.setValue(true);
page = Page.ITEM_PICKER;
hudVisible = true;
composeVisible = false;
captionVisible = false;
buttonState = ButtonState.COUNT;
timerState = TimerState.GONE;
railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE;
lastCameraCapture = Optional.absent();
hudState.setValue(buildHudState());
}
void onFolderPickerStarted() {
countButtonVisibility = CountButtonState.Visibility.CONDITIONAL;
countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility));
cameraButtonVisibility.setValue(true);
page = Page.FOLDER_PICKER;
hudVisible = true;
composeVisible = false;
captionVisible = false;
buttonState = ButtonState.COUNT;
timerState = TimerState.GONE;
railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE;
lastCameraCapture = Optional.absent();
hudState.setValue(buildHudState());
}
void onTimerButtonToggled() {
hudVisible = true;
timerState = (timerState == TimerState.ENABLED) ? TimerState.DISABLED : TimerState.ENABLED;
composeVisible = (timerState != TimerState.ENABLED);
hudState.setValue(buildHudState());
}
void onKeyboardHidden(boolean isSms) {
if (page != Page.EDITOR) return;
composeVisible = (timerState != TimerState.ENABLED);
buttonState = ButtonState.SEND;
if (isSms) {
railState = RailState.GONE;
captionVisible = false;
} else {
railState = RailState.INTERACTIVE;
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 = (timerState == TimerState.GONE);
captionVisible = false;
buttonState = ButtonState.SEND;
} else {
if (isCaptionFocused) {
railState = RailState.INTERACTIVE;
composeVisible = false;
captionVisible = true;
buttonState = ButtonState.GONE;
} else if (isComposeFocused) {
railState = RailState.INTERACTIVE;
composeVisible = (timerState != TimerState.ENABLED);
captionVisible = false;
buttonState = ButtonState.SEND;
}
}
hudState.setValue(buildHudState());
}
void onBodyChanged(@NonNull CharSequence body) {
@ -201,10 +317,22 @@ class MediaSendViewModel extends ViewModel {
BlobProvider.getInstance().delete(context, removed.getUri());
}
selectedMedia.setValue(selectedMedia.getValue());
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));
}
hudState.setValue(buildHudState());
}
void onImageCaptured(@NonNull Media media) {
lastCameraCapture = Optional.of(media);
List<Media> selected = selectedMedia.getValue();
if (selected == null) {
@ -216,40 +344,32 @@ class MediaSendViewModel extends ViewModel {
return;
}
lastImageCapture = Optional.of(media);
selected.add(media);
selectedMedia.setValue(selected);
position.setValue(selected.size() - 1);
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
if (selected.size() == 1) {
countButtonVisibility = CountButtonState.Visibility.FORCED_OFF;
} else {
countButtonVisibility = CountButtonState.Visibility.CONDITIONAL;
}
countButtonState.setValue(new CountButtonState(selected.size(), countButtonVisibility));
}
void onImageCaptureUndo(@NonNull Context context) {
List<Media> selected = getSelectedMediaOrDefault();
if (lastImageCapture.isPresent() && selected.contains(lastImageCapture.get()) && selected.size() == 1) {
selected.remove(lastImageCapture.get());
if (lastCameraCapture.isPresent() && selected.contains(lastCameraCapture.get()) && selected.size() == 1) {
selected.remove(lastCameraCapture.get());
selectedMedia.setValue(selected);
countButtonState.setValue(new CountButtonState(selected.size(), countButtonVisibility));
BlobProvider.getInstance().delete(context, lastImageCapture.get().getUri());
BlobProvider.getInstance().delete(context, lastCameraCapture.get().getUri());
}
}
void onCaptionChanged(@NonNull String newCaption) {
if (position.getValue() >= 0 && !Util.isEmpty(selectedMedia.getValue())) {
selectedMedia.getValue().get(position.getValue()).setCaption(TextUtils.isEmpty(newCaption) ? null : newCaption);
}
}
void onCameraControlsInitialized() {
repository.getMostRecentItem(application, mostRecentMedia::postValue);
}
void saveDrawState(@NonNull Map<Uri, Object> state) {
savedDrawState.clear();
savedDrawState.putAll(state);
@ -277,12 +397,8 @@ class MediaSendViewModel extends ViewModel {
return folders;
}
@NonNull LiveData<CountButtonState> getCountButtonState() {
return countButtonState;
}
@NonNull LiveData<Boolean> getCameraButtonVisibility() {
return cameraButtonVisibility;
@NonNull LiveData<Optional<Media>> getMostRecentMediaItem(@NonNull Context context) {
return mostRecentMedia;
}
@NonNull CharSequence getBody() {
@ -301,10 +417,18 @@ class MediaSendViewModel extends ViewModel {
return error;
}
@NonNull LiveData<HudState> getHudState() {
return hudState;
}
int getMaxSelection() {
return maxSelection;
}
long getRevealDuration() {
return 0;
}
private @NonNull List<Media> getSelectedMediaOrDefault() {
return selectedMedia.getValue() == null ? Collections.emptyList()
: selectedMedia.getValue();
@ -322,44 +446,102 @@ class MediaSendViewModel extends ViewModel {
}
private HudState buildHudState() {
List<Media> selectedMedia = getSelectedMediaOrDefault();
int selectionCount = selectedMedia.size();
ButtonState updatedButtonState = buttonState == ButtonState.COUNT && selectionCount == 0 ? ButtonState.GONE : buttonState;
boolean updatdCaptionVisible = captionVisible && (selectedMedia.size() > 1 || (selectedMedia.size() > 0 && selectedMedia.get(0).getCaption().isPresent()));
return new HudState(hudVisible, composeVisible, updatdCaptionVisible, selectionCount, updatedButtonState, railState, timerState);
}
private void clearPersistedMedia() {
Stream.of(getSelectedMediaOrDefault())
.map(Media::getUri)
.filter(BlobProvider::isAuthority)
.forEach(uri -> BlobProvider.getInstance().delete(application.getApplicationContext(), uri));
}
@Override
protected void onCleared() {
if (!sentMedia) {
Stream.of(getSelectedMediaOrDefault())
.map(Media::getUri)
.filter(BlobProvider::isAuthority)
.forEach(uri -> BlobProvider.getInstance().delete(application.getApplicationContext(), uri));
clearPersistedMedia();
}
}
enum Error {
ITEM_TOO_LARGE, TOO_MANY_ITEMS
ITEM_TOO_LARGE, TOO_MANY_ITEMS, NO_ITEMS
}
static class CountButtonState {
private final int count;
private final Visibility visibility;
enum Page {
CAMERA, ITEM_PICKER, FOLDER_PICKER, EDITOR, UNKNOWN
}
private CountButtonState(int count, @NonNull Visibility visibility) {
this.count = count;
this.visibility = visibility;
enum ButtonState {
COUNT, SEND, GONE
}
enum RailState {
INTERACTIVE, VIEWABLE, GONE
}
enum TimerState {
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 TimerState timerState;
HudState(boolean hudVisible,
boolean composeVisible,
boolean captionVisible,
int selectionCount,
@NonNull ButtonState buttonState,
@NonNull RailState railState,
@NonNull TimerState timerState)
{
this.hudVisible = hudVisible;
this.composeVisible = composeVisible;
this.captionVisible = captionVisible;
this.selectionCount = selectionCount;
this.buttonState = buttonState;
this.railState = railState;
this.timerState = timerState;
}
int getCount() {
return count;
public boolean isHudVisible() {
return hudVisible;
}
boolean isVisible() {
switch (visibility) {
case FORCED_ON: return true;
case FORCED_OFF: return false;
case CONDITIONAL: return count > 0;
default: return false;
}
public boolean isComposeVisible() {
return hudVisible && composeVisible;
}
enum Visibility {
CONDITIONAL, FORCED_ON, FORCED_OFF
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 TimerState getTimerState() {
return hudVisible ? timerState : TimerState.GONE;
}
}

View file

@ -20,7 +20,7 @@ public class SimpleTask {
return;
}
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
SignalExecutors.BOUNDED.execute(() -> {
final E result = backgroundTask.run();
if (isValid(lifecycle)) {
@ -38,7 +38,7 @@ public class SimpleTask {
* the main thread. Essentially {@link AsyncTask}, but lambda-compatible.
*/
public static <E> void run(@NonNull BackgroundTask<E> backgroundTask, @NonNull ForegroundTask<E> foregroundTask) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
SignalExecutors.BOUNDED.execute(() -> {
final E result = backgroundTask.run();
Util.runOnMain(() -> foregroundTask.run(result));
});