From c719a48a2ca867b6ff23d4bb09bacc800ede12e8 Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Wed, 11 Jun 2014 18:03:01 -0700 Subject: [PATCH] Move media attachment long-click event to context menu. Long-click on a media attachment will now bring up the normal context menu for a ConversationItem long-click, but with the addition of a "save attachment" option. This allows users to long-click on messages with media in them and still see the other contextual menu options. // FREEBIE --- res/menu/conversation_context_image.xml | 6 + res/values/strings.xml | 17 +- .../securesms/ConversationFragment.java | 179 +++++++++++++++++- .../securesms/ConversationItem.java | 146 +------------- .../database/model/MediaMmsMessageRecord.java | 5 + .../database/model/MessageRecord.java | 1 + .../model/NotificationMmsMessageRecord.java | 5 + .../database/model/SmsMessageRecord.java | 5 + .../thoughtcrime/securesms/mms/SlideDeck.java | 10 + 9 files changed, 228 insertions(+), 146 deletions(-) create mode 100644 res/menu/conversation_context_image.xml diff --git a/res/menu/conversation_context_image.xml b/res/menu/conversation_context_image.xml new file mode 100644 index 0000000000..c01aea5213 --- /dev/null +++ b/res/menu/conversation_context_image.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 0a37494291..747614df0f 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -47,13 +47,6 @@ Message size: %d KB Expires: %s Error sending message - Saving attachment - Saving attachment to SD card... - Save to SD card? - This media has been stored in an encrypted database. The version you save to the SD card will no longer be encrypted. Would you like to continue? - Error while saving attachment to SD card! - Success! - Unable to write to SD card! View secure media? This media has been stored in an encrypted database. Unfortunately, to view it with an external content viewer currently requires the data to be temporarily decrypted and written to disk. Are you sure that you would like to do this? Received and processed key exchange message. @@ -102,6 +95,13 @@ Sender: %1$s\nTransport: %2$s\nSent: %3$s\nReceived: %4$s Confirm message delete Are you sure that you want to permanently delete this message? + Save to SD card? + This media has been stored in an encrypted database. The version you save to the SD card will no longer be encrypted. Would you like to continue? + Error while saving attachment to SD card! + Success! + Unable to write to SD card! + Saving attachment + Saving attachment to SD card... Key exchange message... @@ -795,6 +795,9 @@ Forward message Resend message + + Save attachment + Start secure session diff --git a/src/org/thoughtcrime/securesms/ConversationFragment.java b/src/org/thoughtcrime/securesms/ConversationFragment.java index abae3631ff..3d917c61b7 100644 --- a/src/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationFragment.java @@ -2,41 +2,61 @@ package org.thoughtcrime.securesms; import android.app.Activity; import android.app.AlertDialog; +import android.app.ProgressDialog; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.database.Cursor; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.AsyncTask; import android.os.Bundle; +import android.os.Environment; import android.os.Handler; import android.support.v4.app.LoaderManager; import android.support.v4.content.Loader; import android.text.ClipboardManager; +import android.util.Log; import android.view.ContextMenu; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.support.v4.widget.CursorAdapter; +import android.webkit.MimeTypeMap; import android.widget.ListView; +import android.widget.Toast; import com.actionbarsherlock.app.SherlockListFragment; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.loaders.ConversationLoader; +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.recipients.RecipientFactory; import org.thoughtcrime.securesms.recipients.Recipients; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.Dialogs; import org.thoughtcrime.securesms.util.DirectoryHelper; import org.whispersystems.textsecure.crypto.MasterSecret; +import org.whispersystems.textsecure.util.Util; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.ref.WeakReference; import java.sql.Date; import java.text.SimpleDateFormat; +import java.util.List; +import java.util.concurrent.ExecutionException; public class ConversationFragment extends SherlockListFragment implements LoaderManager.LoaderCallbacks { + private static final String TAG = ConversationFragment.class.getSimpleName(); private ConversationFragmentListener listener; @@ -66,10 +86,23 @@ public class ConversationFragment extends SherlockListFragment inflater.inflate(R.menu.conversation_context, menu); MessageRecord messageRecord = getMessageRecord(); + if (messageRecord.isFailed()) { MenuItem resend = menu.findItem(R.id.menu_context_resend); resend.setVisible(true); } + + if (messageRecord.isMms() && !messageRecord.isMmsNotification()) { + try { + if (((MediaMmsMessageRecord)messageRecord).getSlideDeck().get().containsMediaSlide()) { + inflater.inflate(R.menu.conversation_context_image, menu); + } + } catch (InterruptedException ie) { + Log.w(TAG, ie); + } catch (ExecutionException ee) { + Log.w(TAG, ee); + } + } } @Override @@ -81,6 +114,7 @@ public class ConversationFragment extends SherlockListFragment case R.id.menu_context_details: handleDisplayDetails(messageRecord); return true; case R.id.menu_context_forward: handleForwardMessage(messageRecord); return true; case R.id.menu_context_resend: handleResendMessage(messageRecord); return true; + case R.id.menu_context_save_attachment:handleSaveAttachment(messageRecord); return true; } return false; @@ -196,11 +230,26 @@ public class ConversationFragment extends SherlockListFragment MessageSender.resend(activity, messageId, message.isMms()); } + private void handleSaveAttachment(final MessageRecord message) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(R.string.ConversationFragment_save_to_sd_card); + builder.setIcon(Dialogs.resolveIcon(getActivity(), R.attr.dialog_alert_icon)); + builder.setCancelable(true); + builder.setMessage(R.string.ConversationFragment_this_media_has_been_stored_in_an_encrypted_database_warning); + builder.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + SaveAttachmentTask saveTask = new SaveAttachmentTask(getActivity()); + saveTask.execute((MediaMmsMessageRecord) message); + } + }); + builder.setNegativeButton(R.string.no, null); + builder.show(); + } + private void initializeResources() { String recipientIds = this.getActivity().getIntent().getStringExtra("recipients"); - this.masterSecret = (MasterSecret)this.getActivity().getIntent() - .getParcelableExtra("master_secret"); + this.masterSecret = this.getActivity().getIntent().getParcelableExtra("master_secret"); this.recipients = RecipientFactory.getRecipientsForIds(getActivity(), recipientIds, true); this.threadId = this.getActivity().getIntent().getLongExtra("thread_id", -1); } @@ -244,4 +293,130 @@ public class ConversationFragment extends SherlockListFragment public void setComposeText(String text); } + private class SaveAttachmentTask extends AsyncTask { + + private static final int SUCCESS = 0; + private static final int FAILURE = 1; + private static final int WRITE_ACCESS_FAILURE = 2; + + private final WeakReference contextReference; + private ProgressDialog progressDialog; + + public SaveAttachmentTask(Context context) { + this.contextReference = new WeakReference(context); + } + + @Override + protected void onPreExecute() { + Context context = contextReference.get(); + + if (context != null) { + progressDialog = ProgressDialog.show(context, + context.getString(R.string.ConversationFragment_saving_attachment), + context.getString(R.string.ConversationFragment_saving_attachment_to_sd_card), + true, false); + } + } + + @Override + protected Integer doInBackground(MediaMmsMessageRecord... messageRecord) { + try { + Context context = contextReference.get(); + + if (!Environment.getExternalStorageDirectory().canWrite()) { + return WRITE_ACCESS_FAILURE; + } + + if (context == null) { + return FAILURE; + } + + Slide slide = getAttachment(messageRecord[0]); + + if (slide == null) { + return FAILURE; + } + + File mediaFile = constructOutputFile(slide); + InputStream inputStream = slide.getPartDataInputStream(); + OutputStream outputStream = new FileOutputStream(mediaFile); + + Util.copy(inputStream, outputStream); + + MediaScannerConnection.scanFile(context, new String[] {mediaFile.getAbsolutePath()}, + new String[] {slide.getContentType()}, null); + + return SUCCESS; + } catch (IOException ioe) { + Log.w(TAG, ioe); + return FAILURE; + } catch (InterruptedException e) { + throw new AssertionError(e); + } catch (ExecutionException e) { + Log.w(TAG, e); + return FAILURE; + } + } + + @Override + protected void onPostExecute(Integer result) { + Context context = contextReference.get(); + if (context == null) return; + + switch (result) { + case FAILURE: + Toast.makeText(context, R.string.ConversationFragment_error_while_saving_attachment_to_sd_card, + Toast.LENGTH_LONG).show(); + break; + case SUCCESS: + Toast.makeText(context, R.string.ConversationFragment_success_exclamation, + Toast.LENGTH_LONG).show(); + break; + case WRITE_ACCESS_FAILURE: + Toast.makeText(context, R.string.ConversationFragment_unable_to_write_to_sd_card_exclamation, + Toast.LENGTH_LONG).show(); + break; + } + + if (progressDialog != null) + progressDialog.dismiss(); + } + + private Slide getAttachment(MediaMmsMessageRecord record) + throws ExecutionException, InterruptedException + { + List slides = record.getSlideDeck().get().getSlides(); + + for (Slide slide : slides) { + if (slide.hasImage() || slide.hasVideo() || slide.hasAudio()) { + return slide; + } + } + + return null; + } + + private File constructOutputFile(Slide slide) throws IOException { + File sdCard = Environment.getExternalStorageDirectory(); + File outputDirectory; + + if (slide.hasVideo()) { + outputDirectory = new File(sdCard.getAbsoluteFile() + File.separator + "Movies"); + } else if (slide.hasAudio()) { + outputDirectory = new File(sdCard.getAbsolutePath() + File.separator + "Music"); + } else { + outputDirectory = new File(sdCard.getAbsolutePath() + File.separator + "Pictures"); + } + + outputDirectory.mkdirs(); + + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String extension = mimeTypeMap.getExtensionFromMimeType(slide.getContentType()); + + if (extension == null) + extension = "attach"; + + return File.createTempFile("textsecure", "." + extension, outputDirectory); + } + } } diff --git a/src/org/thoughtcrime/securesms/ConversationItem.java b/src/org/thoughtcrime/securesms/ConversationItem.java index 1cc76ff823..9171bb8dcc 100644 --- a/src/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/org/thoughtcrime/securesms/ConversationItem.java @@ -17,17 +17,14 @@ package org.thoughtcrime.securesms; import android.app.AlertDialog; -import android.app.ProgressDialog; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; -import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; -import android.os.Environment; import android.os.Handler; import android.os.Message; import android.provider.Contacts.Intents; @@ -35,12 +32,10 @@ import android.provider.ContactsContract.QuickContact; import android.util.AttributeSet; import android.util.Log; import android.view.View; -import android.webkit.MimeTypeMap; import android.widget.Button; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; -import android.widget.Toast; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsDatabase; @@ -53,18 +48,12 @@ import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.service.SendReceiveService; import org.thoughtcrime.securesms.util.DateUtils; -import org.thoughtcrime.securesms.util.Emoji; import org.thoughtcrime.securesms.util.Dialogs; +import org.thoughtcrime.securesms.util.Emoji; import org.whispersystems.textsecure.crypto.MasterSecret; import org.whispersystems.textsecure.util.FutureTaskListener; import org.whispersystems.textsecure.util.ListenableFutureTask; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - /** * A view that displays an individual conversation item within a conversation * thread. Used by ComposeMessageActivity's ListActivity via a ConversationAdapter. @@ -182,9 +171,9 @@ public class ConversationItem extends LinearLayout { setEvents(messageRecord); setMinimumWidth(); - if (messageRecord instanceof NotificationMmsMessageRecord) { + if (messageRecord.isMmsNotification()) { setNotificationMmsAttributes((NotificationMmsMessageRecord)messageRecord); - } else if (messageRecord instanceof MediaMmsMessageRecord) { + } else if (messageRecord.isMms()) { setMediaMmsAttributes((MediaMmsMessageRecord)messageRecord); } } @@ -365,9 +354,13 @@ public class ConversationItem extends LinearLayout { for (Slide slide : result.getSlides()) { if (slide.hasImage()) { slide.setThumbnailOn(mmsThumbnail); -// mmsThumbnail.setImageBitmap(slide.getThumbnail()); mmsThumbnail.setOnClickListener(new ThumbnailClickListener(slide)); - mmsThumbnail.setOnLongClickListener(new ThumbnailSaveListener(slide)); + mmsThumbnail.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + return false; + } + }); mmsThumbnail.setVisibility(View.VISIBLE); return; } @@ -439,127 +432,6 @@ public class ConversationItem extends LinearLayout { context.startActivity(intent); } - private class ThumbnailSaveListener extends Handler implements View.OnLongClickListener, Runnable, MediaScannerConnection.MediaScannerConnectionClient { - private static final int SUCCESS = 0; - private static final int FAILURE = 1; - private static final int WRITE_ACCESS_FAILURE = 2; - - private final Slide slide; - private ProgressDialog progressDialog; - private MediaScannerConnection mediaScannerConnection; - private File mediaFile; - - public ThumbnailSaveListener(Slide slide) { - this.slide = slide; - } - - public void run() { - if (!Environment.getExternalStorageDirectory().canWrite()) { - this.obtainMessage(WRITE_ACCESS_FAILURE).sendToTarget(); - return; - } - - try { - mediaFile = constructOutputFile(); - InputStream inputStream = slide.getPartDataInputStream(); - OutputStream outputStream = new FileOutputStream(mediaFile); - - byte[] buffer = new byte[4096]; - int read; - - while ((read = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, read); - } - - outputStream.close(); - inputStream.close(); - - mediaScannerConnection = new MediaScannerConnection(context, this); - mediaScannerConnection.connect(); - } catch (IOException ioe) { - Log.w(TAG, ioe); - this.obtainMessage(FAILURE).sendToTarget(); - } - } - - private File constructOutputFile() throws IOException { - File sdCard = Environment.getExternalStorageDirectory(); - File outputDirectory; - - if (slide.hasVideo()) - outputDirectory = new File(sdCard.getAbsoluteFile() + File.separator + "Movies"); - else if (slide.hasAudio()) - outputDirectory = new File(sdCard.getAbsolutePath() + File.separator + "Music"); - else - outputDirectory = new File(sdCard.getAbsolutePath() + File.separator + "Pictures"); - outputDirectory.mkdirs(); - - MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); - String extension = mimeTypeMap.getExtensionFromMimeType(slide.getContentType()); - if (extension == null) - extension = "attach"; - - return File.createTempFile("textsecure", "." + extension, outputDirectory); - } - - private void saveToSdCard() { - progressDialog = new ProgressDialog(context); - progressDialog.setTitle(context.getString(R.string.ConversationItem_saving_attachment)); - progressDialog.setMessage(context.getString(R.string.ConversationItem_saving_attachment_to_sd_card)); - progressDialog.setCancelable(false); - progressDialog.setIndeterminate(true); - progressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); - progressDialog.show(); - new Thread(this).start(); - } - - public boolean onLongClick(View v) { - AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(R.string.ConversationItem_save_to_sd_card); - builder.setIcon(Dialogs.resolveIcon(context, R.attr.dialog_alert_icon)); - builder.setCancelable(true); - builder.setMessage(R.string.ConversationItem_this_media_has_been_stored_in_an_encrypted_database_warning); - builder.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - saveToSdCard(); - } - }); - builder.setNegativeButton(R.string.no, null); - builder.show(); - - return true; - } - - @Override - public void handleMessage(Message message) { - switch (message.what) { - case FAILURE: - Toast.makeText(context, R.string.ConversationItem_error_while_saving_attachment_to_sd_card, - Toast.LENGTH_LONG).show(); - break; - case SUCCESS: - Toast.makeText(context, R.string.ConversationItem_success_exclamation, - Toast.LENGTH_LONG).show(); - break; - case WRITE_ACCESS_FAILURE: - Toast.makeText(context, R.string.ConversationItem_unable_to_write_to_sd_card_exclamation, - Toast.LENGTH_LONG).show(); - break; - } - - progressDialog.dismiss(); - } - - public void onMediaScannerConnected() { - mediaScannerConnection.scanFile(mediaFile.getAbsolutePath(), slide.getContentType()); - } - - public void onScanCompleted(String path, Uri uri) { - mediaScannerConnection.disconnect(); - this.obtainMessage(SUCCESS).sendToTarget(); - } - } - private class ThumbnailClickListener implements View.OnClickListener { private final Slide slide; diff --git a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 49d4bcf58e..b11fac0562 100644 --- a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -68,6 +68,11 @@ public class MediaMmsMessageRecord extends MessageRecord { return true; } + @Override + public boolean isMmsNotification() { + return false; + } + @Override public SpannableString getDisplayBody() { if (MmsDatabase.Types.isDecryptInProgressType(type)) { diff --git a/src/org/thoughtcrime/securesms/database/model/MessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MessageRecord.java index 903c5e2c16..4414a4059b 100644 --- a/src/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -62,6 +62,7 @@ public abstract class MessageRecord extends DisplayRecord { } public abstract boolean isMms(); + public abstract boolean isMmsNotification(); public boolean isFailed() { return diff --git a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java index 7ebf70c75d..33adfaa4b6 100644 --- a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java @@ -101,6 +101,11 @@ public class NotificationMmsMessageRecord extends MessageRecord { return true; } + @Override + public boolean isMmsNotification() { + return true; + } + @Override public SpannableString getDisplayBody() { return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_multimedia_message)); diff --git a/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index 1dce659d2e..26d5b0a291 100644 --- a/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -98,6 +98,11 @@ public class SmsMessageRecord extends MessageRecord { return false; } + @Override + public boolean isMmsNotification() { + return false; + } + private static int getGenericDeliveryStatus(int status) { if (status == SmsDatabase.Status.STATUS_NONE) { return MessageRecord.DELIVERY_STATUS_NONE; diff --git a/src/org/thoughtcrime/securesms/mms/SlideDeck.java b/src/org/thoughtcrime/securesms/mms/SlideDeck.java index 5de29f3578..929bc091a2 100644 --- a/src/org/thoughtcrime/securesms/mms/SlideDeck.java +++ b/src/org/thoughtcrime/securesms/mms/SlideDeck.java @@ -74,5 +74,15 @@ public class SlideDeck { public List getSlides() { return slides; } + + public boolean containsMediaSlide() { + for (Slide slide : slides) { + if (slide.hasImage() || slide.hasVideo() || slide.hasAudio()) { + return true; + } + } + + return false; + } }