diff --git a/res/layout/message_compose_attachment.xml b/res/layout/message_compose_attachment.xml index 263d5a13c..60546e184 100644 --- a/res/layout/message_compose_attachment.xml +++ b/res/layout/message_compose_attachment.xml @@ -1,36 +1,55 @@ - - - + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="54dip" + android:paddingRight="6dip" + android:paddingTop="6dip" + android:paddingBottom="6dip"> + + + + + + + + + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml index b306f4420..fd4bd3f0f 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1147,4 +1147,6 @@ Please submit bug reports, contribute new features and ask questions at on %s Mark all as read + + Loading attachment… diff --git a/src/com/fsck/k9/activity/K9Activity.java b/src/com/fsck/k9/activity/K9Activity.java index 64083758d..82ffc3835 100644 --- a/src/com/fsck/k9/activity/K9Activity.java +++ b/src/com/fsck/k9/activity/K9Activity.java @@ -3,12 +3,12 @@ package com.fsck.k9.activity; import android.os.Bundle; import android.view.MotionEvent; -import com.actionbarsherlock.app.SherlockActivity; +import com.actionbarsherlock.app.SherlockFragmentActivity; import com.fsck.k9.activity.K9ActivityCommon.K9ActivityMagic; import com.fsck.k9.activity.misc.SwipeGestureDetector.OnSwipeGestureListener; -public class K9Activity extends SherlockActivity implements K9ActivityMagic { +public class K9Activity extends SherlockFragmentActivity implements K9ActivityMagic { private K9ActivityCommon mBase; diff --git a/src/com/fsck/k9/activity/MessageCompose.java b/src/com/fsck/k9/activity/MessageCompose.java index 5d9d3759a..999bb6947 100644 --- a/src/com/fsck/k9/activity/MessageCompose.java +++ b/src/com/fsck/k9/activity/MessageCompose.java @@ -5,19 +5,18 @@ import android.annotation.TargetApi; import android.app.AlertDialog; import android.app.AlertDialog.Builder; import android.app.Dialog; -import android.content.ContentResolver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.ActivityInfo; -import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Parcelable; -import android.provider.OpenableColumns; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.Loader; import android.text.TextWatcher; import android.text.util.Rfc822Tokenizer; import android.util.Log; @@ -55,6 +54,9 @@ import com.fsck.k9.Identity; import com.fsck.k9.K9; import com.fsck.k9.Preferences; import com.fsck.k9.R; +import com.fsck.k9.activity.loader.AttachmentContentLoader; +import com.fsck.k9.activity.loader.AttachmentInfoLoader; +import com.fsck.k9.activity.misc.Attachment; import com.fsck.k9.controller.MessagingController; import com.fsck.k9.controller.MessagingListener; import com.fsck.k9.crypto.CryptoProvider; @@ -86,14 +88,13 @@ import org.htmlcleaner.CleanerProperties; import org.htmlcleaner.HtmlCleaner; import org.htmlcleaner.SimpleHtmlSerializer; import org.htmlcleaner.TagNode; -import java.io.File; -import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; @@ -147,6 +148,8 @@ public class MessageCompose extends K9Activity implements OnClickListener { private static final String STATE_KEY_QUOTED_TEXT_FORMAT = "com.fsck.k9.activity.MessageCompose.quotedTextFormat"; + private static final String LOADER_ARG_ATTACHMENT = "attachment"; + private static final int MSG_PROGRESS_ON = 1; private static final int MSG_PROGRESS_OFF = 2; private static final int MSG_SKIPPED_ATTACHMENTS = 3; @@ -218,6 +221,7 @@ public class MessageCompose extends K9Activity implements OnClickListener { * have already been added from the restore of the view state. */ private boolean mSourceMessageProcessed = false; + private int mMaxLoaderId = 0; enum Action { COMPOSE, @@ -365,14 +369,6 @@ public class MessageCompose extends K9Activity implements OnClickListener { private ContextThemeWrapper mThemeContext; - static class Attachment implements Serializable { - private static final long serialVersionUID = 3642382876618963734L; - public String name; - public String contentType; - public long size; - public Uri uri; - } - /** * Compose a new message using the given account. If account is null the default account * will be used. @@ -1077,12 +1073,13 @@ public class MessageCompose extends K9Activity implements OnClickListener { @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); - ArrayList attachments = new ArrayList(); + ArrayList attachments = new ArrayList(); for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { View view = mAttachments.getChildAt(i); Attachment attachment = (Attachment) view.getTag(); - attachments.add(attachment.uri); + attachments.add(attachment); } + outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, attachments); outState.putBoolean(STATE_KEY_CC_SHOWN, mCcWrapper.getVisibility() == View.VISIBLE); outState.putBoolean(STATE_KEY_BCC_SHOWN, mBccWrapper.getVisibility() == View.VISIBLE); @@ -1104,11 +1101,22 @@ public class MessageCompose extends K9Activity implements OnClickListener { @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); - ArrayList attachments = savedInstanceState.getParcelableArrayList(STATE_KEY_ATTACHMENTS); + mAttachments.removeAllViews(); - for (Parcelable p : attachments) { - Uri uri = (Uri) p; - addAttachment(uri); + mMaxLoaderId = 0; + + ArrayList attachments = savedInstanceState.getParcelableArrayList(STATE_KEY_ATTACHMENTS); + for (Attachment attachment : attachments) { + addAttachmentView(attachment); + if (attachment.loaderId > mMaxLoaderId) { + mMaxLoaderId = attachment.loaderId; + } + + if (attachment.state == Attachment.LoadingState.URI_ONLY) { + initAttachmentInfoLoader(attachment); + } else if (attachment.state == Attachment.LoadingState.METADATA) { + initAttachmentContentLoader(attachment); + } } mReadReceipt = savedInstanceState @@ -1474,8 +1482,11 @@ public class MessageCompose extends K9Activity implements OnClickListener { for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { Attachment attachment = (Attachment) mAttachments.getChildAt(i).getTag(); - MimeBodyPart bp = new MimeBodyPart( - new LocalStore.LocalAttachmentBody(attachment.uri, getApplication())); + if (attachment.state != Attachment.LoadingState.COMPLETE) { + continue; + } + + MimeBodyPart bp = new MimeBodyPart(new LocalStore.TempFileBody(attachment.filename)); /* * Correctly encode the filename here. Otherwise the whole @@ -1911,71 +1922,131 @@ public class MessageCompose extends K9Activity implements OnClickListener { } private void addAttachment(Uri uri, String contentType) { - long size = -1; - String name = null; - - ContentResolver contentResolver = getContentResolver(); - - Cursor metadataCursor = contentResolver.query( - uri, - new String[] { OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }, - null, - null, - null); - - if (metadataCursor != null) { - try { - if (metadataCursor.moveToFirst()) { - name = metadataCursor.getString(0); - size = metadataCursor.getInt(1); - } - } finally { - metadataCursor.close(); - } - } - - if (name == null) { - name = uri.getLastPathSegment(); - } - - String usableContentType = contentType; - if ((usableContentType == null) || (usableContentType.indexOf('*') != -1)) { - usableContentType = contentResolver.getType(uri); - } - if (usableContentType == null) { - usableContentType = MimeUtility.getMimeTypeByExtension(name); - } - - if (size <= 0) { - String uriString = uri.toString(); - if (uriString.startsWith("file://")) { - Log.v(K9.LOG_TAG, uriString.substring("file://".length())); - File f = new File(uriString.substring("file://".length())); - size = f.length(); - } else { - Log.v(K9.LOG_TAG, "Not a file: " + uriString); - } - } else { - Log.v(K9.LOG_TAG, "old attachment.size: " + size); - } - Log.v(K9.LOG_TAG, "new attachment.size: " + size); - Attachment attachment = new Attachment(); + attachment.state = Attachment.LoadingState.URI_ONLY; attachment.uri = uri; - attachment.contentType = usableContentType; - attachment.name = name; - attachment.size = size; + attachment.contentType = contentType; + attachment.loaderId = ++mMaxLoaderId; + + addAttachmentView(attachment); + + initAttachmentInfoLoader(attachment); + } + + private void initAttachmentInfoLoader(Attachment attachment) { + LoaderManager loaderManager = getSupportLoaderManager(); + Bundle bundle = new Bundle(); + bundle.putParcelable(LOADER_ARG_ATTACHMENT, attachment); + loaderManager.initLoader(attachment.loaderId, bundle, mAttachmentInfoLoaderCallback); + } + + private void initAttachmentContentLoader(Attachment attachment) { + LoaderManager loaderManager = getSupportLoaderManager(); + Bundle bundle = new Bundle(); + bundle.putParcelable(LOADER_ARG_ATTACHMENT, attachment); + loaderManager.initLoader(attachment.loaderId, bundle, mAttachmentContentLoaderCallback); + } + + private void addAttachmentView(Attachment attachment) { + boolean hasMetadata = (attachment.state != Attachment.LoadingState.URI_ONLY); + boolean isLoadingComplete = (attachment.state == Attachment.LoadingState.COMPLETE); View view = getLayoutInflater().inflate(R.layout.message_compose_attachment, mAttachments, false); - TextView nameView = (TextView)view.findViewById(R.id.attachment_name); - ImageButton delete = (ImageButton)view.findViewById(R.id.attachment_delete); - nameView.setText(attachment.name); - delete.setOnClickListener(this); + TextView nameView = (TextView) view.findViewById(R.id.attachment_name); + View progressBar = view.findViewById(R.id.progressBar); + + if (hasMetadata) { + nameView.setText(attachment.name); + } else { + nameView.setText(R.string.loading_attachment); + } + + progressBar.setVisibility(isLoadingComplete ? View.GONE : View.VISIBLE); + + ImageButton delete = (ImageButton) view.findViewById(R.id.attachment_delete); + delete.setOnClickListener(MessageCompose.this); delete.setTag(view); + view.setTag(attachment); mAttachments.addView(view); } + private View getAttachmentView(int loaderId) { + for (int i = 0, childCount = mAttachments.getChildCount(); i < childCount; i++) { + View view = mAttachments.getChildAt(i); + Attachment tag = (Attachment) view.getTag(); + if (tag != null && tag.loaderId == loaderId) { + return view; + } + } + + return null; + } + + private LoaderManager.LoaderCallbacks mAttachmentInfoLoaderCallback = + new LoaderManager.LoaderCallbacks() { + @Override + public Loader onCreateLoader(int id, Bundle args) { + Attachment attachment = args.getParcelable(LOADER_ARG_ATTACHMENT); + return new AttachmentInfoLoader(MessageCompose.this, attachment); + } + + @Override + public void onLoadFinished(Loader loader, Attachment attachment) { + int loaderId = loader.getId(); + + View view = getAttachmentView(loaderId); + if (view != null) { + view.setTag(attachment); + + TextView nameView = (TextView) view.findViewById(R.id.attachment_name); + nameView.setText(attachment.name); + + attachment.loaderId = ++mMaxLoaderId; + initAttachmentContentLoader(attachment); + } + + getSupportLoaderManager().destroyLoader(loaderId); + } + + @Override + public void onLoaderReset(Loader loader) { + } + }; + + private LoaderManager.LoaderCallbacks mAttachmentContentLoaderCallback = + new LoaderManager.LoaderCallbacks() { + @Override + public Loader onCreateLoader(int id, Bundle args) { + Attachment attachment = args.getParcelable(LOADER_ARG_ATTACHMENT); + return new AttachmentContentLoader(MessageCompose.this, attachment); + } + + @Override + public void onLoadFinished(Loader loader, Attachment attachment) { + int loaderId = loader.getId(); + + View view = getAttachmentView(loaderId); + if (view != null) { + if (attachment.state == Attachment.LoadingState.COMPLETE) { + view.setTag(attachment); + + View progressBar = view.findViewById(R.id.progressBar); + progressBar.setVisibility(View.GONE); + } else { + mAttachments.removeView(view); + } + } + + getSupportLoaderManager().destroyLoader(loaderId); + } + + @Override + public void onLoaderReset(Loader loader) { + } + }; + + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { // if a CryptoSystem activity is returning, then mPreventDraftSaving was set to true diff --git a/src/com/fsck/k9/activity/loader/AttachmentContentLoader.java b/src/com/fsck/k9/activity/loader/AttachmentContentLoader.java new file mode 100644 index 000000000..7d677ecbc --- /dev/null +++ b/src/com/fsck/k9/activity/loader/AttachmentContentLoader.java @@ -0,0 +1,81 @@ +package com.fsck.k9.activity.loader; + +import android.content.Context; +import android.support.v4.content.AsyncTaskLoader; +import android.util.Log; + +import com.fsck.k9.K9; +import com.fsck.k9.activity.misc.Attachment; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Loader to fetch the content of an attachment. + * + * This will copy the data to a temporary file in our app's cache directory. + */ +public class AttachmentContentLoader extends AsyncTaskLoader { + private static final String FILENAME_PREFIX = "attachment"; + + private final Attachment mAttachment; + + public AttachmentContentLoader(Context context, Attachment attachment) { + super(context); + mAttachment = attachment; + } + + @Override + protected void onStartLoading() { + if (mAttachment.state == Attachment.LoadingState.COMPLETE) { + deliverResult(mAttachment); + } + + if (takeContentChanged() || mAttachment.state == Attachment.LoadingState.METADATA) { + forceLoad(); + } + } + + @Override + public Attachment loadInBackground() { + Context context = getContext(); + + try { + File file = File.createTempFile(FILENAME_PREFIX, null, context.getCacheDir()); + file.deleteOnExit(); + + if (K9.DEBUG) { + Log.v(K9.LOG_TAG, "Saving attachment to " + file.getAbsolutePath()); + } + + InputStream in = context.getContentResolver().openInputStream(mAttachment.uri); + try { + FileOutputStream out = new FileOutputStream(file); + try { + IOUtils.copy(in, out); + } finally { + out.close(); + } + } finally { + in.close(); + } + + mAttachment.filename = file.getAbsolutePath(); + mAttachment.state = Attachment.LoadingState.COMPLETE; + + return mAttachment; + } catch (IOException e) { + e.printStackTrace(); + } + + mAttachment.filename = null; + mAttachment.state = Attachment.LoadingState.CANCELLED; + + return mAttachment; + } +} diff --git a/src/com/fsck/k9/activity/loader/AttachmentInfoLoader.java b/src/com/fsck/k9/activity/loader/AttachmentInfoLoader.java new file mode 100644 index 000000000..74c874ae8 --- /dev/null +++ b/src/com/fsck/k9/activity/loader/AttachmentInfoLoader.java @@ -0,0 +1,100 @@ +package com.fsck.k9.activity.loader; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.support.v4.content.AsyncTaskLoader; +import android.util.Log; + +import com.fsck.k9.K9; +import com.fsck.k9.activity.misc.Attachment; +import com.fsck.k9.mail.internet.MimeUtility; + +import java.io.File; + +/** + * Loader to fetch metadata of an attachment. + */ +public class AttachmentInfoLoader extends AsyncTaskLoader { + private final Attachment mAttachment; + + public AttachmentInfoLoader(Context context, Attachment attachment) { + super(context); + mAttachment = attachment; + } + + @Override + protected void onStartLoading() { + if (mAttachment.state == Attachment.LoadingState.METADATA) { + deliverResult(mAttachment); + } + + if (takeContentChanged() || mAttachment.state == Attachment.LoadingState.URI_ONLY) { + forceLoad(); + } + } + + @Override + public Attachment loadInBackground() { + Uri uri = mAttachment.uri; + String contentType = mAttachment.contentType; + + long size = -1; + String name = null; + + ContentResolver contentResolver = getContext().getContentResolver(); + + Cursor metadataCursor = contentResolver.query( + uri, + new String[] { OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }, + null, + null, + null); + + if (metadataCursor != null) { + try { + if (metadataCursor.moveToFirst()) { + name = metadataCursor.getString(0); + size = metadataCursor.getInt(1); + } + } finally { + metadataCursor.close(); + } + } + + if (name == null) { + name = uri.getLastPathSegment(); + } + + String usableContentType = contentType; + if ((usableContentType == null) || (usableContentType.indexOf('*') != -1)) { + usableContentType = contentResolver.getType(uri); + } + if (usableContentType == null) { + usableContentType = MimeUtility.getMimeTypeByExtension(name); + } + + if (size <= 0) { + String uriString = uri.toString(); + if (uriString.startsWith("file://")) { + Log.v(K9.LOG_TAG, uriString.substring("file://".length())); + File f = new File(uriString.substring("file://".length())); + size = f.length(); + } else { + Log.v(K9.LOG_TAG, "Not a file: " + uriString); + } + } else { + Log.v(K9.LOG_TAG, "old attachment.size: " + size); + } + Log.v(K9.LOG_TAG, "new attachment.size: " + size); + + mAttachment.contentType = usableContentType; + mAttachment.name = name; + mAttachment.size = size; + mAttachment.state = Attachment.LoadingState.METADATA; + + return mAttachment; + } +} diff --git a/src/com/fsck/k9/activity/misc/Attachment.java b/src/com/fsck/k9/activity/misc/Attachment.java new file mode 100644 index 000000000..e64165783 --- /dev/null +++ b/src/com/fsck/k9/activity/misc/Attachment.java @@ -0,0 +1,129 @@ +package com.fsck.k9.activity.misc; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Container class for information about an attachment. + * + * This is used by {@link com.fsck.k9.activity.MessageCompose} to fetch and manage attachments. + */ +public class Attachment implements Parcelable { + /** + * The URI pointing to the source of the attachment. + * + * In most cases this will be a {@code content://}-URI. + */ + public Uri uri; + + /** + * The current loading state. + */ + public LoadingState state; + + /** + * The ID of the loader that is used to load the metadata or contents. + */ + public int loaderId; + + /** + * The content type of the attachment. + * + * Only valid when {@link #state} is {@link LoadingState#METADATA} or + * {@link LoadingState#COMPLETE}. + */ + public String contentType; + + /** + * The (file)name of the attachment. + * + * Only valid when {@link #state} is {@link LoadingState#METADATA} or + * {@link LoadingState#COMPLETE}. + */ + public String name; + + /** + * The size of the attachment. + * + * Only valid when {@link #state} is {@link LoadingState#METADATA} or + * {@link LoadingState#COMPLETE}. + */ + public long size; + + /** + * The name of the temporary file containing the local copy of the attachment. + * + * Only valid when {@link #state} is {@link LoadingState#COMPLETE}. + */ + public String filename; + + + public Attachment() {} + + public static enum LoadingState { + /** + * The only thing we know about this attachment is {@link #uri}. + */ + URI_ONLY, + + /** + * The metadata of this attachment have been loaded. + * + * {@link #contentType}, {@link #name}, and {@link #size} should contain usable values. + */ + METADATA, + + /** + * The contents of the attachments have been copied to the temporary file {@link #filename}. + */ + COMPLETE, + + /** + * Something went wrong while trying to fetch the attachment's contents. + */ + CANCELLED + } + + + // === Parcelable === + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(uri, flags); + dest.writeSerializable(state); + dest.writeInt(loaderId); + dest.writeString(contentType); + dest.writeString(name); + dest.writeLong(size); + dest.writeString(filename); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public Attachment createFromParcel(Parcel in) { + return new Attachment(in); + } + + @Override + public Attachment[] newArray(int size) { + return new Attachment[size]; + } + }; + + public Attachment(Parcel in) { + uri = in.readParcelable(Uri.class.getClassLoader()); + state = (LoadingState) in.readSerializable(); + loaderId = in.readInt(); + contentType = in.readString(); + name = in.readString(); + size = in.readLong(); + filename = in.readString(); + } +} diff --git a/src/com/fsck/k9/mail/store/LocalStore.java b/src/com/fsck/k9/mail/store/LocalStore.java index 5575499a7..de1c4272e 100644 --- a/src/com/fsck/k9/mail/store/LocalStore.java +++ b/src/com/fsck/k9/mail/store/LocalStore.java @@ -3,6 +3,7 @@ package com.fsck.k9.mail.store; import java.io.ByteArrayInputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; @@ -90,6 +91,7 @@ public class LocalStore extends Store implements Serializable { private static final Message[] EMPTY_MESSAGE_ARRAY = new Message[0]; private static final String[] EMPTY_STRING_ARRAY = new String[0]; private static final Flag[] EMPTY_FLAG_ARRAY = new Flag[0]; + private static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; /* * a String containing the columns getMessages expects to work with @@ -3984,8 +3986,39 @@ public class LocalStore extends Store implements Serializable { } } + public static class TempFileBody implements Body { + private final File mFile; + + public TempFileBody(String filename) { + mFile = new File(filename); + } + + @Override + public InputStream getInputStream() throws MessagingException { + try { + return new FileInputStream(mFile); + } catch (FileNotFoundException e) { + return new ByteArrayInputStream(EMPTY_BYTE_ARRAY); + } + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + InputStream in = getInputStream(); + try { + Base64OutputStream base64Out = new Base64OutputStream(out); + try { + IOUtils.copy(in, base64Out); + } finally { + base64Out.close(); + } + } finally { + in.close(); + } + } + } + public static class LocalAttachmentBody implements Body { - private static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; private Application mApplication; private Uri mUri;