Implement new media send flow.

Update our media send flow to allow users to send multiple images/videos
at once. This change includes:

- New in-app media picker flow.
- Ability to caption images and videos.
- Image editing tools are made more prominent in the flow.
- Some fixes to the image editing tools.
This commit is contained in:
Greyson Parrelli 2018-11-20 09:59:23 -08:00
parent bae55f4b2f
commit 6fa7eca60b
83 changed files with 3270 additions and 247 deletions

View File

@ -278,6 +278,11 @@
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".mediasend.MediaSendActivity"
android:theme="@style/TextSecure.LightNoActionBar"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".PassphraseChangeActivity"
android:label="@string/AndroidManifest__change_passphrase"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1px"
android:color="@color/transparent_white_30" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1px"
android:color="@color/transparent_black_30" />
</shape>

View File

@ -1,12 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.ThumbnailView
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/rail_item_image"
android:layout_width="48dp"
android:layout_height="48sp"
android:layout_margin="2dp"
android:padding="2dp"
android:background="@drawable/album_rail_item_background"
app:thumbnail_radius="2dp"/>
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:animateLayoutChanges="true">
<org.thoughtcrime.securesms.components.ThumbnailView
android:id="@+id/rail_item_image"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_margin="2dp"
android:padding="2dp"
android:background="@drawable/media_rail_item_background"
app:thumbnail_radius="0dp"/>
<ImageView
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_gravity="center"
android:src="@drawable/mediapicker_item_border_dark"/>
<ImageView
android:id="@+id/rail_item_delete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right|end|top"
android:src="@drawable/ic_x_circle"
android:elevation="8dp"
android:visibility="gone"
tools:visibility="visible"/>
</FrameLayout>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/mediapicker_fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:windowBackground">
<android.support.v7.widget.Toolbar
android:id="@+id/mediapicker_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:minHeight="?attr/actionBarSize"
android:background="?attr/conversation_list_toolbar_background"
android:theme="?attr/actionBarStyle" />
<android.support.v7.widget.RecyclerView
android:id="@+id/mediapicker_folder_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginLeft="2dp"
android:layout_marginTop="2dp" />
</LinearLayout>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginRight="2dp"
android:layout_marginBottom="2dp">
<org.thoughtcrime.securesms.components.SquareImageView
android:id="@+id/mediapicker_folder_item_thumbnail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:scaleType="centerCrop"/>
<ImageView
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_gravity="bottom"
android:src="@drawable/image_shade"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="6dp">
<ImageView
android:id="@+id/mediapicker_folder_item_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginRight="6dp"
android:layout_marginEnd="6dp"
android:src="@drawable/ic_folder_white_48dp"/>
<TextView
android:id="@+id/mediapicker_folder_item_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginRight="6dp"
android:layout_marginEnd="6dp"
android:maxLines="1"
android:ellipsize="end"
style="@style/Signal.Text.Preview"
android:textColor="@color/core_white"
tools:text="Daily Bugle"/>
<TextView
android:id="@+id/mediapicker_folder_item_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Signal.Text.Preview"
android:textColor="@color/core_white"
tools:text="122"/>
</LinearLayout>
</FrameLayout>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:windowBackground">
<android.support.v7.widget.Toolbar
android:id="@+id/mediapicker_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:minHeight="?attr/actionBarSize"
android:background="?attr/conversation_list_toolbar_background"
android:theme="?attr/actionBarStyle" />
<android.support.v7.widget.RecyclerView
android:id="@+id/mediapicker_item_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginLeft="2dp"
android:layout_marginTop="2dp" />
</LinearLayout>

View File

@ -0,0 +1,60 @@
<?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="wrap_content"
android:layout_marginRight="2dp"
android:layout_marginBottom="2dp">
<org.thoughtcrime.securesms.components.SquareImageView
android:id="@+id/mediapicker_image_item_thumbnail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:scaleType="centerCrop"/>
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?mediapicker_image_outline" />
<FrameLayout
android:id="@+id/mediapicker_play_overlay"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/circle_white"
android:layout_gravity="center"
android:longClickable="false"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="15dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:layout_marginLeft="2dp"
android:tint="@color/core_blue"
android:scaleType="fitXY"
app:srcCompat="@drawable/triangle_right" />
</FrameLayout>
<FrameLayout
android:id="@+id/mediapicker_selected"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/transparent_black_70"
android:visibility="gone">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:src="@drawable/ic_check_white_24dp" />
</FrameLayout>
</FrameLayout>

View File

@ -0,0 +1,183 @@
<?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">
<org.thoughtcrime.securesms.components.ControllableViewPager
android:id="@+id/mediasend_pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/mediasend_playback_controls_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
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: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_marginLeft="16dp"
android:layout_marginRight="16dp"
style="@style/Signal.Text.Body"
android:paddingTop="11dp"
android:paddingBottom="11dp"
android:drawableLeft="@drawable/ic_add_caption"
android:drawableStart="@drawable/ic_add_caption"
android:drawablePadding="6dp"
android:maxLines="3"
android:maxLength="240"
android:hint="@string/MediaSendActivity_add_a_caption"
android:background="@null"/>
<android.support.v7.widget.RecyclerView
android:id="@+id/mediasend_media_rail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
tools:layout_height="64dp"/>
<LinearLayout
android:id="@+id/mediasend_compose_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:orientation="horizontal"
android:paddingBottom="6dp">
<ImageView
android:id="@+id/mediasend_add_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:src="@drawable/ic_add_photo"
android:background="?selectableItemBackgroundBorderless"
android:visibility="gone"
tools:visibility="visible"/>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:paddingLeft="10dp"
android:paddingStart="10dp"
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:paddingLeft="4dp"
android:paddingStart="4dp"
android:paddingRight="6dp"
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_marginLeft="12dp"
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:paddingRight="6dp"
android:paddingBottom="6dp"
android:paddingLeft="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_marginTop="6dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:paddingBottom="6dp"
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>
<!--<com.pnikosis.materialishprogress.ProgressWheel-->
<!--android:id="@+id/mediasend_progress_wheel"-->
<!--android:layout_width="70dp"-->
<!--android:layout_height="70dp"-->
<!--android:visibility="gone"-->
<!--android:layout_gravity="center"-->
<!--app:matProg_barColor="@color/white"-->
<!--app:matProg_progressIndeterminate="true" />-->
</FrameLayout>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<ImageView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter">
</ImageView>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.video.VideoPlayer
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
</org.thoughtcrime.securesms.video.VideoPlayer>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.pnikosis.materialishprogress.ProgressWheel
android:id="@+id/mediasend_progress_wheel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:padding="8dp"
android:indeterminate="true"
app:matProg_progressIndeterminate="true"
app:matProg_barColor="@color/core_grey_25"/>
</FrameLayout>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:title=""
android:id="@+id/mediapicker_menu_add"
android:visible="true"
android:icon="@drawable/ic_add_photo"
app:showAsAction="always" />
</menu>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:title=""
android:id="@+id/mediapicker_menu_confirm"
android:visible="true"
android:icon="@drawable/ic_check_white_24dp"
app:showAsAction="always" />
</menu>

View File

@ -102,6 +102,8 @@
<attr name="device_link_item_card_background" format="reference|color" />
<attr name="mediapicker_image_outline" format="reference" />
<attr name="import_export_item_background_color" format="reference|color" />
<attr name="import_export_item_background_shadow_color" format="reference|color" />
<attr name="import_export_item_card_background" format="reference" />

View File

@ -35,6 +35,11 @@
<dimen name="media_bubble_min_height">100dp</dimen>
<dimen name="media_bubble_max_height">320dp</dimen>
<dimen name="media_picker_folder_width">175dp</dimen>
<dimen name="media_picker_item_width">85dp</dimen>
<dimen name="mediasend_progress_dialog_size">120dp</dimen>
<dimen name="conversation_compose_height">40dp</dimen>
<dimen name="conversation_individual_right_gutter">16dp</dimen>
<dimen name="conversation_individual_left_gutter">16dp</dimen>

View File

@ -435,6 +435,15 @@
<string name="NotificationMmsMessageRecord_downloading_mms_message">Downloading MMS message</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Error downloading MMS message, tap to retry</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Send to %s</string>
<!-- MediaPickerItemFragment -->
<string name="MediaPickerItemFragment_tap_to_select">Tap to select</string>
<!-- MediaSendActivity -->
<string name="MediaSendActivity_add_a_caption">Add a caption...</string>
<!-- MessageRecord -->
<string name="MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported">Received a message encrypted using an old version of Signal that is no longer supported. Please ask the sender to update to the most recent version and resend the message.</string>
<string name="MessageRecord_left_group">You have left the group.</string>

View File

@ -217,6 +217,8 @@
<item name="device_link_item_card_background">@color/device_link_item_background_light</item>
<item name="mediapicker_image_outline">@drawable/mediapicker_item_border_light</item>
<item name="import_export_item_background_color">@color/import_export_item_background_light</item>
<item name="import_export_item_background_shadow_color">@color/import_export_item_background_shadow_light</item>
<item name="import_export_item_card_background">@drawable/clickable_card_light</item>
@ -324,6 +326,8 @@
<item name="device_link_item_card_background">@color/device_link_item_background_dark</item>
<item name="mediapicker_image_outline">@drawable/mediapicker_item_border_dark</item>
<item name="import_export_item_background_color">@color/import_export_item_background_dark</item>
<item name="import_export_item_background_shadow_color">@color/import_export_item_background_shadow_dark</item>
<item name="import_export_item_card_background">@drawable/clickable_card_dark</item>
@ -420,4 +424,8 @@
<style name="TextSecure.ScribbleTheme" parent="TextSecure.DarkNoActionBar">
</style>
<style name="TextSecure.MediaSendProgressDialog" parent="@android:style/Theme.Dialog">
<item name="android:background">@color/core_grey_95</item>
</style>
</resources>

View File

@ -22,7 +22,6 @@ import android.annotation.TargetApi;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Configuration;
@ -126,6 +125,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
@ -133,6 +134,7 @@ import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.AttachmentManager;
import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.mms.GifSlide;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.ImageSlide;
@ -146,6 +148,7 @@ import org.thoughtcrime.securesms.mms.QuoteId;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
@ -220,6 +223,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
public static final String THREAD_ID_EXTRA = "thread_id";
public static final String IS_ARCHIVED_EXTRA = "is_archived";
public static final String TEXT_EXTRA = "draft_text";
public static final String MEDIA_EXTRA = "media_list";
public static final String DISTRIBUTION_TYPE_EXTRA = "distribution_type";
public static final String TIMING_EXTRA = "timing";
public static final String LAST_SEEN_EXTRA = "last_seen";
@ -237,7 +241,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private static final int PICK_GIF = 10;
private static final int SMS_DEFAULT = 11;
private static final int PICK_CAMERA = 12;
private static final int EDIT_IMAGE = 13;
private static final int MEDIA_SENDER = 13;
private GlideRequests glideRequests;
protected ComposeText composeText;
@ -443,18 +447,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
switch (reqCode) {
case PICK_GALLERY:
MediaType mediaType;
String mimeType = MediaUtil.getMimeType(this, data.getData());
if (MediaUtil.isGif(mimeType)) mediaType = MediaType.GIF;
else if (MediaUtil.isVideo(mimeType)) mediaType = MediaType.VIDEO;
else mediaType = MediaType.IMAGE;
setMedia(data.getData(), mediaType);
break;
case PICK_DOCUMENT:
setMedia(data.getData(), MediaType.DOCUMENT);
break;
@ -526,6 +518,38 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
sendMediaMessage(transport.isSms(), message, slideDeck, Collections.emptyList(), expiresIn, subscriptionId, initiating);
break;
case MEDIA_SENDER:
expiresIn = recipient.getExpireMessages() * 1000L;
subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
initiating = threadId == -1;
transport = data.getParcelableExtra(MediaSendActivity.EXTRA_TRANSPORT);
message = data.getStringExtra(MediaSendActivity.EXTRA_MESSAGE);
slideDeck = new SlideDeck();
if (transport == null) {
throw new IllegalStateException("Received a null transport from the MediaSendActivity.");
}
sendButton.setTransport(transport);
List<Media> mediaList = data.getParcelableArrayListExtra(MediaSendActivity.EXTRA_MEDIA);
for (Media mediaItem : mediaList) {
if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
slideDeck.addSlide(new VideoSlide(this, mediaItem.getUri(), 0, mediaItem.getCaption().orNull()));
} else if (MediaUtil.isGif(mediaItem.getMimeType())) {
slideDeck.addSlide(new GifSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull()));
} else if (MediaUtil.isImageType(mediaItem.getMimeType())) {
slideDeck.addSlide(new ImageSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull()));
} else {
Log.w(TAG, "Asked to send an unexpected mimeType: '" + mediaItem.getMimeType() + "'. Skipping.");
}
}
sendMediaMessage(transport.isSms(), message, slideDeck, Collections.emptyList(), expiresIn, subscriptionId, initiating);
break;
}
}
@ -1094,14 +1118,22 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private ListenableFuture<Boolean> initializeDraft() {
final SettableFuture<Boolean> result = new SettableFuture<>();
final String draftText = getIntent().getStringExtra(TEXT_EXTRA);
final Uri draftMedia = getIntent().getData();
final MediaType draftMediaType = MediaType.from(getIntent().getType());
final String draftText = getIntent().getStringExtra(TEXT_EXTRA);
final Uri draftMedia = getIntent().getData();
final MediaType draftMediaType = MediaType.from(getIntent().getType());
final List<Media> mediaList = getIntent().getParcelableArrayListExtra(MEDIA_EXTRA);
if (!Util.isEmpty(mediaList)) {
Intent sendIntent = MediaSendActivity.getIntent(this, mediaList, recipient, draftText, sendButton.getSelectedTransport());
startActivityForResult(sendIntent, MEDIA_SENDER);
return new SettableFuture<>(false);
}
if (draftText != null) {
composeText.setText(draftText);
result.set(true);
}
if (draftMedia != null && draftMediaType != null) {
return setMedia(draftMedia, draftMediaType);
}
@ -1517,7 +1549,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
Log.i(TAG, "Selected: " + type);
switch (type) {
case AttachmentTypeSelector.ADD_GALLERY:
AttachmentManager.selectGallery(this, PICK_GALLERY); break;
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient, sendButton.getSelectedTransport()); break;
case AttachmentTypeSelector.ADD_DOCUMENT:
AttachmentManager.selectDocument(this, PICK_DOCUMENT); break;
case AttachmentTypeSelector.ADD_SOUND:
@ -1545,6 +1577,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
if (MediaType.VCARD.equals(mediaType) && isSecureText) {
openContactShareEditor(uri);
return new SettableFuture<>(false);
} else if (MediaType.IMAGE.equals(mediaType) || MediaType.GIF.equals(mediaType) || MediaType.VIDEO.equals(mediaType)) {
Media media = new Media(uri, MediaUtil.getMimeType(this, uri), 0, width, height, Optional.absent(), Optional.absent());
startActivityForResult(MediaSendActivity.getIntent(ConversationActivity.this, Collections.singletonList(media), recipient, composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
return new SettableFuture<>(false);
} else {
return attachmentManager.setMedia(glideRequests, uri, mediaType, getCurrentMediaConstraints(), width, height);
}
@ -1858,9 +1894,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private ListenableFuture<Void> sendMediaMessage(final boolean forceSms, String body, SlideDeck slideDeck, List<Contact> contacts, final long expiresIn, final int subscriptionId, final boolean initiating) {
if (!isDefaultSms && (!isSecureText || forceSms)) {
showDefaultSmsPrompt();
SettableFuture<Void> future = new SettableFuture<>();
future.set(null);
return future;
return new SettableFuture<>(null);
}
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient, slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, distributionType, inputPanel.getQuote().orNull(), contacts);
@ -2158,11 +2192,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
@Override
public void onQuickAttachment(Uri uri) {
Intent intent = new Intent();
intent.setData(uri);
onActivityResult(PICK_GALLERY, RESULT_OK, intent);
public void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height) {
Media media = new Media(uri, mimeType, dateTaken, width, height, Optional.fromNullable(bucketId), Optional.absent());
startActivityForResult(MediaSendActivity.getIntent(ConversationActivity.this, Collections.singletonList(media), recipient, composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
}
}

View File

@ -18,15 +18,14 @@ package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.arch.lifecycle.Observer;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.support.v4.app.ActivityCompat;
import android.support.v4.app.ActivityOptionsCompat;
@ -37,17 +36,16 @@ import android.support.v7.app.AlertDialog;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.view.ActionMode;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.LinearSmoothScroller;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.OnScrollListener;
import android.text.ClipboardManager;
import android.text.TextUtils;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.components.ConversationTypingView;
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
import org.thoughtcrime.securesms.logging.Log;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@ -74,6 +72,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.Slide;
@ -83,12 +82,13 @@ import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedList;
@ -507,7 +507,32 @@ public class ConversationFragment extends Fragment
composeIntent.putExtra(Intent.EXTRA_TEXT, message.getDisplayBody().toString());
if (message.isMms()) {
MmsMessageRecord mediaMessage = (MmsMessageRecord) message;
if (mediaMessage.containsMediaSlide()) {
boolean isAlbum = mediaMessage.containsMediaSlide() &&
mediaMessage.getSlideDeck().getSlides().size() > 1 &&
mediaMessage.getSlideDeck().getAudioSlide() == null &&
mediaMessage.getSlideDeck().getDocumentSlide() == null;
if (isAlbum) {
ArrayList<Media> mediaList = new ArrayList<>(mediaMessage.getSlideDeck().getSlides().size());
for (Attachment attachment : mediaMessage.getSlideDeck().asAttachments()) {
Uri uri = attachment.getDataUri() != null ? attachment.getDataUri() : attachment.getThumbnailUri();
if (uri != null) {
mediaList.add(new Media(uri,
attachment.getContentType(),
System.currentTimeMillis(),
attachment.getWidth(),
attachment.getHeight(),
Optional.absent(),
Optional.fromNullable(attachment.getCaption())));
}
}
if (!mediaList.isEmpty()) {
composeIntent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaList);
}
} else if (mediaMessage.containsMediaSlide()) {
Slide slide = mediaMessage.getSlideDeck().getSlides().get(0);
composeIntent.putExtra(Intent.EXTRA_STREAM, slide.getUri());
composeIntent.setType(slide.getContentType());
@ -537,7 +562,7 @@ public class ConversationFragment extends Fragment
for (Slide slide : message.getSlideDeck().getSlides()) {
if ((slide.hasImage() || slide.hasVideo() || slide.hasAudio() || slide.hasDocument()) && slide.getUri() != null) {
SaveAttachmentTask saveTask = new SaveAttachmentTask(getActivity());
saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(slide.getUri(), slide.getContentType(), message.getDateReceived(), slide.getFileName().orNull()));
saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new SaveAttachmentTask.Attachment(slide.getUri(), slide.getContentType(), message.getDateReceived(), slide.getFileName().orNull()));
return;
}
}

View File

@ -60,7 +60,7 @@ import org.thoughtcrime.securesms.components.viewpager.ExtendedOnPageChangedList
import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader;
import org.thoughtcrime.securesms.mediapreview.AlbumRailAdapter;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
@ -82,7 +82,7 @@ import java.util.WeakHashMap;
*/
public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity implements RecipientModifiedListener,
LoaderManager.LoaderCallbacks<Pair<Cursor, Integer>>,
AlbumRailAdapter.RailItemClickedListener
MediaRailAdapter.RailItemListener
{
private final static String TAG = MediaPreviewActivity.class.getSimpleName();
@ -101,7 +101,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
private TextView caption;
private View captionContainer;
private RecyclerView albumRail;
private AlbumRailAdapter albumRailAdapter;
private MediaRailAdapter albumRailAdapter;
private ViewGroup playbackControlsContainer;
private Uri initialMediaUri;
private String initialMediaType;
@ -163,6 +163,11 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
mediaPager.setCurrentItem(mediaPager.getCurrentItem() + distanceFromActive);
}
@Override
public void onRailItemDeleteClicked(int distanceFromActive) {
throw new UnsupportedOperationException("Callback unsupported.");
}
@SuppressWarnings("ConstantConditions")
private void initializeActionBar() {
MediaItem mediaItem = getCurrentMediaItem();
@ -211,7 +216,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
mediaPager.addOnPageChangeListener(new ViewPagerListener());
albumRail = findViewById(R.id.media_preview_album_rail);
albumRailAdapter = new AlbumRailAdapter(GlideApp.with(this), this);
albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false);
albumRail.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
albumRail.setAdapter(albumRailAdapter);
@ -254,7 +259,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
albumRail.setVisibility(previewData.getAlbumThumbnails().isEmpty() ? View.GONE : View.VISIBLE);
albumRailAdapter.setRecords(previewData.getAlbumThumbnails(), previewData.getActivePosition());
albumRailAdapter.setMedia(previewData.getAlbumThumbnails(), previewData.getActivePosition());
albumRail.smoothScrollToPosition(previewData.getActivePosition());
captionContainer.setVisibility(previewData.getCaption() == null ? View.GONE : View.VISIBLE);
@ -446,7 +451,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
mediaPager.setAdapter(adapter);
adapter.setActive(true);
viewModel.setCursor(data.first, leftIsRecent);
viewModel.setCursor(this, data.first, leftIsRecent);
if (restartItem < 0) mediaPager.setCurrentItem(data.second);
else mediaPager.setCurrentItem(restartItem);

View File

@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.providers.PersistentBlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -56,6 +57,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
/**
* An activity to quickly share content with contacts
@ -254,9 +256,13 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity
}
private Intent getBaseShareIntent(final @NonNull Class<?> target) {
final Intent intent = new Intent(this, target);
final String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT);
final Intent intent = new Intent(this, target);
final String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT);
final ArrayList<Media> mediaExtra = getIntent().getParcelableArrayListExtra(ConversationActivity.MEDIA_EXTRA);
intent.putExtra(ConversationActivity.TEXT_EXTRA, textExtra);
intent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaExtra);
if (resolvedExtra != null) intent.setDataAndType(resolvedExtra, mimeType);
return intent;

View File

@ -17,12 +17,9 @@ import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.providers.MemoryBlobProvider;
import org.thoughtcrime.securesms.providers.PersistentBlobProvider;
import org.thoughtcrime.securesms.scribbles.ScribbleFragment;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.concurrent.LifecycleBoundTask;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.whispersystems.libsignal.util.guava.Optional;
@ -125,7 +122,7 @@ public class CameraActivity extends PassphraseRequiredActionBarActivity implemen
result.addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
ScribbleFragment fragment = ScribbleFragment.newInstance(captureUri, dynamicLanguage.getCurrentLocale(), Optional.of(transport));
ScribbleFragment fragment = ScribbleFragment.newInstance(captureUri, dynamicLanguage.getCurrentLocale(), Optional.of(transport), true);
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
.replace(R.id.fragment_container, fragment, TAG_EDITOR)
@ -163,4 +160,7 @@ public class CameraActivity extends PassphraseRequiredActionBarActivity implemen
Toast.makeText(this, R.string.CameraActivity_image_save_failure, Toast.LENGTH_SHORT).show();
finish();
}
@Override
public void onTouchEventsNeeded(boolean needed) { }
}

View File

@ -257,10 +257,10 @@ public class AttachmentTypeSelector extends PopupWindow {
private class RecentPhotoSelectedListener implements RecentPhotoViewRail.OnItemClickedListener {
@Override
public void onItemClicked(Uri uri) {
public void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height) {
animateWindowOutTranslate(getContentView());
if (listener != null) listener.onQuickAttachment(uri);
if (listener != null) listener.onQuickAttachment(uri, mimeType, bucketId, dateTaken, width, height);
}
}
@ -289,8 +289,8 @@ public class AttachmentTypeSelector extends PopupWindow {
}
public interface AttachmentClickedListener {
public void onClick(int type);
public void onQuickAttachment(Uri uri);
void onClick(int type);
void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height);
}
}

View File

@ -7,10 +7,12 @@ import android.support.v4.view.ViewPager;
import android.util.AttributeSet;
import android.view.MotionEvent;
import org.thoughtcrime.securesms.components.viewpager.HackyViewPager;
/**
* An implementation of {@link ViewPager} that disables swiping when the view is disabled.
*/
public class ControllableViewPager extends ViewPager {
public class ControllableViewPager extends HackyViewPager {
public ControllableViewPager(@NonNull Context context) {
super(context);

View File

@ -77,7 +77,7 @@ public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKey
});
}
protected void hideSoftkey(final EditText inputTarget, @Nullable Runnable runAfterClose) {
public void hideSoftkey(final EditText inputTarget, @Nullable Runnable runAfterClose) {
if (runAfterClose != null) postOnKeyboardClose(runAfterClose);
ServiceUtil.getInputMethodManager(inputTarget.getContext())

View File

@ -4,6 +4,7 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.MediaStore;
import android.support.annotation.NonNull;
@ -106,7 +107,10 @@ public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.Lo
long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN));
long dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_MODIFIED));
String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.MIME_TYPE));
String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_ID));
int orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION));
int width = Build.VERSION.SDK_INT >= 16 ? cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.WIDTH)) : 0;
int height = Build.VERSION.SDK_INT >= 16 ? cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.HEIGHT)) : 0;
final Uri uri = Uri.withAppendedPath(baseUri, Long.toString(id));
@ -119,7 +123,7 @@ public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.Lo
.into(viewHolder.imageView);
viewHolder.imageView.setOnClickListener(v -> {
if (clickedListener != null) clickedListener.onItemClicked(uri);
if (clickedListener != null) clickedListener.onItemClicked(uri, mimeType, bucketId, dateTaken, width, height);
});
}
@ -141,6 +145,6 @@ public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.Lo
}
public interface OnItemClickedListener {
void onItemClicked(Uri uri);
void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height);
}
}

View File

@ -106,7 +106,7 @@ public class SendButton extends ImageButton
@Override
public boolean onLongClick(View v) {
if (transportOptions.getEnabledTransports().size() > 1) {
if (isEnabled() && transportOptions.getEnabledTransports().size() > 1) {
getTransportOptionsPopup().display(transportOptions.getEnabledTransports());
return true;
}

View File

@ -295,11 +295,18 @@ public class ThumbnailView extends FrameLayout {
SettableFuture<Boolean> future = new SettableFuture<>();
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
glideRequests.load(new DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transforms(new CenterCrop(), new RoundedCorners(radius))
.transition(withCrossFade())
.into(new GlideDrawableListeningTarget(image, future));
GlideRequest request = glideRequests.load(new DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(withCrossFade());
if (radius > 0) {
request = request.transforms(new CenterCrop(), new RoundedCorners(radius));
} else {
request = request.transforms(new CenterCrop());
}
request.into(new GlideDrawableListeningTarget(image, future));
return future;
}

View File

@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@ -117,11 +116,11 @@ public class TransferControlView extends FrameLayout {
if (!isUpdateToExistingSet(slides)) {
downloadProgress.clear();
Stream.of(slides).forEach(s -> downloadProgress.put(s.asAttachment(), 0f));
} else {
for (Slide slide : slides) {
if (slide.asAttachment().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE) {
downloadProgress.put(slide.asAttachment(), 1f);
}
}
for (Slide slide : slides) {
if (slide.asAttachment().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE) {
downloadProgress.put(slide.asAttachment(), 1f);
}
}

View File

@ -85,9 +85,7 @@ public class MediaDatabase extends Database {
private final long date;
private final boolean outgoing;
// TODO: Make private again
public MediaRecord(DatabaseAttachment attachment, @Nullable Address address, long date, boolean outgoing) {
// private MediaRecord(DatabaseAttachment attachment, @Nullable Address address, long date, boolean outgoing) {
private MediaRecord(DatabaseAttachment attachment, @Nullable Address address, long date, boolean outgoing) {
this.attachment = attachment;
this.address = address;
this.date = date;

View File

@ -5,6 +5,7 @@ import android.Manifest;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.MediaStore;
import android.support.v4.content.CursorLoader;
@ -19,7 +20,19 @@ public class RecentPhotosLoader extends CursorLoader {
MediaStore.Images.ImageColumns.DATE_TAKEN,
MediaStore.Images.ImageColumns.DATE_MODIFIED,
MediaStore.Images.ImageColumns.ORIENTATION,
MediaStore.Images.ImageColumns.MIME_TYPE
MediaStore.Images.ImageColumns.MIME_TYPE,
MediaStore.Images.ImageColumns.BUCKET_ID
};
private static final String[] PROJECTION_16 = new String[] {
MediaStore.Images.ImageColumns._ID,
MediaStore.Images.ImageColumns.DATE_TAKEN,
MediaStore.Images.ImageColumns.DATE_MODIFIED,
MediaStore.Images.ImageColumns.ORIENTATION,
MediaStore.Images.ImageColumns.MIME_TYPE,
MediaStore.Images.ImageColumns.BUCKET_ID,
MediaStore.Images.ImageColumns.WIDTH,
MediaStore.Images.ImageColumns.HEIGHT
};
private final Context context;
@ -31,9 +44,11 @@ public class RecentPhotosLoader extends CursorLoader {
@Override
public Cursor loadInBackground() {
String[] projection = Build.VERSION.SDK_INT >= 16 ? PROJECTION_16 : PROJECTION;
if (Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
PROJECTION, null, null,
projection, null, null,
MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC");
} else {
return null;

View File

@ -29,9 +29,11 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import java.io.IOException;
import java.io.InputStream;
import java.net.ConnectException;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import javax.net.ssl.SSLException;
import androidx.work.Data;
import androidx.work.WorkerParameters;
@ -94,7 +96,9 @@ public class AttachmentUploadJob extends ContextJob implements InjectableType {
@Override
protected boolean onShouldRetry(Exception exception) {
return exception instanceof PushNetworkException;
return exception instanceof PushNetworkException ||
exception instanceof SSLException ||
exception instanceof ConnectException;
}
protected SignalServiceAttachment getAttachmentFor(Attachment attachment) {

View File

@ -1,100 +0,0 @@
package org.thoughtcrime.securesms.mediapreview;
import android.support.annotation.NonNull;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ThumbnailView;
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
import java.util.ArrayList;
import java.util.List;
public class AlbumRailAdapter extends RecyclerView.Adapter<AlbumRailAdapter.AlbumRailViewHolder> {
private final GlideRequests glideRequests;
private final List<MediaRecord> records;
private final RailItemClickedListener listener;
private int activePosition;
public AlbumRailAdapter(@NonNull GlideRequests glideRequests, @NonNull RailItemClickedListener listener) {
this.glideRequests = glideRequests;
this.records = new ArrayList<>();
this.listener = listener;
setHasStableIds(true);
}
@NonNull
@Override
public AlbumRailViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new AlbumRailViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_preview_album_rail_item, viewGroup, false));
}
@Override
public void onBindViewHolder(@NonNull AlbumRailViewHolder albumRailViewHolder, int i) {
albumRailViewHolder.bind(records.get(i), i == activePosition, glideRequests, listener, i - activePosition);
}
@Override
public void onViewRecycled(@NonNull AlbumRailViewHolder holder) {
holder.recycle();
}
@Override
public long getItemId(int position) {
return records.get(position).getAttachment().getAttachmentId().getUniqueId();
}
@Override
public int getItemCount() {
return records.size();
}
public void setRecords(@NonNull List<MediaRecord> records, int activePosition) {
this.activePosition = activePosition;
this.records.clear();
this.records.addAll(records);
notifyDataSetChanged();
}
static class AlbumRailViewHolder extends RecyclerView.ViewHolder {
private final ThumbnailView image;
AlbumRailViewHolder(@NonNull View itemView) {
super(itemView);
image = (ThumbnailView) itemView;
}
void bind(@NonNull MediaRecord record, boolean isActive, @NonNull GlideRequests glideRequests,
@NonNull RailItemClickedListener railItemClickedListener, int distanceFromActive)
{
if (record.getAttachment().getThumbnailUri() != null) {
image.setImageResource(glideRequests, record.getAttachment().getThumbnailUri());
} else if (record.getAttachment().getDataUri() != null) {
image.setImageResource(glideRequests, record.getAttachment().getDataUri());
} else {
image.clear(glideRequests);
}
image.setBackgroundResource(isActive ? R.drawable.album_rail_item_background : 0);
image.setOnClickListener(v -> railItemClickedListener.onRailItemClicked(distanceFromActive));
}
void recycle() {
image.setOnClickListener(null);
}
}
public interface RailItemClickedListener {
void onRailItemClicked(int distanceFromActive);
}
}

View File

@ -5,10 +5,13 @@ import android.arch.lifecycle.MutableLiveData;
import android.arch.lifecycle.ViewModel;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
import org.thoughtcrime.securesms.mediasend.Media;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
import java.util.LinkedList;
@ -22,9 +25,15 @@ public class MediaPreviewViewModel extends ViewModel {
private @Nullable Cursor cursor;
public void setCursor(@Nullable Cursor cursor, boolean leftIsRecent) {
public void setCursor(@NonNull Context context, @Nullable Cursor cursor, boolean leftIsRecent) {
boolean firstLoad = (this.cursor == null) && (cursor != null);
this.cursor = cursor;
this.leftIsRecent = leftIsRecent;
if (firstLoad) {
setActiveAlbumRailItem(context, 0);
}
}
public void setActiveAlbumRailItem(@NonNull Context context, int activePosition) {
@ -37,15 +46,17 @@ public class MediaPreviewViewModel extends ViewModel {
cursor.moveToPosition(activePosition);
MediaRecord activeRecord = MediaRecord.from(context, cursor);
LinkedList<MediaRecord> rail = new LinkedList<>();
MediaRecord activeRecord = MediaRecord.from(context, cursor);
LinkedList<Media> rail = new LinkedList<>();
rail.add(activeRecord);
Media activeMedia = toMedia(activeRecord);
if (activeMedia != null) rail.add(activeMedia);
while (cursor.moveToPrevious()) {
MediaRecord record = MediaRecord.from(context, cursor);
if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) {
rail.addFirst(record);
Media media = toMedia(record);
if (media != null) rail.addFirst(media);
} else {
break;
}
@ -56,7 +67,8 @@ public class MediaPreviewViewModel extends ViewModel {
while (cursor.moveToNext()) {
MediaRecord record = MediaRecord.from(context, cursor);
if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) {
rail.addLast(record);
Media media = toMedia(record);
if (media != null) rail.addLast(media);
} else {
break;
}
@ -68,7 +80,7 @@ public class MediaPreviewViewModel extends ViewModel {
previewData.postValue(new PreviewData(rail.size() > 1 ? rail : Collections.emptyList(),
activeRecord.getAttachment().getCaption(),
rail.indexOf(activeRecord)));
rail.indexOf(activeMedia)));
}
private int getCursorPosition(int position) {
@ -80,22 +92,39 @@ public class MediaPreviewViewModel extends ViewModel {
else return cursor.getCount() - 1 - position;
}
private @Nullable Media toMedia(@NonNull MediaRecord mediaRecord) {
Uri uri = mediaRecord.getAttachment().getThumbnailUri() != null ? mediaRecord.getAttachment().getThumbnailUri()
: mediaRecord.getAttachment().getDataUri();
if (uri == null) {
return null;
}
return new Media(uri,
mediaRecord.getContentType(),
mediaRecord.getDate(),
mediaRecord.getAttachment().getWidth(),
mediaRecord.getAttachment().getHeight(),
Optional.absent(),
Optional.fromNullable(mediaRecord.getAttachment().getCaption()));
}
public LiveData<PreviewData> getPreviewData() {
return previewData;
}
public static class PreviewData {
private final List<MediaRecord> albumThumbnails;
private final String caption;
private final int activePosition;
private final List<Media> albumThumbnails;
private final String caption;
private final int activePosition;
public PreviewData(@NonNull List<MediaRecord> albumThumbnails, @Nullable String caption, int activePosition) {
public PreviewData(@NonNull List<Media> albumThumbnails, @Nullable String caption, int activePosition) {
this.albumThumbnails = albumThumbnails;
this.caption = caption;
this.activePosition = activePosition;
}
public @NonNull List<MediaRecord> getAlbumThumbnails() {
public @NonNull List<Media> getAlbumThumbnails() {
return albumThumbnails;
}

View File

@ -0,0 +1,108 @@
package org.thoughtcrime.securesms.mediapreview;
import android.support.annotation.NonNull;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ThumbnailView;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.GlideRequests;
import java.util.ArrayList;
import java.util.List;
public class MediaRailAdapter extends RecyclerView.Adapter<MediaRailAdapter.MediaRailViewHolder> {
private final GlideRequests glideRequests;
private final List<Media> media;
private final RailItemListener listener;
private final boolean deleteEnabled;
private int activePosition;
public MediaRailAdapter(@NonNull GlideRequests glideRequests, @NonNull RailItemListener listener, boolean deleteEnabled) {
this.glideRequests = glideRequests;
this.media = new ArrayList<>();
this.listener = listener;
this.deleteEnabled = deleteEnabled;
}
@NonNull
@Override
public MediaRailViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new MediaRailViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_preview_album_rail_item, viewGroup, false));
}
@Override
public void onBindViewHolder(@NonNull MediaRailViewHolder mediaRailViewHolder, int i) {
mediaRailViewHolder.bind(media.get(i), i == activePosition, glideRequests, listener, i - activePosition, deleteEnabled);
}
@Override
public void onViewRecycled(@NonNull MediaRailViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return media.size();
}
public void setMedia(@NonNull List<Media> media) {
setMedia(media, activePosition);
}
public void setMedia(@NonNull List<Media> records, int activePosition) {
this.activePosition = activePosition;
this.media.clear();
this.media.addAll(records);
notifyDataSetChanged();
}
public void setActivePosition(int activePosition) {
this.activePosition = activePosition;
notifyDataSetChanged();
}
static class MediaRailViewHolder extends RecyclerView.ViewHolder {
private final ThumbnailView image;
private final View deleteButton;
MediaRailViewHolder(@NonNull View itemView) {
super(itemView);
image = itemView.findViewById(R.id.rail_item_image);
deleteButton = itemView.findViewById(R.id.rail_item_delete);
}
void bind(@NonNull Media media, boolean isActive, @NonNull GlideRequests glideRequests,
@NonNull RailItemListener railItemListener, int distanceFromActive, boolean deleteEnabled)
{
image.setImageResource(glideRequests, media.getUri());
image.setBackgroundResource(isActive ? R.drawable.media_rail_item_background : 0);
image.setOnClickListener(v -> railItemListener.onRailItemClicked(distanceFromActive));
if (deleteEnabled && isActive) {
deleteButton.setVisibility(View.VISIBLE);
deleteButton.setOnClickListener(v -> railItemListener.onRailItemDeleteClicked(distanceFromActive));
} else {
deleteButton.setVisibility(View.GONE);
}
}
void recycle() {
image.setOnClickListener(null);
deleteButton.setOnClickListener(null);
}
}
public interface RailItemListener {
void onRailItemClicked(int distanceFromActive);
void onRailItemDeleteClicked(int distanceFromActive);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,147 @@
package org.thoughtcrime.securesms.mediasend;
import android.support.annotation.NonNull;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
public class MediaPickerItemAdapter extends RecyclerView.Adapter<MediaPickerItemAdapter.ItemViewHolder> {
private final GlideRequests glideRequests;
private final EventListener eventListener;
private final List<Media> media;
private final Set<Media> selected;
private final int maxSelection;
private boolean forcedMultiSelect;
public MediaPickerItemAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, int maxSelection) {
this.glideRequests = glideRequests;
this.eventListener = eventListener;
this.media = new ArrayList<>();
this.maxSelection = maxSelection;
this.selected = new TreeSet<>((m1, m2) -> {
if (m1.equals(m2)) return 0;
else return Long.compare(m2.getDate(), m1.getDate());
});
setHasStableIds(true);
}
@Override
public @NonNull ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new ItemViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_media_item, viewGroup, false));
}
@Override
public void onBindViewHolder(@NonNull ItemViewHolder holder, int i) {
holder.bind(media.get(i), forcedMultiSelect, selected, maxSelection, glideRequests, eventListener);
}
@Override
public void onViewRecycled(@NonNull ItemViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return media.size();
}
@Override
public long getItemId(int position) {
return media.get(position).getDate();
}
void setMedia(@NonNull List<Media> media) {
this.media.clear();
this.media.addAll(media);
notifyDataSetChanged();
}
void setSelected(@NonNull Collection<Media> selected) {
this.selected.clear();
this.selected.addAll(selected);
notifyDataSetChanged();
}
Set<Media> getSelected() {
return selected;
}
void setForcedMultiSelect(boolean forcedMultiSelect) {
this.forcedMultiSelect = forcedMultiSelect;
notifyDataSetChanged();
}
static class ItemViewHolder extends RecyclerView.ViewHolder {
private final ImageView thumbnail;
private final View playOverlay;
private final View selectedOverlay;
ItemViewHolder(@NonNull View itemView) {
super(itemView);
thumbnail = itemView.findViewById(R.id.mediapicker_image_item_thumbnail);
playOverlay = itemView.findViewById(R.id.mediapicker_play_overlay);
selectedOverlay = itemView.findViewById(R.id.mediapicker_selected);
}
void bind(@NonNull Media media, boolean multiSelect, Set<Media> selected, int maxSelection, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
glideRequests.load(media.getUri())
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.withCrossFade())
.into(thumbnail);
playOverlay.setVisibility(MediaUtil.isVideoType(media.getMimeType()) ? View.VISIBLE : View.GONE);
selectedOverlay.setVisibility(selected.contains(media) ? View.VISIBLE : View.GONE);
if (selected.isEmpty() && !multiSelect) {
itemView.setOnClickListener(v -> eventListener.onMediaChosen(media));
if (maxSelection > 1) {
itemView.setOnLongClickListener(v -> {
selected.add(media);
eventListener.onMediaSelectionChanged(new ArrayList<>(selected));
return true;
});
}
} else if (selected.contains(media)) {
itemView.setOnClickListener(v -> {
selected.remove(media);
eventListener.onMediaSelectionChanged(new ArrayList<>(selected));
});
} else {
itemView.setOnClickListener(v -> {
if (selected.size() < maxSelection) {
selected.add(media);
eventListener.onMediaSelectionChanged(new ArrayList<>(selected));
}
});
}
}
void recycle() {
itemView.setOnClickListener(null);
}
}
interface EventListener {
void onMediaChosen(@NonNull Media media);
void onMediaSelectionChanged(@NonNull List<Media> media);
}
}

View File

@ -0,0 +1,241 @@
package org.thoughtcrime.securesms.mediasend;
import android.arch.lifecycle.ViewModelProviders;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Point;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.view.ActionMode;
import android.support.v7.widget.GridLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* Allows the user to select a set of media items from a specified folder.
*/
public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener {
private static final String KEY_BUCKET_ID = "bucket_id";
private static final String KEY_FOLDER_TITLE = "folder_title";
private static final String KEY_MAX_SELECTION = "max_selection";
private String bucketId;
private String folderTitle;
private int maxSelection;
private MediaSendViewModel viewModel;
private MediaPickerItemAdapter adapter;
private Controller controller;
private GridLayoutManager layoutManager;
private ActionMode actionMode;
private ActionMode.Callback actionModeCallback;
public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection) {
Bundle args = new Bundle();
args.putString(KEY_BUCKET_ID, bucketId);
args.putString(KEY_FOLDER_TITLE, folderTitle);
args.putInt(KEY_MAX_SELECTION, maxSelection);
MediaPickerItemFragment fragment = new MediaPickerItemFragment();
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
bucketId = getArguments().getString(KEY_BUCKET_ID);
folderTitle = getArguments().getString(KEY_FOLDER_TITLE);
maxSelection = getArguments().getInt(KEY_MAX_SELECTION);
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(new MediaRepository())).get(MediaSendViewModel.class);
actionModeCallback = new ActionModeCallback();
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
if (!(getActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement controller class.");
}
controller = (Controller) getActivity();
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.mediapicker_item_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
RecyclerView imageList = view.findViewById(R.id.mediapicker_item_list);
adapter = new MediaPickerItemAdapter(GlideApp.with(this), this, maxSelection);
layoutManager = new GridLayoutManager(requireContext(), 4);
imageList.setLayoutManager(layoutManager);
imageList.setAdapter(adapter);
initToolbar(view.findViewById(R.id.mediapicker_toolbar));
onScreenWidthChanged(getScreenWidth());
if (!Util.isEmpty(viewModel.getSelectedMedia().getValue())) {
adapter.setSelected(viewModel.getSelectedMedia().getValue());
onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue()));
}
viewModel.getMediaInBucket(requireContext(), bucketId).observe(this, adapter::setMedia);
}
@Override
public void onResume() {
super.onResume();
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.mediapicker_default, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.mediapicker_menu_add) {
adapter.setForcedMultiSelect(true);
actionMode = ((AppCompatActivity) requireActivity()).startSupportActionMode(actionModeCallback);
actionMode.setTitle(getResources().getString(R.string.MediaPickerItemFragment_tap_to_select));
return true;
}
return false;
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onScreenWidthChanged(getScreenWidth());
}
@Override
public void onMediaChosen(@NonNull Media media) {
controller.onMediaSelected(bucketId, Collections.singleton(media));
viewModel.onSelectedMediaChanged(Collections.singletonList(media));
}
@Override
public void onMediaSelectionChanged(@NonNull List<Media> selected) {
adapter.notifyDataSetChanged();
if (actionMode == null && !selected.isEmpty()) {
actionMode = ((AppCompatActivity) requireActivity()).startSupportActionMode(actionModeCallback);
actionMode.setTitle(String.valueOf(selected.size()));
} else if (actionMode != null && selected.isEmpty()) {
actionMode.finish();
} else if (actionMode != null) {
actionMode.setTitle(String.valueOf(selected.size()));
}
viewModel.onSelectedMediaChanged(selected);
}
private void initToolbar(Toolbar toolbar) {
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(folderTitle);
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
}
private void onScreenWidthChanged(int newWidth) {
if (layoutManager != null) {
layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_item_width));
}
}
private int getScreenWidth() {
Point size = new Point();
requireActivity().getWindowManager().getDefaultDisplay().getSize(size);
return size.x;
}
private class ActionModeCallback implements ActionMode.Callback {
private int statusBarColor;
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuInflater inflater = mode.getMenuInflater();
inflater.inflate(R.menu.mediapicker_multiselect, menu);
if (Build.VERSION.SDK_INT >= 21) {
Window window = requireActivity().getWindow();
statusBarColor = window.getStatusBarColor();
window.setStatusBarColor(getResources().getColor(R.color.action_mode_status_bar));
}
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) {
if (menuItem.getItemId() == R.id.mediapicker_menu_confirm) {
List<Media> selected = new ArrayList<>(adapter.getSelected());
actionMode.finish();
viewModel.onSelectedMediaChanged(selected);
controller.onMediaSelected(bucketId, selected);
return true;
}
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
actionMode = null;
adapter.setSelected(Collections.emptySet());
viewModel.onSelectedMediaChanged(Collections.emptyList());
if (Build.VERSION.SDK_INT >= 21) {
requireActivity().getWindow().setStatusBarColor(statusBarColor);
}
}
}
public interface Controller {
void onMediaSelected(@NonNull String bucketId, @NonNull Collection<Media> media);
}
}

View File

@ -0,0 +1,190 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Video;
import android.support.annotation.NonNull;
import android.support.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
/**
* Handles the retrieval of media present on the user's device.
*/
class MediaRepository {
/**
* Retrieves a list of folders that contain media.
*/
void getFolders(@NonNull Context context, @NonNull Callback<List<MediaFolder>> callback) {
AsyncTask.THREAD_POOL_EXECUTOR.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)));
}
@WorkerThread
private @NonNull List<MediaFolder> getFolders(@NonNull Context context) {
Pair<String, Map<String, FolderData>> imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI);
Pair<String, Map<String, FolderData>> videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI);
Map<String, FolderData> folders = new HashMap<>(imageFolders.second());
for (Map.Entry<String, FolderData> entry : videoFolders.second().entrySet()) {
if (folders.containsKey(entry.getKey())) {
folders.get(entry.getKey()).incrementCount(entry.getValue().getCount());
} else {
folders.put(entry.getKey(), entry.getValue());
}
}
String cameraBucketId = imageFolders.first() != null ? imageFolders.first() : videoFolders.first();
FolderData cameraFolder = cameraBucketId != null ? folders.remove(cameraBucketId) : null;
List<MediaFolder> mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(),
folder.getTitle(),
folder.getCount(),
folder.getBucketId(),
MediaFolder.FolderType.NORMAL))
.sorted((o1, o2) -> o1.getTitle().toLowerCase().compareTo(o2.getTitle().toLowerCase()))
.toList();
if (cameraFolder != null) {
mediaFolders.add(0, new MediaFolder(cameraFolder.getThumbnail(), cameraFolder.getTitle(), cameraFolder.getCount(), cameraFolder.getBucketId(), MediaFolder.FolderType.CAMERA));
}
return mediaFolders;
}
@WorkerThread
private @NonNull Pair<String, Map<String, FolderData>> getFolders(@NonNull Context context, @NonNull Uri contentUri) {
String cameraPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() + File.separator + "Camera";
String cameraBucketId = null;
Map<String, FolderData> folders = new HashMap<>();
String[] projection = new String[] { Images.Media.DATA, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME };
String selection = Images.Media.DATA + " NOT NULL";
String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_TAKEN + " DESC";
try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) {
while (cursor != null && cursor.moveToNext()) {
String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0]));
Uri thumbnail = Uri.fromFile(new File(path));
String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1]));
String title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2]));
FolderData folder = Util.getOrDefault(folders, bucketId, new FolderData(thumbnail, title, bucketId));
folder.incrementCount();
folders.put(bucketId, folder);
if (cameraBucketId == null && path.startsWith(cameraPath)) {
cameraBucketId = bucketId;
}
}
}
return new Pair<>(cameraBucketId, folders);
}
@WorkerThread
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId) {
List<Media> images = getMediaInBucket(context, bucketId, Images.Media.EXTERNAL_CONTENT_URI);
List<Media> videos = getMediaInBucket(context, bucketId, Video.Media.EXTERNAL_CONTENT_URI);
List<Media> media = new ArrayList<>(images.size() + videos.size());
media.addAll(images);
media.addAll(videos);
Collections.sort(media, (o1, o2) -> Long.compare(o2.getDate(), o1.getDate()));
return media;
}
@WorkerThread
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri) {
List<Media> media = new LinkedList<>();
String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL";
String sortBy = Images.Media.DATE_TAKEN + " DESC";
String[] projection = Build.VERSION.SDK_INT >= 16 ? new String[] { Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT }
: new String[] { Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN };
try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, new String[] { bucketId }, sortBy)) {
while (cursor != null && cursor.moveToNext()) {
Uri uri = Uri.withAppendedPath(contentUri, cursor.getString(cursor.getColumnIndexOrThrow(projection[0])));
String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(projection[1]));
long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(projection[2]));
int width = 0;
int height = 0;
if (Build.VERSION.SDK_INT >= 16) {
width = cursor.getInt(cursor.getColumnIndexOrThrow(projection[3]));
height = cursor.getInt(cursor.getColumnIndexOrThrow(projection[4]));
}
media.add(new Media(uri, mimetype, dateTaken, width, height, Optional.of(bucketId), Optional.absent()));
}
}
return media;
}
private static class FolderData {
private final Uri thumbnail;
private final String title;
private final String bucketId;
private int count;
private FolderData(Uri thumbnail, String title, String bucketId) {
this.thumbnail = thumbnail;
this.title = title;
this.bucketId = bucketId;
}
Uri getThumbnail() {
return thumbnail;
}
String getTitle() {
return title;
}
String getBucketId() {
return bucketId;
}
int getCount() {
return count;
}
void incrementCount() {
incrementCount(1);
}
void incrementCount(int amount) {
count += amount;
}
}
interface Callback<E> {
void onComplete(@NonNull E result);
}
}

View File

@ -0,0 +1,228 @@
package org.thoughtcrime.securesms.mediasend;
import android.arch.lifecycle.ViewModelProviders;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.support.annotation.NonNull;
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.scribbles.ScribbleFragment;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* Encompasses the entire flow of sending media, starting from the selection process to the actual
* captioning and editing of the content.
*
* This activity is intended to be launched via {@link #startActivityForResult(Intent, int)}.
* It will return the {@link Media} that the user decided to send.
*/
public class MediaSendActivity extends PassphraseRequiredActionBarActivity implements MediaPickerFolderFragment.Controller,
MediaPickerItemFragment.Controller,
MediaSendFragment.Controller,
ScribbleFragment.Controller
{
public static final String EXTRA_MEDIA = "media";
public static final String EXTRA_MESSAGE = "message";
public static final String EXTRA_TRANSPORT = "transport";
private static final int MAX_PUSH = 32;
private static final int MAX_SMS = 1;
private static final String KEY_ADDRESS = "address";
private static final String KEY_BODY = "body";
private static final String KEY_MEDIA = "media";
private static final String KEY_TRANSPORT = "transport";
private static final String TAG_FOLDER_PICKER = "folder_picker";
private static final String TAG_ITEM_PICKER = "item_picker";
private static final String TAG_SEND = "send";
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
private Recipient recipient;
private TransportOption transport;
private MediaSendViewModel viewModel;
/**
* Get an intent to launch the media send flow starting with the picker.
*/
public static Intent getIntent(@NonNull Context context, @NonNull Recipient recipient, @NonNull TransportOption transport) {
Intent intent = new Intent(context, MediaSendActivity.class);
intent.putExtra(KEY_ADDRESS, recipient.getAddress().serialize());
intent.putExtra(KEY_TRANSPORT, transport);
return intent;
}
/**
* Get an intent to launch the media send flow with a specific list of media. Will jump right to
* the editor screen.
*/
public static Intent getIntent(@NonNull Context context,
@NonNull List<Media> media,
@NonNull Recipient recipient,
@NonNull String body,
@NonNull TransportOption transport)
{
Intent intent = getIntent(context, recipient, transport);
intent.putParcelableArrayListExtra(KEY_MEDIA, new ArrayList<>(media));
intent.putExtra(KEY_BODY, body);
return intent;
}
@Override
protected void onPreCreate() {
dynamicTheme.onCreate(this);
dynamicLanguage.onCreate(this);
}
@Override
protected void onCreate(Bundle savedInstanceState, boolean ready) {
setContentView(R.layout.mediapicker_activity);
setResult(RESULT_CANCELED);
if (savedInstanceState != null) {
return;
}
viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(new MediaRepository())).get(MediaSendViewModel.class);
recipient = Recipient.from(this, Address.fromSerialized(getIntent().getStringExtra(KEY_ADDRESS)), true);
transport = getIntent().getParcelableExtra(KEY_TRANSPORT);
List<Media> media = getIntent().getParcelableArrayListExtra(KEY_MEDIA);
String body = getIntent().getStringExtra(KEY_BODY);
if (!Util.isEmpty(media)) {
navigateToMediaSend(media, body, transport);
} else {
navigateToFolderPicker(recipient);
}
}
@Override
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
dynamicLanguage.onResume(this);
}
@Override
public void onBackPressed() {
MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
if (sendFragment == null || !sendFragment.handleBackPress()) {
super.onBackPressed();
}
}
@Override
public void onFolderSelected(@NonNull MediaFolder folder) {
viewModel.onFolderSelected(folder.getBucketId());
MediaPickerItemFragment fragment = MediaPickerItemFragment.newInstance(folder.getBucketId(),
folder.getTitle(),
transport.isSms() ? MAX_SMS : MAX_PUSH);
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
.replace(R.id.mediapicker_fragment_container, fragment, TAG_ITEM_PICKER)
.addToBackStack(null)
.commit();
}
@Override
public void onMediaSelected(@NonNull String bucketId, @NonNull Collection<Media> media) {
MediaSendFragment fragment = MediaSendFragment.newInstance("", transport, dynamicLanguage.getCurrentLocale());
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
.replace(R.id.mediapicker_fragment_container, fragment, TAG_SEND)
.addToBackStack(null)
.commit();
}
@Override
public void onAddMediaClicked(@NonNull String bucketId) {
MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient);
MediaPickerItemFragment itemFragment = MediaPickerItemFragment.newInstance(bucketId,
"",
transport.isSms() ? MAX_SMS : MAX_PUSH);
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediapicker_fragment_container, folderFragment, TAG_FOLDER_PICKER)
.addToBackStack(null)
.commit();
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediapicker_fragment_container, itemFragment, TAG_ITEM_PICKER)
.addToBackStack(null)
.commit();
}
@Override
public void onSendClicked(@NonNull List<Media> media, @NonNull String message, @NonNull TransportOption transport) {
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();
}
@Override
public void onImageEditComplete(@NonNull Uri uri, int width, int height, long size, @NonNull Optional<String> message, @NonNull Optional<TransportOption> transport) {
throw new UnsupportedOperationException("Callback unsupported.");
}
@Override
public void onImageEditFailure() {
throw new UnsupportedOperationException("Callback unsupported.");
}
@Override
public void onTouchEventsNeeded(boolean needed) {
MediaSendFragment fragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
if (fragment != null) {
fragment.onTouchEventsNeeded(needed);
}
}
private void navigateToMediaSend(List<Media> media, String body, TransportOption transport) {
viewModel.setInitialSelectedMedia(media);
MediaSendFragment sendFragment = MediaSendFragment.newInstance(body, transport, dynamicLanguage.getCurrentLocale());
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediapicker_fragment_container, sendFragment, TAG_SEND)
.commit();
}
private void navigateToFolderPicker(@NonNull Recipient recipient) {
MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient);
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediapicker_fragment_container, folderFragment, TAG_FOLDER_PICKER)
.commit();
}
}

View File

@ -0,0 +1,506 @@
package org.thoughtcrime.securesms.mediasend;
import android.annotation.SuppressLint;
import android.app.ProgressDialog;
import android.arch.lifecycle.ViewModelProviders;
import android.content.Context;
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 android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v4.view.ViewPager;
import android.support.v7.app.AlertDialog;
import android.support.v7.view.ContextThemeWrapper;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.widget.TextView;
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.EmojiDrawer;
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.providers.PersistentBlobProvider;
import org.thoughtcrime.securesms.scribbles.widget.ScribbleView;
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.views.Stub;
import java.io.ByteArrayOutputStream;
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
{
private static final String TAG = MediaSendFragment.class.getSimpleName();
private static final String KEY_BODY = "body";
private static final String KEY_TRANSPORT = "transport";
private static final String KEY_LOCALE = "locale";
private InputAwareLayout hud;
private SendButton sendButton;
private View addButton;
private ComposeText composeText;
private ViewGroup composeContainer;
private EmojiEditText captionText;
private EmojiToggle emojiToggle;
private Stub<EmojiDrawer> emojiDrawer;
private ViewGroup playbackControlsContainer;
private TextView charactersLeft;
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 String body, @NonNull TransportOption transport, @NonNull Locale locale) {
Bundle args = new Bundle();
args.putString(KEY_BODY, body);
args.putParcelable(KEY_TRANSPORT, transport);
args.putSerializable(KEY_LOCALE, locale);
MediaSendFragment fragment = new MediaSendFragment();
fragment.setArguments(args);
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)
.inflate(R.layout.mediasend_fragment, container, false);
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
locale = (Locale) getArguments().getSerializable(KEY_LOCALE);
initViewModel();
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
hud = view.findViewById(R.id.mediasend_hud);
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);
addButton = view.findViewById(R.id.mediasend_add_button);
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();
emojiToggle.setOnClickListener(this::onEmojiToggleClicked);
fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(requireActivity().getSupportFragmentManager(), locale);
fragmentPager.setAdapter(fragmentPagerAdapter);
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(getArguments().getString(KEY_BODY));
}
@Override
public void onStart() {
super.onStart();
fragmentPagerAdapter.restoreState(viewModel.getDrawState());
}
@Override
public void onStop() {
super.onStop();
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(fragmentPager.getCurrentItem() + distanceFromActive);
}
@Override
public void onKeyboardShown() {
if (composeText.hasFocus()) {
composeContainer.setVisibility(View.VISIBLE);
captionText.setVisibility(View.GONE);
} else if (captionText.hasFocus()) {
mediaRail.setVisibility(View.GONE);
composeContainer.setVisibility(View.GONE);
}
}
@Override
public void onKeyboardHidden() {
composeContainer.setVisibility(View.VISIBLE);
if (!Util.isEmpty(viewModel.getSelectedMedia().getValue()) && viewModel.getSelectedMedia().getValue().size() > 1) {
mediaRail.setVisibility(View.VISIBLE);
captionText.setVisibility(View.VISIBLE);
}
}
public void onTouchEventsNeeded(boolean needed) {
fragmentPager.setEnabled(!needed);
}
public boolean handleBackPress() {
if (hud.isInputOpen()) {
hud.hideCurrentInput(composeText);
return true;
}
return false;
}
private void initViewModel() {
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(new MediaRepository())).get(MediaSendViewModel.class);
viewModel.getSelectedMedia().observe(this, media -> {
if (Util.isEmpty(media)) {
controller.onNoMediaAvailable();
return;
}
fragmentPagerAdapter.setMedia(media);
mediaRail.setVisibility(media.size() > 1 ? View.VISIBLE : View.GONE);
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().isEmpty()) {
captionText.setText(fragmentPagerAdapter.getAllMedia().get(position).getCaption().or(""));
}
View playbackControls = fragmentPagerAdapter.getPlaybackControls(position);
if (playbackControls != null) {
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
playbackControls.setLayoutParams(params);
playbackControlsContainer.removeAllViews();
playbackControlsContainer.addView(playbackControls);
} else {
playbackControlsContainer.removeAllViews();
}
});
viewModel.getBucketId().observe(this, bucketId -> {
if (bucketId == null || !bucketId.isPresent() || sendButton.getSelectedTransport().isSms()) {
addButton.setVisibility(View.GONE);
} else {
addButton.setVisibility(View.VISIBLE);
addButton.setOnClickListener(v -> controller.onAddMediaClicked(bucketId.get()));
}
});
}
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.maxMessageSize,
characterState.messagesSpent));
charactersLeft.setVisibility(View.VISIBLE);
} else {
charactersLeft.setVisibility(View.GONE);
}
}
private void onEmojiToggleClicked(View v) {
if (!emojiDrawer.resolved()) {
emojiToggle.attach(emojiDrawer.get());
emojiDrawer.get().setEmojiEventListener(new EmojiDrawer.EmojiEventListener() {
@Override
public void onKeyEvent(KeyEvent keyEvent) {
getActiveInputField().dispatchKeyEvent(keyEvent);
}
@Override
public void onEmojiSelected(String emoji) {
getActiveInputField().insertEmoji(emoji);
}
});
}
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, ListenableFuture<Bitmap>> futures = new HashMap<>();
for (Media media : mediaList) {
Object state = savedState.get(media.getUri());
if (state instanceof ScribbleView.SavedState && !((ScribbleView.SavedState) state).isEmpty()) {
futures.put(media, ScribbleView.renderImage(requireContext(), media.getUri(), (ScribbleView.SavedState) state, GlideApp.with(this)));
}
}
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());
for (Media media : mediaList) {
if (futures.containsKey(media)) {
try {
Bitmap bitmap = futures.get(media).get();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos);
Uri uri = PersistentBlobProvider.getInstance(context).create(context, baos.toByteArray(), MediaUtil.IMAGE_JPEG, null);
Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), media.getBucketId(), media.getCaption());
updatedMedia.add(updated);
renderTimer.split("item");
} catch (InterruptedException | ExecutionException e) {
Log.w(TAG, "Failed to render image. Using base image.");
updatedMedia.add(media);
}
} 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();
}
private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
@Override
public void onPageSelected(int position) {
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();
}
@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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,136 @@
package org.thoughtcrime.securesms.mediasend;
import android.arch.lifecycle.LiveData;
import android.arch.lifecycle.MutableLiveData;
import android.arch.lifecycle.ViewModel;
import android.arch.lifecycle.ViewModelProvider;
import android.content.Context;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Manages the observable datasets available in {@link MediaSendActivity}.
*/
class MediaSendViewModel extends ViewModel {
private final MediaRepository repository;
private final MutableLiveData<List<Media>> selectedMedia;
private final MutableLiveData<List<Media>> bucketMedia;
private final MutableLiveData<Integer> position;
private final MutableLiveData<Optional<String>> bucketId;
private final MutableLiveData<List<MediaFolder>> folders;
private final Map<Uri, Object> savedDrawState;
private MediaSendViewModel(@NonNull MediaRepository repository) {
this.repository = repository;
this.selectedMedia = new MutableLiveData<>();
this.bucketMedia = new MutableLiveData<>();
this.position = new MutableLiveData<>();
this.bucketId = new MutableLiveData<>();
this.folders = new MutableLiveData<>();
this.savedDrawState = new HashMap<>();
position.setValue(-1);
}
void setInitialSelectedMedia(@NonNull List<Media> newMedia) {
boolean allBucketsPopulated = Stream.of(newMedia).reduce(true, (populated, m) -> populated && m.getBucketId().isPresent());
selectedMedia.setValue(newMedia);
bucketId.setValue(allBucketsPopulated ? computeBucketId(newMedia) : Optional.absent());
}
void onSelectedMediaChanged(@NonNull List<Media> newMedia) {
selectedMedia.setValue(newMedia);
position.setValue(newMedia.isEmpty() ? -1 : 0);
}
void onFolderSelected(@NonNull String bucketId) {
this.bucketId.setValue(Optional.of(bucketId));
bucketMedia.setValue(Collections.emptyList());
}
void onPageChanged(int position) {
this.position.setValue(position);
}
void onMediaItemRemoved(int position) {
selectedMedia.getValue().remove(position);
selectedMedia.setValue(selectedMedia.getValue());
}
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 saveDrawState(@NonNull Map<Uri, Object> state) {
savedDrawState.clear();
savedDrawState.putAll(state);
}
@NonNull Map<Uri, Object> getDrawState() {
return savedDrawState;
}
LiveData<List<Media>> getSelectedMedia() {
return selectedMedia;
}
LiveData<List<Media>> getMediaInBucket(@NonNull Context context, @NonNull String bucketId) {
repository.getMediaInBucket(context, bucketId, bucketMedia::postValue);
return bucketMedia;
}
@NonNull LiveData<List<MediaFolder>> getFolders(@NonNull Context context) {
repository.getFolders(context, folders::postValue);
return folders;
}
LiveData<Integer> getPosition() {
return position;
}
LiveData<Optional<String>> getBucketId() {
return bucketId;
}
private Optional<String> computeBucketId(@NonNull List<Media> media) {
if (media.isEmpty() || !media.get(0).getBucketId().isPresent()) return Optional.absent();
String candidate = media.get(0).getBucketId().get();
for (int i = 1; i < media.size(); i++) {
if (!Util.equals(candidate, media.get(i).getBucketId().orNull())) {
return Optional.absent();
}
}
return Optional.of(candidate);
}
static class Factory extends ViewModelProvider.NewInstanceFactory {
private final MediaRepository repository;
Factory(@NonNull MediaRepository repository) {
this.repository = repository;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return modelClass.cast(new MediaSendViewModel(repository));
}
}
}

View File

@ -35,6 +35,8 @@ import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.logging.Log;
import android.util.Pair;
import android.view.View;
@ -56,6 +58,7 @@ import org.thoughtcrime.securesms.components.location.SignalPlace;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.providers.PersistentBlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.scribbles.ScribbleActivity;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
@ -370,12 +373,13 @@ public class AttachmentManager {
.execute();
}
public static void selectGallery(Activity activity, int requestCode) {
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull TransportOption transport) {
Permissions.with(activity)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.onAllGranted(() -> selectMediaType(activity, "image/*", new String[] {"image/*", "video/*"}, requestCode))
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.getIntent(activity, recipient, transport), requestCode))
.execute();
}

View File

@ -34,7 +34,7 @@ import org.thoughtcrime.securesms.util.ResUtil;
public class AudioSlide extends Slide {
public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, voiceNote, false));
super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, voiceNote, false));
}
public AudioSlide(Context context, Uri uri, long dataSize, String contentType, boolean voiceNote) {

View File

@ -19,7 +19,7 @@ public class DocumentSlide extends Slide {
@NonNull String contentType, long size,
@Nullable String fileName)
{
super(context, constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, StorageUtil.getCleanFileName(fileName), false, false));
super(context, constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, StorageUtil.getCleanFileName(fileName), null, false, false));
}
@Override

View File

@ -13,8 +13,13 @@ public class GifSlide extends ImageSlide {
super(context, attachment);
}
public GifSlide(Context context, Uri uri, long size, int width, int height) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, true, null, false, false));
this(context, uri, size, width, height, null);
}
public GifSlide(Context context, Uri uri, long size, int width, int height, @Nullable String caption) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, true, null, caption, false, false));
}
@Override

View File

@ -37,7 +37,11 @@ public class ImageSlide extends Slide {
}
public ImageSlide(Context context, Uri uri, long size, int width, int height) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_JPEG, size, width, height, true, null, false, false));
this(context, uri, size, width, height, null);
}
public ImageSlide(Context context, Uri uri, long size, int width, int height, @Nullable String caption) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_JPEG, size, width, height, true, null, caption, false, false));
}
@Override

View File

@ -135,12 +135,13 @@ public abstract class Slide {
protected static Attachment constructAttachmentFromUri(@NonNull Context context,
@NonNull Uri uri,
@NonNull String defaultMime,
long size,
int width,
int height,
boolean hasThumbnail,
@Nullable String fileName,
boolean voiceNote,
long size,
int width,
int height,
boolean hasThumbnail,
@Nullable String fileName,
@Nullable String caption,
boolean voiceNote,
boolean quote)
{
try {
@ -157,7 +158,7 @@ public abstract class Slide {
fastPreflightId,
voiceNote,
quote,
null);
caption);
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}

View File

@ -21,6 +21,7 @@ import android.content.res.Resources.Theme;
import android.net.Uri;
import android.support.annotation.DrawableRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
@ -30,7 +31,11 @@ import org.thoughtcrime.securesms.util.ResUtil;
public class VideoSlide extends Slide {
public VideoSlide(Context context, Uri uri, long dataSize) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(uri), null, false, false));
this(context, uri, dataSize, null);
}
public VideoSlide(Context context, Uri uri, long dataSize, @Nullable String caption) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(uri), null, caption, false, false));
}
public VideoSlide(Context context, Attachment attachment) {

View File

@ -13,8 +13,6 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.whispersystems.libsignal.util.guava.Optional;
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
@ -36,7 +34,7 @@ public class ScribbleActivity extends PassphraseRequiredActionBarActivity implem
setContentView(R.layout.scribble_activity);
if (savedInstanceState == null) {
ScribbleFragment fragment = ScribbleFragment.newInstance(getIntent().getData(), dynamicLanguage.getCurrentLocale(), Optional.absent());
ScribbleFragment fragment = ScribbleFragment.newInstance(getIntent().getData(), dynamicLanguage.getCurrentLocale(), Optional.absent(), false);
getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, fragment).commit();
}
@ -66,4 +64,7 @@ public class ScribbleActivity extends PassphraseRequiredActionBarActivity implem
Toast.makeText(ScribbleActivity.this, R.string.ScribbleActivity_save_failure, Toast.LENGTH_SHORT).show();
finish();
}
@Override
public void onTouchEventsNeeded(boolean needed) { }
}

View File

@ -20,6 +20,7 @@ import android.view.WindowManager;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediasend.MediaSendPageFragment;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.providers.PersistentBlobProvider;
@ -46,13 +47,17 @@ import java.util.concurrent.ExecutionException;
import static android.app.Activity.RESULT_OK;
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public class ScribbleFragment extends Fragment implements ScribbleHud.EventListener, VerticalSlideColorPicker.OnColorChangeListener {
public class ScribbleFragment extends Fragment implements ScribbleHud.EventListener,
VerticalSlideColorPicker.OnColorChangeListener,
MediaSendPageFragment
{
private static final String TAG = ScribbleFragment.class.getSimpleName();
private static final String KEY_IMAGE_URI = "image_uri";
private static final String KEY_LOCALE = "locale";
private static final String KEY_TRANSPORT = "compose_mode";
private static final String KEY_HIDE_SAVE = "hide_save";
public static final int SELECT_STICKER_REQUEST_CODE = 123;
@ -60,15 +65,20 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe
private ScribbleHud scribbleHud;
private ScribbleView scribbleView;
private GlideRequests glideRequests;
private Uri imageUri;
public static ScribbleFragment newInstance(@NonNull Uri imageUri, @NonNull Locale locale, Optional<TransportOption> transport) {
private ScribbleView.SavedState savedState;
public static ScribbleFragment newInstance(@NonNull Uri imageUri, @NonNull Locale locale, Optional<TransportOption> transport, boolean hideSave) {
Bundle args = new Bundle();
args.putParcelable(KEY_IMAGE_URI, imageUri);
args.putSerializable(KEY_LOCALE, locale);
args.putParcelable(KEY_TRANSPORT, transport.orNull());
args.putBoolean(KEY_HIDE_SAVE, hideSave);
ScribbleFragment fragment = new ScribbleFragment();
fragment.setArguments(args);
fragment.setUri(imageUri);
return fragment;
}
@ -79,6 +89,7 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe
throw new IllegalStateException("Parent activity must implement Controller interface.");
}
controller = (Controller) getActivity();
imageUri = getArguments().getParcelable(KEY_IMAGE_URI);
}
@Nullable
@ -97,12 +108,46 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe
scribbleHud.setEventListener(this);
scribbleHud.setTransport(Optional.fromNullable(getArguments().getParcelable(KEY_TRANSPORT)));
scribbleHud.hideSaveButton(getArguments().getBoolean(KEY_HIDE_SAVE));
scribbleHud.setFullscreen((getActivity().getWindow().getAttributes().flags & WindowManager.LayoutParams.FLAG_FULLSCREEN) > 0);
scribbleView.setMotionViewCallback(motionViewCallback);
scribbleView.setDrawingChangedListener(() -> scribbleHud.setColorPalette(scribbleView.getUniqueColors()));
scribbleView.setDrawingMode(false);
scribbleView.setImage(glideRequests, getArguments().getParcelable(KEY_IMAGE_URI));
scribbleView.setImage(glideRequests, imageUri);
if (savedState != null) {
scribbleView.restoreState(savedState);
}
}
@Override
public void setUri(@NonNull Uri uri) {
this.imageUri = uri;
}
@Override
public @NonNull Uri getUri() {
return imageUri;
}
@Override
public @Nullable View getPlaybackControls() {
return null;
}
@Override
public @Nullable Object saveState() {
return scribbleView.saveState();
}
@Override
public void restoreState(@NonNull Object state) {
if (state instanceof ScribbleView.SavedState) {
savedState = (ScribbleView.SavedState) state;
} else {
Log.w(TAG, "Received a bad saved state. Received class: " + state.getClass().getName());
}
}
public boolean isEmojiKeyboardVisible() {
@ -204,27 +249,32 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe
public void onModeStarted(@NonNull ScribbleHud.Mode mode) {
switch (mode) {
case DRAW:
controller.onTouchEventsNeeded(true);
scribbleView.setDrawingMode(true);
scribbleView.setDrawingBrushWidth(ScribbleView.DEFAULT_BRUSH_WIDTH);
break;
case HIGHLIGHT:
controller.onTouchEventsNeeded(true);
scribbleView.setDrawingMode(true);
scribbleView.setDrawingBrushWidth(ScribbleView.DEFAULT_BRUSH_WIDTH * 3);
break;
case TEXT:
controller.onTouchEventsNeeded(true);
scribbleView.setDrawingMode(false);
addTextSticker();
break;
case STICKER:
controller.onTouchEventsNeeded(true);
scribbleView.setDrawingMode(false);
Intent intent = new Intent(getContext(), StickerSelectActivity.class);
startActivityForResult(intent, SELECT_STICKER_REQUEST_CODE);
break;
case NONE:
controller.onTouchEventsNeeded(false);
scribbleView.clearSelection();
scribbleView.setDrawingMode(false);
break;
@ -283,13 +333,16 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe
public void onEntitySelected(@Nullable MotionEntity entity) {
if (entity == null) {
scribbleHud.enterMode(ScribbleHud.Mode.NONE);
controller.onTouchEventsNeeded(false);
} else if (entity instanceof TextEntity) {
int textColor = ((TextEntity) entity).getLayer().getFont().getColor();
scribbleHud.enterMode(ScribbleHud.Mode.TEXT);
scribbleHud.setActiveColor(textColor);
controller.onTouchEventsNeeded(true);
} else {
scribbleHud.enterMode(ScribbleHud.Mode.STICKER);
controller.onTouchEventsNeeded(true);
}
}
@ -302,5 +355,6 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe
public interface Controller {
void onImageEditComplete(@NonNull Uri uri, int width, int height, long size, @NonNull Optional<String> message, @NonNull Optional<TransportOption> transport);
void onImageEditFailure();
void onTouchEventsNeeded(boolean needed);
}
}

View File

@ -201,6 +201,12 @@ public class ScribbleHud extends InputAwareLayout implements ViewTreeObserver.On
}
}
public void hideSaveButton(boolean hide) {
if (hide) {
saveButton.setVisibility(GONE);
}
}
public void dismissEmojiKeyboard() {
hideCurrentInput(composeText);
}

View File

@ -58,9 +58,11 @@ public class CanvasView extends View {
QUBIC_BEZIER;
}
private int canvasWidth = 1;
private int canvasHeight = 1;
private Bitmap bitmap = null;
private int initialWidth = 0;
private int initialHeight = 0;
private int canvasWidth = 1;
private int canvasHeight = 1;
private Bitmap bitmap = null;
private List<Path> pathLists = new ArrayList<Path>();
private List<Paint> paintLists = new ArrayList<Paint>();
@ -177,8 +179,8 @@ public class CanvasView extends View {
Path path = new Path();
// Save for ACTION_MOVE
this.startX = event.getX();
this.startY = event.getY();
this.startX = scaleX(event.getX());
this.startY = scaleY(event.getY());
path.moveTo(this.startX, this.startY);
@ -279,7 +281,7 @@ public class CanvasView extends View {
switch (this.drawer) {
case PEN :
for (int i = 0; i < event.getHistorySize(); i++) {
path.lineTo(event.getHistoricalX(i), event.getHistoricalY(i));
path.lineTo(scaleX(event.getHistoricalX(i)), scaleY(event.getHistoricalY(i)));
}
break;
case LINE :
@ -316,7 +318,7 @@ public class CanvasView extends View {
Path path = this.getCurrentPath();
path.reset();
path.moveTo(this.startX, this.startY);
path.moveTo(scaleX(this.startX), scaleY(this.startY));
path.quadTo(this.controlX, this.controlY, x, y);
}
@ -344,6 +346,25 @@ public class CanvasView extends View {
}
}
public SavedState saveState() {
return new SavedState(pathLists, paintLists, historyPointer, initialWidth, initialHeight, canvasWidth, canvasHeight);
}
public void restoreState(@NonNull SavedState state) {
this.pathLists.clear();
this.pathLists.addAll(state.getPaths());
this.paintLists.clear();
this.paintLists.addAll(state.getPaints());
this.historyPointer = state.getHistoryPointer();
this.initialWidth = state.getInitialWidth();
this.initialHeight = state.getInitialHeight();
postInvalidate();
}
public void setActive(boolean active) {
this.active = active;
}
@ -357,19 +378,8 @@ public class CanvasView extends View {
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// Before "drawPath"
canvas.drawColor(this.baseColor);
if (this.bitmap != null) {
canvas.drawBitmap(this.bitmap, 0F, 0F, new Paint());
}
for (int i = 0; i < this.historyPointer; i++) {
Path path = this.pathLists.get(i);
Paint paint = this.paintLists.get(i);
canvas.drawPath(path, paint);
}
render(canvas);
}
@Override
@ -377,18 +387,41 @@ public class CanvasView extends View {
super.onSizeChanged(w, h, oldw, oldh);
this.canvasWidth = w;
this.canvasHeight = h;
if (initialWidth == 0) {
initialWidth = canvasWidth;
}
if (initialHeight == 0) {
initialHeight = canvasHeight;
}
}
public void render(Canvas canvas) {
float scaleX = 1.0F * canvas.getWidth() / canvasWidth;
float scaleY = 1.0F * canvas.getHeight() / canvasHeight;
render(canvas, initialWidth, initialHeight, canvasWidth, canvasHeight, pathLists, paintLists, historyPointer);
}
public static void render(Canvas canvas, int initialWidth, int initialHeight, int canvasWidth, int canvasHeight, List<Path> pathLists, List<Paint> paintLists, int historyPointer) {
float scaleX = 1f;
float scaleY = 1f;
if (initialWidth > 0) {
scaleX *= (float) canvasWidth / initialWidth;
}
if (initialHeight > 0) {
scaleY *= (float) canvasHeight / initialHeight;
}
scaleX *= (float) canvas.getWidth() / canvasWidth;
scaleY *= (float) canvas.getHeight() / canvasHeight;
Matrix matrix = new Matrix();
matrix.setScale(scaleX, scaleY);
for (int i = 0; i < this.historyPointer; i++) {
Path path = this.pathLists.get(i);
Paint paint = this.paintLists.get(i);
for (int i = 0; i < historyPointer; i++) {
Path path = pathLists.get(i);
Paint paint = paintLists.get(i);
Path scaledPath = new Path();
path.transform(matrix, scaledPath);
@ -785,4 +818,64 @@ public class CanvasView extends View {
return colors;
}
private float scaleX(float x) {
return ((float) initialWidth / canvasWidth) * x;
}
private float scaleY(float y) {
return ((float) initialWidth / canvasWidth) * y;
}
static class SavedState {
private final List<Path> paths;
private final List<Paint> paints;
private final int historyPointer;
private final int initialWidth;
private final int initialHeight;
private final int canvasWidth;
private final int canvasHeight;
SavedState(List<Path> paths, List<Paint> paints, int historyPointer, int initialWidth, int initialHeight, int canvasWidth, int canvasHeight) {
this.paths = new ArrayList<>(paths);
this.paints = new ArrayList<>(paints);
this.historyPointer = historyPointer;
this.initialWidth = initialWidth;
this.initialHeight = initialHeight;
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
}
List<Path> getPaths() {
return paths;
}
List<Paint> getPaints() {
return paints;
}
int getHistoryPointer() {
return historyPointer;
}
int getInitialWidth() {
return initialWidth;
}
int getInitialHeight() {
return initialHeight;
}
int getCanvasWidth() {
return canvasWidth;
}
int getCanvasHeight() {
return canvasHeight;
}
boolean isEmpty() {
return paths.size() <= 1;
}
}
}

View File

@ -49,6 +49,8 @@ import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.FrameLayout;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.scribbles.multitouch.MoveGestureDetector;
@ -146,6 +148,17 @@ public class MotionView extends FrameLayout implements TextWatcher {
updateUI();
}
public SavedState saveState() {
return new SavedState(entities);
}
public void restoreState(@NonNull SavedState savedState) {
this.entities.clear();
this.entities.addAll(savedState.getEntities());
postInvalidate();
}
public void startEditing(TextEntity entity) {
editText.setFocusableInTouchMode(true);
editText.setFocusable(true);
@ -224,7 +237,7 @@ public class MotionView extends FrameLayout implements TextWatcher {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawAllEntities(canvas);
render(canvas, entities);
}
public void render(Canvas canvas) {
@ -232,11 +245,7 @@ public class MotionView extends FrameLayout implements TextWatcher {
draw(canvas);
}
/**
* draws all entities on the canvas
* @param canvas Canvas where to draw all entities
*/
private void drawAllEntities(Canvas canvas) {
public static void render(Canvas canvas, List<MotionEntity> entities) {
for (int i = 0; i < entities.size(); i++) {
entities.get(i).draw(canvas, null);
}
@ -254,7 +263,7 @@ public class MotionView extends FrameLayout implements TextWatcher {
// which doesn't have transparent pixels, the background will be black
bmp.eraseColor(Color.WHITE);
Canvas canvas = new Canvas(bmp);
drawAllEntities(canvas);
render(canvas, entities);
return bmp;
}
@ -494,4 +503,21 @@ public class MotionView extends FrameLayout implements TextWatcher {
}
}
static class SavedState {
private final List<MotionEntity> entities;
SavedState(List<MotionEntity> entities) {
this.entities = new ArrayList<>(entities);
Stream.of(entities).forEach(e -> e.setIsSelected(false));
}
List<MotionEntity> getEntities() {
return entities;
}
boolean isEmpty() {
return entities.isEmpty();
}
}
}

View File

@ -29,7 +29,6 @@ import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageView;
@ -93,8 +92,15 @@ public class ScribbleView extends FrameLayout {
}
public @NonNull ListenableFuture<Bitmap> getRenderedImage(@NonNull GlideRequests glideRequests) {
return renderImage(getContext(), imageUri, saveState(), glideRequests);
}
public static @NonNull ListenableFuture<Bitmap> renderImage(@NonNull Context context,
@Nullable Uri imageUri,
@NonNull SavedState savedState,
@NonNull GlideRequests glideRequests)
{
final SettableFuture<Bitmap> future = new SettableFuture<>();
final Context context = getContext();
final boolean isLowMemory = Util.isLowMemory(context);
if (imageUri == null) {
@ -119,8 +125,15 @@ public class ScribbleView extends FrameLayout {
@Override
public void onResourceReady(@NonNull Bitmap bitmap, @Nullable Transition<? super Bitmap> transition) {
Canvas canvas = new Canvas(bitmap);
motionView.render(canvas);
canvasView.render(canvas);
MotionView.render(canvas, savedState.getMotionState().getEntities());
CanvasView.render(canvas,
savedState.getCanvasState().getInitialWidth(),
savedState.getCanvasState().getInitialHeight(),
savedState.getCanvasState().getCanvasWidth(),
savedState.getCanvasState().getCanvasHeight(),
savedState.getCanvasState().getPaths(),
savedState.getCanvasState().getPaints(),
savedState.getCanvasState().getHistoryPointer());
future.set(bitmap);
}
@ -128,11 +141,20 @@ public class ScribbleView extends FrameLayout {
public void onLoadFailed(@Nullable Drawable errorDrawable) {
future.setException(new Throwable("Failed to load image."));
}
});
});
return future;
}
public SavedState saveState() {
return new SavedState(canvasView.saveState(), motionView.saveState());
}
public void restoreState(@NonNull SavedState state) {
canvasView.restoreState(state.getCanvasState());
motionView.restoreState(state.getMotionState());
}
private void initialize(@NonNull Context context) {
inflate(context, R.layout.scribble_view, this);
@ -221,4 +243,26 @@ public class ScribbleView extends FrameLayout {
public interface DrawingChangedListener {
void onDrawingChanged();
}
public static class SavedState {
private final CanvasView.SavedState canvasState;
private final MotionView.SavedState motionState;
SavedState(@NonNull CanvasView.SavedState canvasState, @NonNull MotionView.SavedState motionState) {
this.canvasState = canvasState;
this.motionState = motionState;
}
CanvasView.SavedState getCanvasState() {
return canvasState;
}
MotionView.SavedState getMotionState() {
return motionState;
}
public boolean isEmpty() {
return canvasState.isEmpty() && motionState.isEmpty();
}
}
}

View File

@ -239,6 +239,8 @@ public class MediaUtil {
if ("com.android.providers.media.documents".equals(uri.getAuthority())) {
return uri.getLastPathSegment().contains("video");
} else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) {
return true;
}
return false;
@ -248,6 +250,13 @@ public class MediaUtil {
if ("com.android.providers.media.documents".equals(uri.getAuthority())) {
long videoId = Long.parseLong(uri.getLastPathSegment().split(":")[1]);
return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(),
videoId,
MediaStore.Images.Thumbnails.MINI_KIND,
null);
} else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) {
long videoId = Long.parseLong(uri.getLastPathSegment());
return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(),
videoId,
MediaStore.Images.Thumbnails.MINI_KIND,

View File

@ -5,7 +5,11 @@ import android.content.res.Resources;
import android.graphics.Color;
import android.support.annotation.AttrRes;
import android.support.annotation.NonNull;
import android.support.annotation.StyleRes;
import android.support.v7.view.ContextThemeWrapper;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import org.thoughtcrime.securesms.R;
@ -25,6 +29,11 @@ public class ThemeUtil {
return Color.RED;
}
public static LayoutInflater getThemedInflater(@NonNull Context context, @NonNull LayoutInflater inflater, @StyleRes int theme) {
Context contextThemeWrapper = new ContextThemeWrapper(context, theme);
return inflater.cloneInContext(contextThemeWrapper);
}
private static String getAttribute(Context context, int attribute, String defaultValue) {
TypedValue outValue = new TypedValue();

View File

@ -133,6 +133,10 @@ public class Util {
return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed());
}
public static boolean isEmpty(Collection collection) {
return collection == null || collection.isEmpty();
}
public static <K, V> V getOrDefault(@NonNull Map<K, V> map, K key, V defaultValue) {
return map.containsKey(key) ? map.get(key) : defaultValue;
}