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
This commit is contained in:
Moxie Marlinspike 2014-06-11 18:03:01 -07:00
parent 68747142d6
commit c719a48a2c
9 changed files with 228 additions and 146 deletions

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:title="@string/conversation_context_image__save_attachment"
android:id="@+id/menu_context_save_attachment" />
</menu>

View File

@ -47,13 +47,6 @@
<string name="ConversationItem_message_size_d_kb">Message size: %d KB</string>
<string name="ConversationItem_expires_s">Expires: %s</string>
<string name="ConversationItem_error_sending_message">Error sending message</string>
<string name="ConversationItem_saving_attachment">Saving attachment</string>
<string name="ConversationItem_saving_attachment_to_sd_card">Saving attachment to SD card...</string>
<string name="ConversationItem_save_to_sd_card">Save to SD card?</string>
<string name="ConversationItem_this_media_has_been_stored_in_an_encrypted_database_warning">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?</string>
<string name="ConversationItem_error_while_saving_attachment_to_sd_card">Error while saving attachment to SD card!</string>
<string name="ConversationItem_success_exclamation">Success!</string>
<string name="ConversationItem_unable_to_write_to_sd_card_exclamation">Unable to write to SD card!</string>
<string name="ConversationItem_view_secure_media_question">View secure media?</string>
<string name="ConversationItem_this_media_has_been_stored_in_an_encrypted_database_external_viewer_warning">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?</string>
<string name="ConversationItem_received_and_processed_key_exchange_message">Received and processed key exchange message.</string>
@ -102,6 +95,13 @@
<string name="ConversationFragment_sender_s_transport_s_sent_s_received_s">Sender: %1$s\nTransport: %2$s\nSent: %3$s\nReceived: %4$s</string>
<string name="ConversationFragment_confirm_message_delete">Confirm message delete</string>
<string name="ConversationFragment_are_you_sure_you_want_to_permanently_delete_this_message">Are you sure that you want to permanently delete this message?</string>
<string name="ConversationFragment_save_to_sd_card">Save to SD card?</string>
<string name="ConversationFragment_this_media_has_been_stored_in_an_encrypted_database_warning">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?</string>
<string name="ConversationFragment_error_while_saving_attachment_to_sd_card">Error while saving attachment to SD card!</string>
<string name="ConversationFragment_success_exclamation">Success!</string>
<string name="ConversationFragment_unable_to_write_to_sd_card_exclamation">Unable to write to SD card!</string>
<string name="ConversationFragment_saving_attachment">Saving attachment</string>
<string name="ConversationFragment_saving_attachment_to_sd_card">Saving attachment to SD card...</string>
<!-- ConversationListAdapter -->
<string name="ConversationListAdapter_key_exchange_message">Key exchange message...</string>
@ -795,6 +795,9 @@
<string name="conversation_context__menu_forward_message">Forward message</string>
<string name="conversation_context__menu_resend_message">Resend message</string>
<!-- conversation_context_image -->
<string name="conversation_context_image__save_attachment">Save attachment</string>
<!-- conversation_insecure -->
<string name="conversation_insecure__menu_start_secure_session">Start secure session</string>

View File

@ -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<Cursor>
{
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<MediaMmsMessageRecord, Void, Integer> {
private static final int SUCCESS = 0;
private static final int FAILURE = 1;
private static final int WRITE_ACCESS_FAILURE = 2;
private final WeakReference<Context> contextReference;
private ProgressDialog progressDialog;
public SaveAttachmentTask(Context context) {
this.contextReference = new WeakReference<Context>(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<Slide> 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);
}
}
}

View File

@ -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;

View File

@ -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)) {

View File

@ -62,6 +62,7 @@ public abstract class MessageRecord extends DisplayRecord {
}
public abstract boolean isMms();
public abstract boolean isMmsNotification();
public boolean isFailed() {
return

View File

@ -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));

View File

@ -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;

View File

@ -74,5 +74,15 @@ public class SlideDeck {
public List<Slide> getSlides() {
return slides;
}
public boolean containsMediaSlide() {
for (Slide slide : slides) {
if (slide.hasImage() || slide.hasVideo() || slide.hasAudio()) {
return true;
}
}
return false;
}
}