diff --git a/res/drawable-hdpi/ic_download_circle_fill_white_48dp.png b/res/drawable-hdpi/ic_download_circle_fill_white_48dp.png new file mode 100644 index 0000000000..47d4fc7cd6 Binary files /dev/null and b/res/drawable-hdpi/ic_download_circle_fill_white_48dp.png differ diff --git a/res/drawable-hdpi/ic_pause_circle_fill_white_48dp.png b/res/drawable-hdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000..340c65b4f4 Binary files /dev/null and b/res/drawable-hdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/res/drawable-hdpi/ic_play_circle_fill_white_48dp.png b/res/drawable-hdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000..7c0c0bc2db Binary files /dev/null and b/res/drawable-hdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/res/drawable-mdpi/ic_download_circle_fill_white_48dp.png b/res/drawable-mdpi/ic_download_circle_fill_white_48dp.png new file mode 100644 index 0000000000..fbea4f468f Binary files /dev/null and b/res/drawable-mdpi/ic_download_circle_fill_white_48dp.png differ diff --git a/res/drawable-mdpi/ic_pause_circle_fill_white_48dp.png b/res/drawable-mdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000..55b14334b2 Binary files /dev/null and b/res/drawable-mdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/res/drawable-mdpi/ic_play_circle_fill_white_48dp.png b/res/drawable-mdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000..64c1f79ab8 Binary files /dev/null and b/res/drawable-mdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/res/drawable-xhdpi/ic_download_circle_fill_white_48dp.png b/res/drawable-xhdpi/ic_download_circle_fill_white_48dp.png new file mode 100644 index 0000000000..4fcc4d38a4 Binary files /dev/null and b/res/drawable-xhdpi/ic_download_circle_fill_white_48dp.png differ diff --git a/res/drawable-xhdpi/ic_pause_circle_fill_white_48dp.png b/res/drawable-xhdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000..bd2c7793dd Binary files /dev/null and b/res/drawable-xhdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/res/drawable-xhdpi/ic_play_circle_fill_white_48dp.png b/res/drawable-xhdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000..12ed3bfcb8 Binary files /dev/null and b/res/drawable-xhdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/res/drawable-xxhdpi/ic_download_circle_fill_white_48dp.png b/res/drawable-xxhdpi/ic_download_circle_fill_white_48dp.png new file mode 100644 index 0000000000..6ac6077150 Binary files /dev/null and b/res/drawable-xxhdpi/ic_download_circle_fill_white_48dp.png differ diff --git a/res/drawable-xxhdpi/ic_pause_circle_fill_white_48dp.png b/res/drawable-xxhdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000..9c5c1abd02 Binary files /dev/null and b/res/drawable-xxhdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/res/drawable-xxhdpi/ic_play_circle_fill_white_48dp.png b/res/drawable-xxhdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000..23e189c8fc Binary files /dev/null and b/res/drawable-xxhdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/res/drawable-xxxhdpi/ic_download_circle_fill_white_48dp.png b/res/drawable-xxxhdpi/ic_download_circle_fill_white_48dp.png new file mode 100644 index 0000000000..db6ce0ff60 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_download_circle_fill_white_48dp.png differ diff --git a/res/drawable-xxxhdpi/ic_pause_circle_fill_white_48dp.png b/res/drawable-xxxhdpi/ic_pause_circle_fill_white_48dp.png new file mode 100644 index 0000000000..236c1c5071 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_pause_circle_fill_white_48dp.png differ diff --git a/res/drawable-xxxhdpi/ic_play_circle_fill_white_48dp.png b/res/drawable-xxxhdpi/ic_play_circle_fill_white_48dp.png new file mode 100644 index 0000000000..d08a2ed51b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_play_circle_fill_white_48dp.png differ diff --git a/res/layout/audio_view.xml b/res/layout/audio_view.xml new file mode 100644 index 0000000000..a83df65ad0 --- /dev/null +++ b/res/layout/audio_view.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/conversation_activity.xml b/res/layout/conversation_activity.xml index 48a4716e46..0a5ba8ed5d 100644 --- a/res/layout/conversation_activity.xml +++ b/res/layout/conversation_activity.xml @@ -37,13 +37,32 @@ android:background="?android:windowBackground" android:visibility="gone"> - + + + + + + + diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml index 4f38aaf108..3cadef9b63 100644 --- a/res/layout/conversation_item_received.xml +++ b/res/layout/conversation_item_received.xml @@ -1,12 +1,14 @@ - + + tools:visibility="gone" /> + + + 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:id="@+id/conversation_item" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:background="@drawable/conversation_item_background"> + + + android:layout_gravity="top|right" + android:src="@drawable/conversation_attachment_close_circle" + android:visibility="gone"/> diff --git a/res/layout/thumbnail_view.xml b/res/layout/thumbnail_view.xml index fc420fd56c..1e446b0552 100644 --- a/res/layout/thumbnail_view.xml +++ b/res/layout/thumbnail_view.xml @@ -16,9 +16,4 @@ android:layout_gravity="center" android:layout="@layout/transfer_controls_stub" /> - diff --git a/res/values/attrs.xml b/res/values/attrs.xml index cbada251db..d89c892452 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -134,4 +134,9 @@ + + + + + diff --git a/src/org/thoughtcrime/securesms/BindableConversationItem.java b/src/org/thoughtcrime/securesms/BindableConversationItem.java index 553b9f5ac9..c6fd33cfd9 100644 --- a/src/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/src/org/thoughtcrime/securesms/BindableConversationItem.java @@ -4,6 +4,7 @@ import android.support.annotation.NonNull; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipients; import java.util.Locale; import java.util.Set; @@ -13,5 +14,5 @@ public interface BindableConversationItem extends Unbindable { @NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull Set batchSelected, - boolean groupThread); + @NonNull Recipients recipients); } diff --git a/src/org/thoughtcrime/securesms/ConversationAdapter.java b/src/org/thoughtcrime/securesms/ConversationAdapter.java index 94d310385e..9a2b599ed9 100644 --- a/src/org/thoughtcrime/securesms/ConversationAdapter.java +++ b/src/org/thoughtcrime/securesms/ConversationAdapter.java @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipients; import org.thoughtcrime.securesms.util.LRUCache; import java.lang.ref.SoftReference; @@ -68,12 +69,12 @@ public class ConversationAdapter private final Set batchSelected = Collections.synchronizedSet(new HashSet()); - private final ItemClickListener clickListener; - private final MasterSecret masterSecret; - private final Locale locale; - private final boolean groupThread; - private final MmsSmsDatabase db; - private final LayoutInflater inflater; + private final ItemClickListener clickListener; + private final MasterSecret masterSecret; + private final Locale locale; + private final Recipients recipients; + private final MmsSmsDatabase db; + private final LayoutInflater inflater; protected static class ViewHolder extends RecyclerView.ViewHolder { public ViewHolder(final @NonNull V itemView) { @@ -96,15 +97,15 @@ public class ConversationAdapter @NonNull Locale locale, @Nullable ItemClickListener clickListener, @Nullable Cursor cursor, - boolean groupThread) + @NonNull Recipients recipients) { super(context, cursor); - this.masterSecret = masterSecret; - this.locale = locale; - this.clickListener = clickListener; - this.groupThread = groupThread; - this.inflater = LayoutInflater.from(context); - this.db = DatabaseFactory.getMmsSmsDatabase(context); + this.masterSecret = masterSecret; + this.locale = locale; + this.clickListener = clickListener; + this.recipients = recipients; + this.inflater = LayoutInflater.from(context); + this.db = DatabaseFactory.getMmsSmsDatabase(context); } @Override @@ -118,7 +119,7 @@ public class ConversationAdapter String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)); MessageRecord messageRecord = getMessageRecord(id, cursor, type); - viewHolder.getView().bind(masterSecret, messageRecord, locale, batchSelected, groupThread); + viewHolder.getView().bind(masterSecret, messageRecord, locale, batchSelected, recipients); } @Override public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { diff --git a/src/org/thoughtcrime/securesms/ConversationFragment.java b/src/org/thoughtcrime/securesms/ConversationFragment.java index 16cb98a5ab..81fb1c8062 100644 --- a/src/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationFragment.java @@ -142,8 +142,7 @@ public class ConversationFragment extends Fragment private void initializeListAdapter() { if (this.recipients != null && this.threadId != -1) { - list.setAdapter(new ConversationAdapter(getActivity(), masterSecret, locale, selectionClickListener, null, - (!this.recipients.isSingleRecipient()) || this.recipients.isGroupRecipient())); + list.setAdapter(new ConversationAdapter(getActivity(), masterSecret, locale, selectionClickListener, null, this.recipients)); getLoaderManager().restartLoader(0, Bundle.EMPTY, this); } } diff --git a/src/org/thoughtcrime/securesms/ConversationItem.java b/src/org/thoughtcrime/securesms/ConversationItem.java index 2b5b8c1332..a16cff52de 100644 --- a/src/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/org/thoughtcrime/securesms/ConversationItem.java @@ -41,13 +41,14 @@ import android.widget.Toast; import com.afollestad.materialdialogs.AlertDialogWrapper; +import org.thoughtcrime.securesms.components.AudioView; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase; -import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; @@ -58,10 +59,13 @@ import org.thoughtcrime.securesms.jobs.MmsSendJob; import org.thoughtcrime.securesms.jobs.SmsSendJob; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.Recipients; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.Util; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; @@ -75,7 +79,7 @@ import java.util.Set; */ public class ConversationItem extends LinearLayout - implements Recipient.RecipientModifiedListener, BindableConversationItem + implements Recipient.RecipientModifiedListener, Recipients.RecipientsModifiedListener, BindableConversationItem { private final static String TAG = ConversationItem.class.getSimpleName(); @@ -98,11 +102,13 @@ public class ConversationItem extends LinearLayout private View pendingIndicator; private ImageView pendingApprovalIndicator; - private StatusManager statusManager; - private Set batchSelected; - private ThumbnailView mediaThumbnail; - private Button mmsDownloadButton; - private TextView mmsDownloadingLabel; + private @NonNull Set batchSelected = new HashSet<>(); + private @Nullable Recipients conversationRecipients; + private @NonNull StatusManager statusManager; + private @NonNull ThumbnailView mediaThumbnail; + private @NonNull AudioView audioView; + private @NonNull Button mmsDownloadButton; + private @NonNull TextView mmsDownloadingLabel; private int defaultBubbleColor; @@ -152,15 +158,20 @@ public class ConversationItem extends LinearLayout this.pendingApprovalIndicator = (ImageView) findViewById(R.id.pending_approval_indicator); this.pendingIndicator = findViewById(R.id.pending_indicator); this.mediaThumbnail = (ThumbnailView) findViewById(R.id.image_view); + this.audioView = (AudioView) findViewById(R.id.audio_view); this.statusManager = new StatusManager(pendingIndicator, sentIndicator, deliveredIndicator, failedIndicator, pendingApprovalIndicator); setOnClickListener(new ClickListener(null)); - PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); - if (mmsDownloadButton != null) mmsDownloadButton.setOnClickListener(mmsDownloadClickListener); + PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); + AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener(); + + mmsDownloadButton.setOnClickListener(mmsDownloadClickListener); mediaThumbnail.setThumbnailClickListener(new ThumbnailClickListener()); - mediaThumbnail.setDownloadClickListener(new ThumbnailDownloadClickListener()); + mediaThumbnail.setDownloadClickListener(downloadClickListener); mediaThumbnail.setOnLongClickListener(passthroughClickListener); mediaThumbnail.setOnClickListener(passthroughClickListener); + audioView.setDownloadClickListener(downloadClickListener); + audioView.setOnLongClickListener(passthroughClickListener); bodyText.setOnLongClickListener(passthroughClickListener); bodyText.setOnClickListener(passthroughClickListener); } @@ -170,16 +181,18 @@ public class ConversationItem extends LinearLayout @NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull Set batchSelected, - boolean groupThread) + @NonNull Recipients conversationRecipients) { this.masterSecret = masterSecret; this.messageRecord = messageRecord; this.locale = locale; this.batchSelected = batchSelected; - this.groupThread = groupThread; + this.conversationRecipients = conversationRecipients; + this.groupThread = !conversationRecipients.isSingleRecipient() || conversationRecipients.isGroupRecipient(); this.recipient = messageRecord.getIndividualRecipient(); this.recipient.addListener(this); + this.conversationRecipients.addListener(this); setInteractionState(messageRecord); setBodyText(messageRecord); @@ -218,6 +231,7 @@ public class ConversationItem extends LinearLayout if (messageRecord.isOutgoing()) { bodyBubble.getBackground().setColorFilter(defaultBubbleColor, PorterDuff.Mode.MULTIPLY); mediaThumbnail.setBackgroundColorHint(defaultBubbleColor); + audioView.setTint(conversationRecipients.getColor().toConversationColor(context)); } else { int color = recipient.getColor().toConversationColor(context); bodyBubble.getBackground().setColorFilter(color, PorterDuff.Mode.MULTIPLY); @@ -237,7 +251,13 @@ public class ConversationItem extends LinearLayout return TextUtils.isEmpty(messageRecord.getDisplayBody()) && messageRecord.isMms(); } - private boolean hasMedia(MessageRecord messageRecord) { + private boolean hasAudio(MessageRecord messageRecord) { + return messageRecord.isMms() && + !messageRecord.isMmsNotification() && + ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null; + } + + private boolean hasThumbnail(MessageRecord messageRecord) { return messageRecord.isMms() && !messageRecord.isMmsNotification() && ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide() != null; @@ -256,20 +276,33 @@ public class ConversationItem extends LinearLayout } private void setMediaAttributes(MessageRecord messageRecord) { + boolean showControls = !messageRecord.isFailed() && (!messageRecord.isOutgoing() || messageRecord.isPending()); + if (messageRecord.isMmsNotification()) { mediaThumbnail.setVisibility(View.GONE); + audioView.setVisibility(View.GONE); + bodyText.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); setNotificationMmsAttributes((NotificationMmsMessageRecord) messageRecord); - } else if (hasMedia(messageRecord)) { + } else if (hasAudio(messageRecord)) { + audioView.setVisibility(View.VISIBLE); + mediaThumbnail.setVisibility(View.GONE); + + //noinspection ConstantConditions + audioView.setAudio(masterSecret, ((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide(), showControls); + bodyText.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + } else if (hasThumbnail(messageRecord)) { mediaThumbnail.setVisibility(View.VISIBLE); + audioView.setVisibility(View.GONE); + //noinspection ConstantConditions mediaThumbnail.setImageResource(masterSecret, ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide(), - !messageRecord.isFailed() && (!messageRecord.isOutgoing() || messageRecord.isPending()), - false); + showControls); bodyText.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); } else { mediaThumbnail.setVisibility(View.GONE); + audioView.setVisibility(View.GONE); bodyText.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); } } @@ -400,7 +433,12 @@ public class ConversationItem extends LinearLayout }); } - private class ThumbnailDownloadClickListener implements ThumbnailView.ThumbnailClickListener { + @Override + public void onModified(Recipients recipient) { + onModified(recipient.getPrimaryRecipient()); + } + + private class AttachmentDownloadClickListener implements SlideClickListener { @Override public void onClick(View v, final Slide slide) { DatabaseFactory.getAttachmentDatabase(context).setTransferState(messageRecord.getId(), slide.asAttachment(), @@ -408,7 +446,7 @@ public class ConversationItem extends LinearLayout } } - private class ThumbnailClickListener implements ThumbnailView.ThumbnailClickListener { + private class ThumbnailClickListener implements SlideClickListener { private void fireIntent(Slide slide) { Log.w(TAG, "Clicked: " + slide.getUri() + " , " + slide.getContentType()); Intent intent = new Intent(Intent.ACTION_VIEW); diff --git a/src/org/thoughtcrime/securesms/ConversationUpdateItem.java b/src/org/thoughtcrime/securesms/ConversationUpdateItem.java index b42def811d..c31b8b68a3 100644 --- a/src/org/thoughtcrime/securesms/ConversationUpdateItem.java +++ b/src/org/thoughtcrime/securesms/ConversationUpdateItem.java @@ -56,7 +56,7 @@ public class ConversationUpdateItem extends LinearLayout @NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull Set batchSelected, - boolean groupThread) + @NonNull Recipients conversationRecipients) { bind(messageRecord, locale); } diff --git a/src/org/thoughtcrime/securesms/ImageMediaAdapter.java b/src/org/thoughtcrime/securesms/ImageMediaAdapter.java index cd27ca41fe..3d51d6bf51 100644 --- a/src/org/thoughtcrime/securesms/ImageMediaAdapter.java +++ b/src/org/thoughtcrime/securesms/ImageMediaAdapter.java @@ -70,7 +70,7 @@ public class ImageMediaAdapter extends CursorRecyclerViewAdapter { Slide slide = MediaUtil.getSlideForAttachment(getContext(), imageRecord.getAttachment()); if (slide != null) { - imageView.setImageResource(masterSecret, slide, false, false); + imageView.setImageResource(masterSecret, slide, false); } imageView.setOnClickListener(new OnMediaClickListener(imageRecord)); diff --git a/src/org/thoughtcrime/securesms/MessageDetailsActivity.java b/src/org/thoughtcrime/securesms/MessageDetailsActivity.java index bd59046fec..45ffce71fd 100644 --- a/src/org/thoughtcrime/securesms/MessageDetailsActivity.java +++ b/src/org/thoughtcrime/securesms/MessageDetailsActivity.java @@ -172,8 +172,7 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity } toFrom.setText(toFromRes); conversationItem.bind(masterSecret, messageRecord, dynamicLanguage.getCurrentLocale(), - new HashSet(), - recipients != messageRecord.getRecipients()); + new HashSet(), recipients); recipientsList.setAdapter(new MessageDetailsRecipientAdapter(this, masterSecret, messageRecord, recipients, isPushGroup)); } diff --git a/src/org/thoughtcrime/securesms/attachments/UriAttachment.java b/src/org/thoughtcrime/securesms/attachments/UriAttachment.java index 8c2749878b..c7b73bd528 100644 --- a/src/org/thoughtcrime/securesms/attachments/UriAttachment.java +++ b/src/org/thoughtcrime/securesms/attachments/UriAttachment.java @@ -13,15 +13,15 @@ import java.io.InputStream; public class UriAttachment extends Attachment { - private final Uri dataUri; - private final Uri thumbnailUri; + private final @NonNull Uri dataUri; + private final @NonNull Uri thumbnailUri; - public UriAttachment(Uri uri, String contentType, int transferState, long size) { + public UriAttachment(@NonNull Uri uri, @NonNull String contentType, int transferState, long size) { this(uri, uri, contentType, transferState, size); } - public UriAttachment(Uri dataUri, Uri thumbnailUri, - String contentType, int transferState, long size) + public UriAttachment(@NonNull Uri dataUri, @NonNull Uri thumbnailUri, + @NonNull String contentType, int transferState, long size) { super(contentType, transferState, size, null, null, null); this.dataUri = dataUri; @@ -39,4 +39,14 @@ public class UriAttachment extends Attachment { public Uri getThumbnailUri() { return thumbnailUri; } + + @Override + public boolean equals(Object other) { + return other != null && other instanceof UriAttachment && ((UriAttachment) other).dataUri.equals(this.dataUri); + } + + @Override + public int hashCode() { + return dataUri.hashCode(); + } } diff --git a/src/org/thoughtcrime/securesms/audio/AudioAttachmentServer.java b/src/org/thoughtcrime/securesms/audio/AudioAttachmentServer.java new file mode 100644 index 0000000000..31e67a767e --- /dev/null +++ b/src/org/thoughtcrime/securesms/audio/AudioAttachmentServer.java @@ -0,0 +1,376 @@ +package org.thoughtcrime.securesms.audio; + + +import android.content.Context; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.util.Log; + +import org.spongycastle.util.encoders.Hex; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.Util; + +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.security.MessageDigest; +import java.util.Map; +import java.util.Properties; +import java.util.StringTokenizer; + +/** + * @author Stefan "frostymarvelous" Froelich + */ +public class AudioAttachmentServer implements Runnable { + + private static final String TAG = AudioAttachmentServer.class.getSimpleName(); + + private final Context context; + private final MasterSecret masterSecret; + private final Attachment attachment; + private final ServerSocket socket; + private final int port; + private final String auth; + + private volatile boolean isRunning; + + public AudioAttachmentServer(Context context, MasterSecret masterSecret, Attachment attachment) + throws IOException + { + try { + this.context = context; + this.masterSecret = masterSecret; + this.attachment = attachment; + this.socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1})); + this.port = socket.getLocalPort(); + this.auth = new String(Hex.encode(Util.getSecretBytes(16))); + + this.socket.setSoTimeout(5000); + } catch (UnknownHostException e) { + throw new AssertionError(e); + } + } + + public Uri getUri() { + return Uri.parse(String.format("http://127.0.0.1:%d/%s", port, auth)); + } + + public void start() { + isRunning = true; + new Thread(this).start(); + } + + public void stop() { + isRunning = false; + } + + @Override + public void run() { + while (isRunning) { + Socket client = null; + + try { + client = socket.accept(); + + if (client != null) { + StreamToMediaPlayerTask task = new StreamToMediaPlayerTask(client, "/" + auth); + + if (task.processRequest()) { + task.execute(); + } + } + + } catch (SocketTimeoutException e) { + Log.w(TAG, e); + } catch (IOException e) { + Log.e(TAG, "Error connecting to client", e); + } finally { + try {if (client != null) client.close();} catch (IOException e) {} + } + } + + Log.d(TAG, "Proxy interrupted. Shutting down."); + } + + + private class StreamToMediaPlayerTask { + + private final @NonNull Socket client; + private final @NonNull String auth; + + private long cbSkip; + private Properties parameters; + private Properties request; + private Properties requestHeaders; +// private String filePath; + + public StreamToMediaPlayerTask(@NonNull Socket client, @NonNull String auth) { + this.client = client; + this.auth = auth; + } + + public boolean processRequest() throws IOException { + InputStream is = client.getInputStream(); + final int bufferSize = 8192; + byte[] buffer = new byte[bufferSize]; + int splitByte = 0; + int readLength = 0; + + { + int read = is.read(buffer, 0, bufferSize); + while (read > 0) { + readLength += read; + splitByte = findHeaderEnd(buffer, readLength); + if (splitByte > 0) + break; + read = is.read(buffer, readLength, bufferSize - readLength); + } + } + + // Create a BufferedReader for parsing the header. + ByteArrayInputStream hbis = new ByteArrayInputStream(buffer, 0, readLength); + BufferedReader hin = new BufferedReader(new InputStreamReader(hbis)); + + request = new Properties(); + parameters = new Properties(); + requestHeaders = new Properties(); + + try { + decodeHeader(hin, request, parameters, requestHeaders); + } catch (InterruptedException e1) { + Log.e(TAG, "Exception: " + e1.getMessage()); + e1.printStackTrace(); + } + + for (Map.Entry e : requestHeaders.entrySet()) { + Log.i(TAG, "Header: " + e.getKey() + " : " + e.getValue()); + } + + String range = requestHeaders.getProperty("range"); + + if (range != null) { + Log.i(TAG, "range is: " + range); + range = range.substring(6); + int charPos = range.indexOf('-'); + if (charPos > 0) { + range = range.substring(0, charPos); + } + cbSkip = Long.parseLong(range); + Log.i(TAG, "range found!! " + cbSkip); + } + + if(!request.get("method").equals("GET")) { + Log.e(TAG, "Only GET is supported"); + return false; + } + + String receivedAuth = request.getProperty("uri"); + + if (receivedAuth == null || !MessageDigest.isEqual(receivedAuth.getBytes(), auth.getBytes())) { + Log.w(TAG, "Bad auth token!"); + return false; + } + +// filePath = request.getProperty("uri"); + + return true; + } + + protected void execute() throws IOException { + InputStream inputStream = PartAuthority.getAttachmentStream(context, masterSecret, attachment.getDataUri()); + long fileSize = attachment.getSize(); + + String headers = ""; + if (cbSkip > 0) {// It is a seek or skip request if there's a Range + // header + headers += "HTTP/1.1 206 Partial Content\r\n"; + headers += "Content-Type: " + attachment.getContentType() + "\r\n"; + headers += "Accept-Ranges: bytes\r\n"; + headers += "Content-Length: " + (fileSize - cbSkip) + "\r\n"; + headers += "Content-Range: bytes " + cbSkip + "-" + (fileSize - 1) + "/" + fileSize + "\r\n"; + headers += "Connection: Keep-Alive\r\n"; + headers += "\r\n"; + } else { + headers += "HTTP/1.1 200 OK\r\n"; + headers += "Content-Type: " + attachment.getContentType() + "\r\n"; + headers += "Accept-Ranges: bytes\r\n"; + headers += "Content-Length: " + fileSize + "\r\n"; + headers += "Connection: Keep-Alive\r\n"; + headers += "\r\n"; + } + + Log.i(TAG, "headers: " + headers); + + OutputStream output = null; + byte[] buff = new byte[64 * 1024]; + try { + output = new BufferedOutputStream(client.getOutputStream(), 32 * 1024); + output.write(headers.getBytes()); + + inputStream.skip(cbSkip); +// dataSource.skipFully(data, cbSkip);//try to skip as much as possible + + // Loop as long as there's stuff to send and client has not closed + int cbRead; + while (!client.isClosed() && (cbRead = inputStream.read(buff, 0, buff.length)) != -1) { + output.write(buff, 0, cbRead); + } + } + catch (SocketException socketException) { + Log.e(TAG, "SocketException() thrown, proxy client has probably closed. This can exit harmlessly"); + } + catch (Exception e) { + Log.e(TAG, "Exception thrown from streaming task:"); + Log.e(TAG, e.getClass().getName() + " : " + e.getLocalizedMessage()); + } + + // Cleanup + try { + if (output != null) { + output.close(); + } + client.close(); + } + catch (IOException e) { + Log.e(TAG, "IOException while cleaning up streaming task:"); + Log.e(TAG, e.getClass().getName() + " : " + e.getLocalizedMessage()); + e.printStackTrace(); + } + } + + /** + * Find byte index separating header from body. It must be the last byte of + * the first two sequential new lines. + **/ + private int findHeaderEnd(final byte[] buf, int rlen) { + int splitbyte = 0; + while (splitbyte + 3 < rlen) { + if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' + && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') + return splitbyte + 4; + splitbyte++; + } + return 0; + } + + + /** + * Decodes the sent headers and loads the data into java Properties' key - + * value pairs + **/ + private void decodeHeader(BufferedReader in, Properties pre, + Properties parms, Properties header) throws InterruptedException { + try { + // Read the request line + String inLine = in.readLine(); + if (inLine == null) + return; + StringTokenizer st = new StringTokenizer(inLine); + if (!st.hasMoreTokens()) + Log.e(TAG, + "BAD REQUEST: Syntax error. Usage: GET /example/file.html"); + + String method = st.nextToken(); + pre.put("method", method); + + if (!st.hasMoreTokens()) + Log.e(TAG, + "BAD REQUEST: Missing URI. Usage: GET /example/file.html"); + + String uri = st.nextToken(); + + // Decode parameters from the URI + int qmi = uri.indexOf('?'); + if (qmi >= 0) { + decodeParms(uri.substring(qmi + 1), parms); + uri = decodePercent(uri.substring(0, qmi)); + } else + uri = decodePercent(uri); + + // If there's another token, it's protocol version, + // followed by HTTP headers. Ignore version but parse headers. + // NOTE: this now forces header names lowercase since they are + // case insensitive and vary by client. + if (st.hasMoreTokens()) { + String line = in.readLine(); + while (line != null && line.trim().length() > 0) { + int p = line.indexOf(':'); + if (p >= 0) + header.put(line.substring(0, p).trim().toLowerCase(), + line.substring(p + 1).trim()); + line = in.readLine(); + } + } + + pre.put("uri", uri); + } catch (IOException ioe) { + Log.e(TAG, + "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); + } + } + + /** + * Decodes parameters in percent-encoded URI-format ( e.g. + * "name=Jack%20Daniels&pass=Single%20Malt" ) and adds them to given + * Properties. NOTE: this doesn't support multiple identical keys due to the + * simplicity of Properties -- if you need multiples, you might want to + * replace the Properties with a Hashtable of Vectors or such. + */ + private void decodeParms(String parms, Properties p) + throws InterruptedException { + if (parms == null) + return; + + StringTokenizer st = new StringTokenizer(parms, "&"); + while (st.hasMoreTokens()) { + String e = st.nextToken(); + int sep = e.indexOf('='); + if (sep >= 0) + p.put(decodePercent(e.substring(0, sep)).trim(), + decodePercent(e.substring(sep + 1))); + } + } + + /** + * Decodes the percent encoding scheme.
+ * For example: "an+example%20string" -> "an example string" + */ + private String decodePercent(String str) throws InterruptedException { + try { + StringBuffer sb = new StringBuffer(); + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + switch (c) { + case '+': + sb.append(' '); + break; + case '%': + sb.append((char) Integer.parseInt( + str.substring(i + 1, i + 3), 16)); + i += 2; + break; + default: + sb.append(c); + break; + } + } + return sb.toString(); + } catch (Exception e) { + Log.e(TAG, "BAD REQUEST: Bad percent-encoding."); + return null; + } + } + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java new file mode 100644 index 0000000000..bb0dab829f --- /dev/null +++ b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -0,0 +1,237 @@ +package org.thoughtcrime.securesms.audio; + +import android.content.Context; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.os.Handler; +import android.os.Message; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; +import android.util.Pair; + +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libaxolotl.util.guava.Optional; + +import java.io.IOException; +import java.lang.ref.WeakReference; + +public class AudioSlidePlayer { + + private static final String TAG = AudioSlidePlayer.class.getSimpleName(); + + private static @NonNull Optional playing = Optional.absent(); + + private final @NonNull Context context; + private final @NonNull MasterSecret masterSecret; + private final @NonNull AudioSlide slide; + private final @NonNull Handler progressEventHandler; + + private @NonNull WeakReference listener; + private @Nullable MediaPlayer mediaPlayer; + private @Nullable AudioAttachmentServer audioAttachmentServer; + + public synchronized static AudioSlidePlayer createFor(@NonNull Context context, + @NonNull MasterSecret masterSecret, + @NonNull AudioSlide slide, + @NonNull Listener listener) + { + if (playing.isPresent() && playing.get().getAudioSlide().equals(slide)) { + playing.get().setListener(listener); + return playing.get(); + } else { + return new AudioSlidePlayer(context, masterSecret, slide, listener); + } + } + + private AudioSlidePlayer(@NonNull Context context, + @NonNull MasterSecret masterSecret, + @NonNull AudioSlide slide, + @NonNull Listener listener) + { + this.context = context; + this.masterSecret = masterSecret; + this.slide = slide; + this.listener = new WeakReference<>(listener); + this.progressEventHandler = new ProgressEventHandler(this); + } + + public void play(final double progress) throws IOException { + if (this.mediaPlayer != null) return; + + this.mediaPlayer = new MediaPlayer(); + this.audioAttachmentServer = new AudioAttachmentServer(context, masterSecret, slide.asAttachment()); + + audioAttachmentServer.start(); + + mediaPlayer.setDataSource(context, audioAttachmentServer.getUri()); + mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(MediaPlayer mp) { + Log.w(TAG, "onPrepared"); + if (progress > 0) { + mediaPlayer.seekTo((int)(mediaPlayer.getDuration() * progress)); + } + + mediaPlayer.start(); + + notifyOnStart(); + setPlaying(AudioSlidePlayer.this); + progressEventHandler.sendEmptyMessage(0); + } + }); + + mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mp) { + Log.w(TAG, "onComplete"); + mediaPlayer = null; + audioAttachmentServer.stop(); + audioAttachmentServer = null; + + notifyOnStop(); + progressEventHandler.removeMessages(0); + } + }); + + mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + Log.w(TAG, "MediaPlayer Error: " + what + " , " + extra); + notifyOnStop(); + return true; + } + }); + + mediaPlayer.prepareAsync(); + } + + public void stop() { + Log.w(TAG, "Stop called!"); + shutdown(); + } + + public void setListener(@NonNull Listener listener) { + this.listener = new WeakReference<>(listener); + + if (this.mediaPlayer != null && this.mediaPlayer.isPlaying()) { + notifyOnStart(); + } + } + + public @NonNull AudioSlide getAudioSlide() { + return slide; + } + + private void shutdown() { + removePlaying(this); + + if (this.mediaPlayer != null) { + this.mediaPlayer.stop(); + } + + if (this.audioAttachmentServer != null) { + this.audioAttachmentServer.stop(); + } + + this.mediaPlayer = null; + this.audioAttachmentServer = null; + } + + private Pair getProgress() { + if (mediaPlayer == null || mediaPlayer.getCurrentPosition() <= 0 || mediaPlayer.getDuration() <= 0) { + return new Pair<>(0D, 0); + } else { + return new Pair<>((double) mediaPlayer.getCurrentPosition() / (double) mediaPlayer.getDuration(), + mediaPlayer.getCurrentPosition()); + } + } + + private void notifyOnStart() { + Util.runOnMain(new Runnable() { + @Override + public void run() { + getListener().onStart(); + } + }); + } + + private void notifyOnStop() { + Util.runOnMain(new Runnable() { + @Override + public void run() { + getListener().onStop(); + } + }); + } + + private void notifyOnProgress(final double progress, final long millis) { + Util.runOnMain(new Runnable() { + @Override + public void run() { + getListener().onProgress(progress, millis); + } + }); + } + + private @NonNull Listener getListener() { + Listener listener = this.listener.get(); + + if (listener != null) return listener; + else return new Listener() { + @Override + public void onStart() {} + @Override + public void onStop() {} + @Override + public void onProgress(double progress, long millis) {} + }; + } + + private synchronized static void setPlaying(@NonNull AudioSlidePlayer player) { + if (playing.isPresent() && playing.get() != player) { + playing.get().notifyOnStop(); + playing.get().stop(); + } + + playing = Optional.of(player); + } + + private synchronized static void removePlaying(@NonNull AudioSlidePlayer player) { + if (playing.isPresent() && playing.get() == player) { + playing = Optional.absent(); + } + } + + public interface Listener { + public void onStart(); + public void onStop(); + public void onProgress(double progress, long millis); + } + + private static class ProgressEventHandler extends Handler { + + private final WeakReference playerReference; + + private ProgressEventHandler(@NonNull AudioSlidePlayer player) { + this.playerReference = new WeakReference<>(player); + } + + @Override + public void handleMessage(Message msg) { + AudioSlidePlayer player = playerReference.get(); + + if (player == null || player.mediaPlayer == null || !player.mediaPlayer.isPlaying()) { + return; + } + + Pair progress = player.getProgress(); + player.notifyOnProgress(progress.first, progress.second); + sendEmptyMessageDelayed(0, 50); + } + } + +} diff --git a/src/org/thoughtcrime/securesms/components/AnimatingToggle.java b/src/org/thoughtcrime/securesms/components/AnimatingToggle.java index 293e07fd31..b8979fcc7a 100644 --- a/src/org/thoughtcrime/securesms/components/AnimatingToggle.java +++ b/src/org/thoughtcrime/securesms/components/AnimatingToggle.java @@ -57,4 +57,12 @@ public class AnimatingToggle extends FrameLayout { current = view; } + + public void displayQuick(@Nullable View view) { + if (view == current) return; + if (current != null) current.setVisibility(View.GONE); + if (view != null) view.setVisibility(View.VISIBLE); + + current = view; + } } diff --git a/src/org/thoughtcrime/securesms/components/AudioView.java b/src/org/thoughtcrime/securesms/components/AudioView.java new file mode 100644 index 0000000000..b0e8427292 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/AudioView.java @@ -0,0 +1,233 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.pnikosis.materialishprogress.ProgressWheel; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.audio.AudioSlidePlayer; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.jobs.PartProgressEvent; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener { + + private static final String TAG = AudioView.class.getSimpleName(); + + private final @NonNull AnimatingToggle controlToggle; + private final @NonNull ImageView playButton; + private final @NonNull ImageView pauseButton; + private final @NonNull ImageView downloadButton; + private final @NonNull ProgressWheel downloadProgress; + private final @NonNull SeekBar seekBar; + private final @NonNull TextView timestamp; + + private @Nullable SlideClickListener downloadListener; + private @Nullable AudioSlidePlayer audioSlidePlayer; + private int backwardsCounter; + + public AudioView(Context context) { + this(context, null); + } + + public AudioView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AudioView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.audio_view, this); + + this.controlToggle = (AnimatingToggle) findViewById(R.id.control_toggle); + this.playButton = (ImageView) findViewById(R.id.play); + this.pauseButton = (ImageView) findViewById(R.id.pause); + this.downloadButton = (ImageView) findViewById(R.id.download); + this.downloadProgress = (ProgressWheel) findViewById(R.id.download_progress); + this.seekBar = (SeekBar) findViewById(R.id.seek); + this.timestamp = (TextView) findViewById(R.id.timestamp); + + this.playButton.setOnClickListener(new PlayClickedListener()); + this.pauseButton.setOnClickListener(new PauseClickedListener()); + this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener()); + + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0); + setTint(typedArray.getColor(R.styleable.AudioView_tintColor, Color.WHITE)); + typedArray.recycle(); + } + } + + public void setAudio(final @NonNull MasterSecret masterSecret, + final @NonNull AudioSlide audio, + final boolean showControls) + { + + if (showControls && audio.isPendingDownload()) { + controlToggle.displayQuick(downloadButton); + seekBar.setEnabled(false); + downloadButton.setOnClickListener(new DownloadClickedListener(audio)); + if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); + } else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) { + controlToggle.displayQuick(downloadProgress); + seekBar.setEnabled(false); + downloadProgress.spin(); + } else { + controlToggle.displayQuick(playButton); + seekBar.setEnabled(true); + if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); + } + + this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), masterSecret, audio, this); + } + + public void cleanup() { + if (this.audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { + this.audioSlidePlayer.stop(); + } + } + + public void setDownloadClickListener(@Nullable SlideClickListener listener) { + this.downloadListener = listener; + } + + @Override + public void onStart() { + this.controlToggle.display(this.pauseButton); + } + + @Override + public void onStop() { + this.controlToggle.display(this.playButton); + } + + @Override + public void onProgress(double progress, long millis) { + int seekProgress = (int)Math.floor(progress * this.seekBar.getMax()); + + if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) { + backwardsCounter = 0; + this.seekBar.setProgress(seekProgress); + this.timestamp.setText(String.format("%02d:%02d", + TimeUnit.MILLISECONDS.toMinutes(millis), + TimeUnit.MILLISECONDS.toSeconds(millis))); + } else { + backwardsCounter++; + } + } + + public void setTint(int tint) { + this.playButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN); + this.pauseButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN); + this.downloadButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN); + this.downloadProgress.setBarColor(tint); + + this.timestamp.setTextColor(tint); + this.seekBar.getProgressDrawable().setColorFilter(tint, PorterDuff.Mode.SRC_IN); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + this.seekBar.getThumb().setColorFilter(tint, PorterDuff.Mode.SRC_IN); + } + } + + private double getProgress() { + if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) { + return 0; + } else { + return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax(); + } + } + + private class PlayClickedListener implements View.OnClickListener { + @Override + public void onClick(View v) { + try { + Log.w(TAG, "playbutton onClick"); + if (audioSlidePlayer != null) { + controlToggle.display(pauseButton); + audioSlidePlayer.play(getProgress()); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + private class PauseClickedListener implements View.OnClickListener { + @Override + public void onClick(View v) { + Log.w(TAG, "pausebutton onClick"); + if (audioSlidePlayer != null) { + controlToggle.display(playButton); + audioSlidePlayer.stop(); + } + } + } + + private class DownloadClickedListener implements View.OnClickListener { + private final @NonNull AudioSlide slide; + + private DownloadClickedListener(@NonNull AudioSlide slide) { + this.slide = slide; + } + + @Override + public void onClick(View v) { + if (downloadListener != null) downloadListener.onClick(v, slide); + } + } + + private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {} + + @Override + public synchronized void onStartTrackingTouch(SeekBar seekBar) { + if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { + audioSlidePlayer.stop(); + } + } + + @Override + public synchronized void onStopTrackingTouch(SeekBar seekBar) { + try { + if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { + audioSlidePlayer.play(getProgress()); + } + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + + @SuppressWarnings("unused") + public void onEventAsync(final PartProgressEvent event) { + if (audioSlidePlayer != null && event.attachment.equals(this.audioSlidePlayer.getAudioSlide().asAttachment())) { + Util.runOnMain(new Runnable() { + @Override + public void run() { + downloadProgress.setInstantProgress(((float) event.progress) / event.total); + } + }); + } + } + +} diff --git a/src/org/thoughtcrime/securesms/components/RemovableMediaView.java b/src/org/thoughtcrime/securesms/components/RemovableMediaView.java new file mode 100644 index 0000000000..44ef5ef067 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/RemovableMediaView.java @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import org.thoughtcrime.securesms.R; + +public class RemovableMediaView extends FrameLayout { + + private final @NonNull ImageView remove; + private final int removeSize; + + private @Nullable View current; + + public RemovableMediaView(Context context) { + this(context, null); + } + + public RemovableMediaView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RemovableMediaView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + this.remove = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_remove_button, this, false); + this.removeSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_remove_button_size); + + this.remove.setVisibility(View.GONE); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + this.addView(remove); + } + + public void display(@Nullable View view) { + if (view == current) return; + if (current != null) current.setVisibility(View.GONE); + + if (view != null) { + MarginLayoutParams params = (MarginLayoutParams)view.getLayoutParams(); + params.setMargins(0, removeSize / 2, removeSize / 2, 0); + view.setLayoutParams(params); + + view.setVisibility(View.VISIBLE); + remove.setVisibility(View.VISIBLE); + } else { + remove.setVisibility(View.GONE); + } + + current = view; + } + + public void setRemoveClickListener(View.OnClickListener listener) { + this.remove.setOnClickListener(listener); + } +} diff --git a/src/org/thoughtcrime/securesms/components/ThumbnailView.java b/src/org/thoughtcrime/securesms/components/ThumbnailView.java index c1dda574a5..8820c6e3b0 100644 --- a/src/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/src/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -17,35 +17,31 @@ import android.widget.ImageView; import com.bumptech.glide.DrawableRequestBuilder; import com.bumptech.glide.GenericRequestBuilder; import com.bumptech.glide.Glide; -import com.bumptech.glide.load.resource.bitmap.GlideBitmapDrawable; -import com.bumptech.glide.load.resource.drawable.GlideDrawable; -import com.bumptech.glide.request.RequestListener; -import com.bumptech.glide.request.target.Target; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.RoundedCorners; import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.whispersystems.libaxolotl.util.guava.Optional; public class ThumbnailView extends FrameLayout { + private static final String TAG = ThumbnailView.class.getSimpleName(); private ImageView image; - private ImageView removeButton; private int backgroundColorHint; private int radius; private OnClickListener parentClickListener; - private Optional transferControls = Optional.absent(); - private ThumbnailClickListener thumbnailClickListener = null; - private ThumbnailClickListener downloadClickListener = null; - private Slide slide = null; + private Optional transferControls = Optional.absent(); + private SlideClickListener thumbnailClickListener = null; + private SlideClickListener downloadClickListener = null; + private Slide slide = null; public ThumbnailView(Context context) { this(context, null); @@ -57,9 +53,11 @@ public class ThumbnailView extends FrameLayout { public ThumbnailView(final Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); + inflate(context, R.layout.thumbnail_view, this); - radius = getResources().getDimensionPixelSize(R.dimen.message_bubble_corner_radius); - image = (ImageView) findViewById(R.id.thumbnail_image); + + this.radius = getResources().getDimensionPixelSize(R.dimen.message_bubble_corner_radius); + this.image = (ImageView) findViewById(R.id.thumbnail_image); super.setOnClickListener(new ThumbnailClickDispatcher()); if (attrs != null) { @@ -86,21 +84,6 @@ public class ThumbnailView extends FrameLayout { if (transferControls.isPresent()) transferControls.get().setClickable(clickable); } - @Override - protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - super.onLayout(changed, left, top, right, bottom); - if (removeButton != null) { - final int paddingHorizontal = removeButton.getWidth() / 2; - final int paddingVertical = removeButton.getHeight() / 2; - image.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, 0); - } - } - - private ImageView getRemoveButton() { - if (removeButton == null) removeButton = ViewUtil.inflateStub(this, R.id.remove_button_stub); - return removeButton; - } - private TransferControlView getTransferControls() { if (!transferControls.isPresent()) { transferControls = Optional.of((TransferControlView)ViewUtil.inflateStub(this, R.id.transfer_controls_stub)); @@ -112,9 +95,8 @@ public class ThumbnailView extends FrameLayout { this.backgroundColorHint = color; } - public void setImageResource(@NonNull MasterSecret masterSecret, @NonNull Slide slide, - boolean showControls, boolean showRemove) - { + public void setImageResource(@NonNull MasterSecret masterSecret, @NonNull Slide slide, boolean showControls) { + if (Util.equals(slide, this.slide)) { Log.w(TAG, "Not re-loading slide " + slide.asAttachment().getDataUri()); return; @@ -137,22 +119,16 @@ public class ThumbnailView extends FrameLayout { this.slide = slide; - if (slide.getThumbnailUri() != null) buildThumbnailGlideRequest(slide, masterSecret, showRemove).into(image); + if (slide.getThumbnailUri() != null) buildThumbnailGlideRequest(slide, masterSecret).into(image); else if (slide.hasPlaceholder()) buildPlaceholderGlideRequest(slide).into(image); else Glide.clear(image); } - public void setThumbnailClickListener(ThumbnailClickListener listener) { + public void setThumbnailClickListener(SlideClickListener listener) { this.thumbnailClickListener = listener; } - public void setRemoveClickListener(OnClickListener listener) { - getRemoveButton().setOnClickListener(listener); - final int pad = getResources().getDimensionPixelSize(R.dimen.media_bubble_remove_button_size); - image.setPadding(pad, pad, pad, 0); - } - - public void setDownloadClickListener(ThumbnailClickListener listener) { + public void setDownloadClickListener(SlideClickListener listener) { this.downloadClickListener = listener; } @@ -174,15 +150,11 @@ public class ThumbnailView extends FrameLayout { !((Activity)getContext()).isDestroyed(); } - private GenericRequestBuilder buildThumbnailGlideRequest(@NonNull Slide slide, @NonNull MasterSecret masterSecret, boolean showRemove) { + private GenericRequestBuilder buildThumbnailGlideRequest(@NonNull Slide slide, @NonNull MasterSecret masterSecret) { DrawableRequestBuilder builder = Glide.with(getContext()).load(new DecryptableUri(masterSecret, slide.getThumbnailUri())) .crossFade() .transform(new RoundedCorners(getContext(), true, radius, backgroundColorHint)); - if (showRemove) { - builder = builder.listener(new ThumbnailSetListener(slide.asAttachment())); - } - if (slide.isInProgress()) return builder; else return builder.error(R.drawable.ic_missing_thumbnail_picture); } @@ -193,10 +165,6 @@ public class ThumbnailView extends FrameLayout { .fitCenter(); } - public interface ThumbnailClickListener { - void onClick(View v, Slide slide); - } - private class ThumbnailClickDispatcher implements View.OnClickListener { @Override public void onClick(View view) { @@ -220,36 +188,4 @@ public class ThumbnailView extends FrameLayout { } } } - - private class ThumbnailSetListener implements RequestListener { - - private final Attachment attachment; - - public ThumbnailSetListener(@NonNull Attachment attachment) { - this.attachment = attachment; - } - - @Override - public boolean onException(Exception e, Object model, Target target, boolean isFirstResource) { - return false; - } - - @Override - public boolean onResourceReady(GlideDrawable resource, Object model, Target target, boolean isFromMemoryCache, boolean isFirstResource) { - if (resource instanceof GlideBitmapDrawable) { - Log.w(TAG, "onResourceReady() for a Bitmap. Saving."); - attachment.setThumbnail(((GlideBitmapDrawable) resource).getBitmap()); - } - LayoutParams layoutParams = (LayoutParams) getRemoveButton().getLayoutParams(); - if (resource.getIntrinsicWidth() < getWidth()) { - layoutParams.topMargin = 0; - layoutParams.rightMargin = Math.max(0, (getWidth() - image.getPaddingRight() - resource.getIntrinsicWidth()) / 2); - } else { - layoutParams.topMargin = Math.max(0, (getHeight() - image.getPaddingTop() - resource.getIntrinsicHeight()) / 2); - layoutParams.rightMargin = 0; - } - getRemoveButton().setLayoutParams(layoutParams); - return false; - } - } } diff --git a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 3f2cdee56d..97d3bda371 100644 --- a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -279,7 +279,6 @@ public class MmsSmsDatabase extends Database { return getCurrent(); } - @TargetApi(Build.VERSION_CODES.HONEYCOMB) public MessageRecord getCurrent() { String type = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT)); diff --git a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java index 6b6b9cb2a1..b8b103d597 100644 --- a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -34,6 +34,8 @@ import android.view.animation.Animation; import android.widget.Toast; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.RemovableMediaView; import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.providers.CaptureProvider; @@ -43,24 +45,29 @@ import org.thoughtcrime.securesms.util.MediaUtil; import java.io.IOException; public class AttachmentManager { + private final static String TAG = AttachmentManager.class.getSimpleName(); - private final Context context; - private final View attachmentView; - private final ThumbnailView thumbnail; - private final SlideDeck slideDeck; - private final AttachmentListener attachmentListener; + private final @NonNull Context context; + private final @NonNull View attachmentView; + private final @NonNull RemovableMediaView removableMediaView; + private final @NonNull ThumbnailView thumbnail; + private final @NonNull AudioView audioView; + private final @NonNull SlideDeck slideDeck; + private final @NonNull AttachmentListener attachmentListener; private Uri captureUri; - public AttachmentManager(Activity view, AttachmentListener listener) { + public AttachmentManager(@NonNull Activity view, @NonNull AttachmentListener listener) { this.attachmentView = view.findViewById(R.id.attachment_editor); this.thumbnail = (ThumbnailView) view.findViewById(R.id.attachment_thumbnail); + this.audioView = (AudioView) view.findViewById(R.id.attachment_audio); + this.removableMediaView = (RemovableMediaView) view.findViewById(R.id.removable_media_view); this.slideDeck = new SlideDeck(); this.context = view; this.attachmentListener = listener; - thumbnail.setRemoveClickListener(new RemoveButtonListener()); + removableMediaView.setRemoveClickListener(new RemoveButtonListener()); } public void clear() { @@ -81,6 +88,7 @@ public class AttachmentManager { }); attachmentView.startAnimation(animation); + audioView.cleanup(); } public void cleanup() { @@ -135,7 +143,15 @@ public class AttachmentManager { } else { slideDeck.addSlide(slide); attachmentView.setVisibility(View.VISIBLE); - thumbnail.setImageResource(masterSecret, slide, false, true); + + if (slide.hasAudio()) { + audioView.setAudio(masterSecret, (AudioSlide)slide, false); + removableMediaView.display(audioView); + } else { + thumbnail.setImageResource(masterSecret, slide, false); + removableMediaView.display(thumbnail); + } + attachmentListener.onAttachmentChanged(); } } diff --git a/src/org/thoughtcrime/securesms/mms/SlideClickListener.java b/src/org/thoughtcrime/securesms/mms/SlideClickListener.java new file mode 100644 index 0000000000..f7d1345ca0 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mms/SlideClickListener.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.mms; + +import android.view.View; + +public interface SlideClickListener { + void onClick(View v, Slide slide); +} diff --git a/src/org/thoughtcrime/securesms/mms/SlideDeck.java b/src/org/thoughtcrime/securesms/mms/SlideDeck.java index 546eef1967..8709494587 100644 --- a/src/org/thoughtcrime/securesms/mms/SlideDeck.java +++ b/src/org/thoughtcrime/securesms/mms/SlideDeck.java @@ -81,6 +81,17 @@ public class SlideDeck { return slide; } } + + return null; + } + + public @Nullable AudioSlide getAudioSlide() { + for (Slide slide : slides) { + if (slide.hasAudio()) { + return (AudioSlide)slide; + } + } + return null; } }