diff --git a/images/drawables-pgp/status_lock_closed.svg b/images/drawables-pgp/status_lock_closed.svg new file mode 100644 index 000000000..286e89297 --- /dev/null +++ b/images/drawables-pgp/status_lock_closed.svg @@ -0,0 +1,12 @@ + + + + lock-closed + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/drawables-pgp/status_lock_error.svg b/images/drawables-pgp/status_lock_error.svg new file mode 100644 index 000000000..d3c4e1d1d --- /dev/null +++ b/images/drawables-pgp/status_lock_error.svg @@ -0,0 +1,12 @@ + + + + lock-error + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/drawables-pgp/status_lock_open.svg b/images/drawables-pgp/status_lock_open.svg new file mode 100644 index 000000000..9beb127af --- /dev/null +++ b/images/drawables-pgp/status_lock_open.svg @@ -0,0 +1,12 @@ + + + + lock-open + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/drawables-pgp/status_signature_expired_cutout.svg b/images/drawables-pgp/status_signature_expired_cutout.svg new file mode 100644 index 000000000..61ac8fdd0 --- /dev/null +++ b/images/drawables-pgp/status_signature_expired_cutout.svg @@ -0,0 +1,12 @@ + + + + signature-expired-cutout + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/drawables-pgp/status_signature_invalid_cutout.svg b/images/drawables-pgp/status_signature_invalid_cutout.svg new file mode 100644 index 000000000..61fd2ace0 --- /dev/null +++ b/images/drawables-pgp/status_signature_invalid_cutout.svg @@ -0,0 +1,12 @@ + + + + signature-invalid-cutout + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/drawables-pgp/status_signature_revoked_cutout.svg b/images/drawables-pgp/status_signature_revoked_cutout.svg new file mode 100644 index 000000000..0421286fe --- /dev/null +++ b/images/drawables-pgp/status_signature_revoked_cutout.svg @@ -0,0 +1,12 @@ + + + + signature-revoked-cutout + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/drawables-pgp/status_signature_unknown_cutout.svg b/images/drawables-pgp/status_signature_unknown_cutout.svg new file mode 100644 index 000000000..402bffcaa --- /dev/null +++ b/images/drawables-pgp/status_signature_unknown_cutout.svg @@ -0,0 +1,12 @@ + + + + signature-unknown-cutout + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/drawables-pgp/status_signature_unverified_cutout.svg b/images/drawables-pgp/status_signature_unverified_cutout.svg new file mode 100644 index 000000000..ffa98580a --- /dev/null +++ b/images/drawables-pgp/status_signature_unverified_cutout.svg @@ -0,0 +1,12 @@ + + + + signature-unverified-cutout + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/drawables-pgp/status_signature_verified_cutout.svg b/images/drawables-pgp/status_signature_verified_cutout.svg new file mode 100644 index 000000000..04356a977 --- /dev/null +++ b/images/drawables-pgp/status_signature_verified_cutout.svg @@ -0,0 +1,12 @@ + + + + signature-verified-cutout + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/update-drawables-pgp.sh b/images/update-drawables-pgp.sh new file mode 100755 index 000000000..45220ebc5 --- /dev/null +++ b/images/update-drawables-pgp.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +APP_DIR=../k9mail/src/main +MDPI_DIR=$APP_DIR/res/drawable-mdpi +HDPI_DIR=$APP_DIR/res/drawable-hdpi +XDPI_DIR=$APP_DIR/res/drawable-xhdpi +XXDPI_DIR=$APP_DIR/res/drawable-xxhdpi +XXXDPI_DIR=$APP_DIR/res/drawable-xxxhdpi +SRC_DIR=./drawables-pgp/ + + +for NAME in "status_lock_closed" "status_lock_error" "status_lock_open" "status_signature_expired_cutout" "status_signature_invalid_cutout" "status_signature_revoked_cutout" "status_signature_unknown_cutout" "status_signature_unverified_cutout" "status_signature_verified_cutout" +do +echo $NAME +inkscape -w 24 -h 24 -e "$MDPI_DIR/$NAME.png" "$SRC_DIR/$NAME.svg" +inkscape -w 32 -h 32 -e "$HDPI_DIR/$NAME.png" "$SRC_DIR/$NAME.svg" +inkscape -w 48 -h 48 -e "$XDPI_DIR/$NAME.png" "$SRC_DIR/$NAME.svg" +inkscape -w 64 -h 64 -e "$XXDPI_DIR/$NAME.png" "$SRC_DIR/$NAME.svg" +done diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/BodyPart.java b/k9mail-library/src/main/java/com/fsck/k9/mail/BodyPart.java index 551866829..ef48dce8f 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/BodyPart.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/BodyPart.java @@ -2,14 +2,25 @@ package com.fsck.k9.mail; public abstract class BodyPart implements Part { - private Multipart mParent; + private String serverExtra; + private Multipart parent; + + @Override + public String getServerExtra() { + return serverExtra; + } + + @Override + public void setServerExtra(String serverExtra) { + this.serverExtra = serverExtra; + } public Multipart getParent() { - return mParent; + return parent; } public void setParent(Multipart parent) { - mParent = parent; + this.parent = parent; } public abstract void setEncoding(String encoding) throws MessagingException; diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/Message.java b/k9mail-library/src/main/java/com/fsck/k9/mail/Message.java index a77e0c282..67ba3a244 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/Message.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/Message.java @@ -120,9 +120,6 @@ public abstract class Message implements Part, CompositeBody { @Override public abstract Body getBody(); - @Override - public abstract String getContentType() throws MessagingException; - @Override public abstract void addHeader(String name, String value) throws MessagingException; @@ -141,7 +138,7 @@ public abstract class Message implements Part, CompositeBody { public abstract void removeHeader(String name) throws MessagingException; @Override - public abstract void setBody(Body body) throws MessagingException; + public abstract void setBody(Body body); public abstract long getId(); @@ -150,55 +147,6 @@ public abstract class Message implements Part, CompositeBody { public abstract int getSize(); - /* - * calculateContentPreview - * Takes a plain text message body as a string. - * Returns a message summary as a string suitable for showing in a message list - * - * A message summary should be about the first 160 characters - * of unique text written by the message sender - * Quoted text, "On $date" and so on will be stripped out. - * All newlines and whitespace will be compressed. - * - */ - public static String calculateContentPreview(String text) { - if (text == null) { - return null; - } - - // Only look at the first 8k of a message when calculating - // the preview. This should avoid unnecessary - // memory usage on large messages - if (text.length() > 8192) { - text = text.substring(0, 8192); - } - - // Remove (correctly delimited by '-- \n') signatures - text = text.replaceAll("(?ms)^-- [\\r\\n]+.*", ""); - // try to remove lines of dashes in the preview - text = text.replaceAll("(?m)^----.*?$", ""); - // remove quoted text from the preview - text = text.replaceAll("(?m)^[#>].*$", ""); - // Remove a common quote header from the preview - text = text.replaceAll("(?m)^On .*wrote.?$", ""); - // Remove a more generic quote header from the preview - text = text.replaceAll("(?m)^.*\\w+:$", ""); - // Remove horizontal rules. - text = text.replaceAll("\\s*([-=_]{30,}+)\\s*", " "); - - // URLs in the preview should just be shown as "..." - They're not - // clickable and they usually overwhelm the preview - text = text.replaceAll("https?://\\S+", "..."); - // Don't show newlines in the preview - text = text.replaceAll("(\\r|\\n)+", " "); - // Collapse whitespace in the preview - text = text.replaceAll("\\s+", " "); - // Remove any whitespace at the beginning and end of the string. - text = text.trim(); - - return (text.length() <= 512) ? text : text.substring(0, 512); - } - public void delete(String trashFolderName) throws MessagingException {} /* diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/Multipart.java b/k9mail-library/src/main/java/com/fsck/k9/mail/Multipart.java index 5f82e062d..b89b69182 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/Multipart.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/Multipart.java @@ -28,7 +28,9 @@ public abstract class Multipart implements CompositeBody { return Collections.unmodifiableList(mParts); } - public abstract String getContentType(); + public abstract String getMimeType(); + + public abstract String getBoundary(); public int getCount() { return mParts.size(); @@ -64,4 +66,7 @@ public abstract class Multipart implements CompositeBody { ((TextBody)body).setCharset(charset); } } + + public abstract byte[] getPreamble(); + public abstract byte[] getEpilogue(); } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/Part.java b/k9mail-library/src/main/java/com/fsck/k9/mail/Part.java index 1d0274f32..d78132f21 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/Part.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/Part.java @@ -2,6 +2,7 @@ package com.fsck.k9.mail; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; public interface Part { @@ -15,22 +16,24 @@ public interface Part { Body getBody(); - String getContentType() throws MessagingException; + String getContentType(); String getDisposition() throws MessagingException; - String getContentId() throws MessagingException; + String getContentId(); String[] getHeader(String name) throws MessagingException; boolean isMimeType(String mimeType) throws MessagingException; - String getMimeType() throws MessagingException; + String getMimeType(); - void setBody(Body body) throws MessagingException; + void setBody(Body body); void writeTo(OutputStream out) throws IOException, MessagingException; + void writeHeaderTo(OutputStream out) throws IOException, MessagingException; + /** * Called just prior to transmission, once the type of transport is known to * be 7bit. @@ -44,4 +47,8 @@ public interface Part { */ //TODO perhaps it would be clearer to use a flag "force7bit" in writeTo void setUsing7bitTransport() throws MessagingException; + + String getServerExtra(); + + void setServerExtra(String serverExtra); } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileBody.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileBody.java index 17870c140..157b4046c 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileBody.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileBody.java @@ -1,6 +1,5 @@ package com.fsck.k9.mail.internet; -import com.fsck.k9.mail.Body; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.filter.Base64OutputStream; import org.apache.commons.io.IOUtils; @@ -15,7 +14,7 @@ import java.io.*; * and writeTo one time. After writeTo is called, or the InputStream returned from * getInputStream is closed the file is deleted and the Body should be considered disposed of. */ -public class BinaryTempFileBody implements RawDataBody { +public class BinaryTempFileBody implements RawDataBody, SizeAware { private static File mTempDirectory; private File mFile; @@ -26,6 +25,10 @@ public class BinaryTempFileBody implements RawDataBody { mTempDirectory = tempDirectory; } + public static File getTempDirectory() { + return mTempDirectory; + } + @Override public String getEncoding() { return mEncoding; @@ -101,6 +104,15 @@ public class BinaryTempFileBody implements RawDataBody { } } + @Override + public long getSize() { + return mFile.length(); + } + + public File getFile() { + return mFile; + } + class BinaryTempFileBodyInputStream extends FilterInputStream { public BinaryTempFileBodyInputStream(InputStream in) { super(in); diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileMessageBody.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileMessageBody.java index e5fe7c370..a47f2dffd 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileMessageBody.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/BinaryTempFileMessageBody.java @@ -11,10 +11,7 @@ import com.fsck.k9.mail.CompositeBody; import com.fsck.k9.mail.MessagingException; /** - * A {@link BinaryTempFileBody} extension containing a body of type - * message/rfc822. This relates to a BinaryTempFileBody the same way that a - * {@link LocalAttachmentMessageBody} relates to a {@link LocalAttachmentBody}. - * + * A {@link BinaryTempFileBody} extension containing a body of type message/rfc822. */ public class BinaryTempFileMessageBody extends BinaryTempFileBody implements CompositeBody { @@ -63,4 +60,4 @@ public class BinaryTempFileMessageBody extends BinaryTempFileBody implements Com */ } -} \ No newline at end of file +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MessageExtractor.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MessageExtractor.java index ba0bfa42f..7d1b6e291 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MessageExtractor.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MessageExtractor.java @@ -48,7 +48,7 @@ public class MessageExtractor { * determine the charset from HTML message. */ if (mimeType.equalsIgnoreCase("text/html") && charset == null) { - InputStream in = part.getBody().getInputStream(); + InputStream in = MimeUtility.decodeBody(body); try { byte[] buf = new byte[256]; in.read(buf, 0, buf.length); @@ -64,18 +64,8 @@ public class MessageExtractor { } } finally { try { - if (in instanceof BinaryTempFileBody.BinaryTempFileBodyInputStream) { - /* - * If this is a BinaryTempFileBodyInputStream, calling close() - * will delete the file. But we can't let that happen because - * the file needs to be opened again by the code a few lines - * down. - */ - ((BinaryTempFileBody.BinaryTempFileBodyInputStream) in).closeWithoutDeleting(); - } else { - in.close(); - } - } catch (Exception e) { /* ignore */ } + MimeUtility.closeInputStreamWithoutDeletingTemporaryFiles(in); + } catch (IOException e) { /* ignore */ } } } charset = fixupCharset(charset, getMessageFromPart(part)); @@ -84,22 +74,12 @@ public class MessageExtractor { * Now we read the part into a buffer for further processing. Because * the stream is now wrapped we'll remove any transfer encoding at this point. */ - InputStream in = part.getBody().getInputStream(); + InputStream in = MimeUtility.decodeBody(body); try { - String text = CharsetSupport.readToString(in, charset); - - // Replace the body with a TextBody that already contains the decoded text - part.setBody(new TextBody(text)); - - return text; + return CharsetSupport.readToString(in, charset); } finally { try { - /* - * This time we don't care if it's a BinaryTempFileBodyInputStream. We - * replaced the body with a TextBody instance and hence don't need the - * file anymore. - */ - in.close(); + MimeUtility.closeInputStreamWithoutDeletingTemporaryFiles(in); } catch (IOException e) { /* Ignore */ } } } @@ -186,6 +166,8 @@ public class MessageExtractor { Html html = new Html(part); viewables.add(html); } + } else if (part.getMimeType().equalsIgnoreCase("application/pgp-signature")) { + // ignore this type explicitly } else { // Everything else is treated as attachment. attachments.add(part); diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeBodyPart.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeBodyPart.java index 32e3e2654..2a099db15 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeBodyPart.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeBodyPart.java @@ -5,7 +5,6 @@ import com.fsck.k9.mail.Body; import com.fsck.k9.mail.BodyPart; import com.fsck.k9.mail.CompositeBody; import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.Multipart; import java.io.BufferedWriter; import java.io.IOException; @@ -73,7 +72,7 @@ public class MimeBodyPart extends BodyPart { } @Override - public void setBody(Body body) throws MessagingException { + public void setBody(Body body) { this.mBody = body; } @@ -86,7 +85,7 @@ public class MimeBodyPart extends BodyPart { } @Override - public String getContentType() throws MessagingException { + public String getContentType() { String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE); return (contentType == null) ? "text/plain" : contentType; } @@ -97,7 +96,7 @@ public class MimeBodyPart extends BodyPart { } @Override - public String getContentId() throws MessagingException { + public String getContentId() { String contentId = getFirstHeader(MimeHeader.HEADER_CONTENT_ID); if (contentId == null) { return null; @@ -112,7 +111,7 @@ public class MimeBodyPart extends BodyPart { } @Override - public String getMimeType() throws MessagingException { + public String getMimeType() { return MimeUtility.getHeaderParameter(getContentType(), null); } @@ -135,6 +134,11 @@ public class MimeBodyPart extends BodyPart { } } + @Override + public void writeHeaderTo(OutputStream out) throws IOException, MessagingException { + mHeader.writeTo(out); + } + @Override public void setUsing7bitTransport() throws MessagingException { String type = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE); diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeHeader.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeHeader.java index f3835d04a..318ea6da0 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeHeader.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeHeader.java @@ -11,28 +11,11 @@ import java.util.*; public class MimeHeader { private static final String[] EMPTY_STRING_ARRAY = new String[0]; - /** - * Application specific header that contains Store specific information about an attachment. - * In IMAP this contains the IMAP BODYSTRUCTURE part id so that the ImapStore can later - * retrieve the attachment at will from the server. - * The info is recorded from this header on LocalStore.appendMessages and is put back - * into the MIME data by LocalStore.fetch. - */ - public static final String HEADER_ANDROID_ATTACHMENT_STORE_DATA = "X-Android-Attachment-StoreData"; - public static final String HEADER_CONTENT_TYPE = "Content-Type"; public static final String HEADER_CONTENT_TRANSFER_ENCODING = "Content-Transfer-Encoding"; public static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition"; public static final String HEADER_CONTENT_ID = "Content-ID"; - /** - * Fields that should be omitted when writing the header using writeTo() - */ - private static final String[] writeOmitFields = { -// HEADER_ANDROID_ATTACHMENT_DOWNLOADED, -// HEADER_ANDROID_ATTACHMENT_ID, - HEADER_ANDROID_ATTACHMENT_STORE_DATA - }; private List mFields = new ArrayList(); private String mCharset = null; @@ -101,14 +84,12 @@ public class MimeHeader { public void writeTo(OutputStream out) throws IOException { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); for (Field field : mFields) { - if (!Arrays.asList(writeOmitFields).contains(field.name)) { - if (field.hasRawData()) { - writer.write(field.getRaw()); - } else { - writeNameValueField(writer, field); - } - writer.write("\r\n"); + if (field.hasRawData()) { + writer.write(field.getRaw()); + } else { + writeNameValueField(writer, field); } + writer.write("\r\n"); } writer.flush(); } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMessage.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMessage.java index 632018c29..7b0af70bb 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMessage.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMessage.java @@ -57,6 +57,7 @@ public class MimeMessage extends Message { private Body mBody; protected int mSize; + private String serverExtra; public MimeMessage() { } @@ -162,7 +163,7 @@ public class MimeMessage extends Message { } @Override - public String getContentType() throws MessagingException { + public String getContentType() { String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE); return (contentType == null) ? "text/plain" : contentType; } @@ -171,12 +172,14 @@ public class MimeMessage extends Message { public String getDisposition() throws MessagingException { return getFirstHeader(MimeHeader.HEADER_CONTENT_DISPOSITION); } + @Override - public String getContentId() throws MessagingException { + public String getContentId() { return null; } + @Override - public String getMimeType() throws MessagingException { + public String getMimeType() { return MimeUtility.getHeaderParameter(getContentType(), null); } @@ -308,13 +311,10 @@ public class MimeMessage extends Message { if (mMessageId == null) { mMessageId = getFirstHeader("Message-ID"); } - if (mMessageId == null) { // even after checking the header - setMessageId(generateMessageId()); - } return mMessageId; } - private String generateMessageId() { + public void generateMessageId() throws MessagingException { String hostname = null; if (mFrom != null && mFrom.length >= 1) { @@ -330,7 +330,9 @@ public class MimeMessage extends Message { } /* We use upper case here to match Apple Mail Message-ID format (for privacy) */ - return "<" + UUID.randomUUID().toString().toUpperCase(Locale.US) + "@" + hostname + ">"; + String messageId = "<" + UUID.randomUUID().toString().toUpperCase(Locale.US) + "@" + hostname + ">"; + + setMessageId(messageId); } public void setMessageId(String messageId) throws MessagingException { @@ -394,7 +396,7 @@ public class MimeMessage extends Message { } @Override - public void setBody(Body body) throws MessagingException { + public void setBody(Body body) { this.mBody = body; } @@ -444,6 +446,11 @@ public class MimeMessage extends Message { } } + @Override + public void writeHeaderTo(OutputStream out) throws IOException, MessagingException { + mHeader.writeTo(out); + } + @Override public InputStream getInputStream() throws MessagingException { return null; @@ -487,13 +494,11 @@ public class MimeMessage extends Message { stack.addFirst(MimeMessage.this); } else { expect(Part.class); - try { - MimeMessage m = new MimeMessage(); - ((Part)stack.peek()).setBody(m); - stack.addFirst(m); - } catch (MessagingException me) { - throw new Error(me); - } + Part part = (Part) stack.peek(); + + MimeMessage m = new MimeMessage(); + part.setBody(m); + stack.addFirst(m); } } @@ -519,7 +524,10 @@ public class MimeMessage extends Message { Part e = (Part)stack.peek(); try { - MimeMultipart multiPart = new MimeMultipart(e.getContentType()); + String contentType = e.getContentType(); + String mimeType = MimeUtility.getHeaderParameter(contentType, null); + String boundary = MimeUtility.getHeaderParameter(contentType, "boundary"); + MimeMultipart multiPart = new MimeMultipart(mimeType, boundary); e.setBody(multiPart); stack.addFirst(multiPart); } catch (MessagingException me) { @@ -540,7 +548,21 @@ public class MimeMessage extends Message { @Override public void endMultipart() { - stack.removeFirst(); + expect(Multipart.class); + Multipart multipart = (Multipart) stack.removeFirst(); + + boolean hasNoBodyParts = multipart.getCount() == 0; + boolean hasNoEpilogue = multipart.getEpilogue() == null; + if (hasNoBodyParts && hasNoEpilogue) { + /* + * The parser is calling startMultipart(), preamble(), and endMultipart() when all we have is + * headers of a "multipart/*" part. But there's really no point in keeping a Multipart body if all + * of the content is missing. + */ + expect(Part.class); + Part part = (Part) stack.peek(); + part.setBody(null); + } } @Override @@ -686,4 +708,16 @@ public class MimeMessage extends Message { setEncoding(MimeUtil.ENC_QUOTED_PRINTABLE); } } + + @Override + public String getServerExtra() { + return serverExtra; + } + + @Override + public void setServerExtra(String serverExtra) { + this.serverExtra = serverExtra; + } + + } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMessageHelper.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMessageHelper.java index bc1695607..6cb1bd64f 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMessageHelper.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMessageHelper.java @@ -23,9 +23,10 @@ public class MimeMessageHelper { if (body instanceof Multipart) { Multipart multipart = ((Multipart) body); multipart.setParent(part); - String type = multipart.getContentType(); - part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, type); - if ("multipart/signed".equalsIgnoreCase(type)) { + String mimeType = multipart.getMimeType(); + String contentType = String.format("%s; boundary=\"%s\"", mimeType, multipart.getBoundary()); + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType); + if ("multipart/signed".equalsIgnoreCase(mimeType)) { setEncoding(part, MimeUtil.ENC_7BIT); } else { setEncoding(part, MimeUtil.ENC_8BIT); diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMultipart.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMultipart.java index d6ce4377a..ae4c52016 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMultipart.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeMultipart.java @@ -10,30 +10,26 @@ import java.util.Locale; import java.util.Random; public class MimeMultipart extends Multipart { - private byte[] mPreamble; - private byte[] mEpilogue; - - private String mContentType; - - private final String mBoundary; + private String mimeType; + private byte[] preamble; + private byte[] epilogue; + private final String boundary; public MimeMultipart() throws MessagingException { - mBoundary = generateBoundary(); + boundary = generateBoundary(); setSubType("mixed"); } - public MimeMultipart(String contentType) throws MessagingException { - this.mContentType = contentType; - try { - mBoundary = MimeUtility.getHeaderParameter(contentType, "boundary"); - if (mBoundary == null) { - throw new MessagingException("MultiPart does not contain boundary: " + contentType); - } - } catch (Exception e) { - throw new MessagingException( - "Invalid MultiPart Content-Type; must contain subtype and boundary. (" - + contentType + ")", e); + public MimeMultipart(String mimeType, String boundary) throws MessagingException { + if (mimeType == null) { + throw new IllegalArgumentException("mimeType can't be null"); } + if (boundary == null) { + throw new IllegalArgumentException("boundary can't be null"); + } + + this.mimeType = mimeType; + this.boundary = boundary; } public String generateBoundary() { @@ -46,40 +42,53 @@ public class MimeMultipart extends Multipart { return sb.toString().toUpperCase(Locale.US); } + @Override + public String getBoundary() { + return boundary; + } + + public byte[] getPreamble() { + return preamble; + } + public void setPreamble(byte[] preamble) { - this.mPreamble = preamble; + this.preamble = preamble; + } + + public byte[] getEpilogue() { + return epilogue; } public void setEpilogue(byte[] epilogue) { - mEpilogue = epilogue; + this.epilogue = epilogue; } @Override - public String getContentType() { - return mContentType; + public String getMimeType() { + return mimeType; } public void setSubType(String subType) { - mContentType = String.format("multipart/%s; boundary=\"%s\"", subType, mBoundary); + mimeType = "multipart/" + subType; } @Override public void writeTo(OutputStream out) throws IOException, MessagingException { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); - if (mPreamble != null) { - out.write(mPreamble); + if (preamble != null) { + out.write(preamble); writer.write("\r\n"); } if (getBodyParts().isEmpty()) { writer.write("--"); - writer.write(mBoundary); + writer.write(boundary); writer.write("\r\n"); } else { for (BodyPart bodyPart : getBodyParts()) { writer.write("--"); - writer.write(mBoundary); + writer.write(boundary); writer.write("\r\n"); writer.flush(); bodyPart.writeTo(out); @@ -88,11 +97,11 @@ public class MimeMultipart extends Multipart { } writer.write("--"); - writer.write(mBoundary); + writer.write(boundary); writer.write("--\r\n"); writer.flush(); - if (mEpilogue != null) { - out.write(mEpilogue); + if (epilogue != null) { + out.write(epilogue); } } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeUtility.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeUtility.java index bfde9e0b4..ee68fe449 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeUtility.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeUtility.java @@ -16,10 +16,7 @@ import org.apache.james.mime4j.util.MimeUtil; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; import java.util.Locale; -import java.util.Set; import java.util.regex.Pattern; @@ -1029,7 +1026,7 @@ public class MimeUtility { @Override public void close() throws IOException { super.close(); - rawInputStream.close(); + closeInputStreamWithoutDeletingTemporaryFiles(rawInputStream); } }; } else if (MimeUtil.ENC_QUOTED_PRINTABLE.equalsIgnoreCase(encoding)) { @@ -1037,7 +1034,7 @@ public class MimeUtility { @Override public void close() throws IOException { super.close(); - rawInputStream.close(); + closeInputStreamWithoutDeletingTemporaryFiles(rawInputStream); } }; } else { @@ -1050,6 +1047,14 @@ public class MimeUtility { return inputStream; } + public static void closeInputStreamWithoutDeletingTemporaryFiles(InputStream rawInputStream) throws IOException { + if (rawInputStream instanceof BinaryTempFileBody.BinaryTempFileBodyInputStream) { + ((BinaryTempFileBody.BinaryTempFileBodyInputStream) rawInputStream).closeWithoutDeleting(); + } else { + rawInputStream.close(); + } + } + public static String getMimeTypeByExtension(String filename) { String returnedType = null; String extension = null; diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/SizeAware.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/SizeAware.java new file mode 100644 index 000000000..07d5fdef9 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/SizeAware.java @@ -0,0 +1,6 @@ +package com.fsck.k9.mail.internet; + + +public interface SizeAware { + long getSize(); +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/TextBody.java b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/TextBody.java index d6355662f..84162bd41 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/TextBody.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/TextBody.java @@ -10,10 +10,11 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; +import com.fsck.k9.mail.filter.CountingOutputStream; import org.apache.james.mime4j.codec.QuotedPrintableOutputStream; import org.apache.james.mime4j.util.MimeUtil; -public class TextBody implements Body { +public class TextBody implements Body, SizeAware { /** * Immutable empty byte array @@ -98,4 +99,33 @@ public class TextBody implements Body { public void setComposedMessageOffset(Integer composedMessageOffset) { this.mComposedMessageOffset = composedMessageOffset; } + + @Override + public long getSize() { + try { + byte[] bytes = mBody.getBytes(mCharset); + + if (MimeUtil.ENC_8BIT.equalsIgnoreCase(mEncoding)) { + return bytes.length; + } else { + return getLengthWhenQuotedPrintableEncoded(bytes); + } + } catch (IOException e) { + throw new RuntimeException("Couldn't get body size", e); + } + } + + private long getLengthWhenQuotedPrintableEncoded(byte[] bytes) throws IOException { + CountingOutputStream countingOutputStream = new CountingOutputStream(); + OutputStream quotedPrintableOutputStream = new QuotedPrintableOutputStream(countingOutputStream, false); + try { + quotedPrintableOutputStream.write(bytes); + } finally { + try { + quotedPrintableOutputStream.close(); + } catch (IOException e) { /* ignore */ } + } + + return countingOutputStream.getCount(); + } } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/message/MessageHeaderParser.java b/k9mail-library/src/main/java/com/fsck/k9/mail/message/MessageHeaderParser.java new file mode 100644 index 000000000..758158ee1 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/message/MessageHeaderParser.java @@ -0,0 +1,118 @@ +package com.fsck.k9.mail.message; + + +import java.io.IOException; +import java.io.InputStream; + +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Part; +import org.apache.james.mime4j.MimeException; +import org.apache.james.mime4j.parser.ContentHandler; +import org.apache.james.mime4j.parser.MimeStreamParser; +import org.apache.james.mime4j.stream.BodyDescriptor; +import org.apache.james.mime4j.stream.Field; +import org.apache.james.mime4j.stream.MimeConfig; + + +public class MessageHeaderParser { + + public static void parse(final Part part, InputStream headerInputStream) throws MessagingException { + MimeStreamParser parser = getMimeStreamParser(); + parser.setContentHandler(new MessageHeaderParserContentHandler(part)); + + try { + parser.parse(headerInputStream); + } catch (MimeException me) { + throw new MessagingException("Error parsing headers", me); + } catch (IOException e) { + throw new MessagingException("I/O error parsing headers", e); + } + } + + private static MimeStreamParser getMimeStreamParser() { + MimeConfig parserConfig = new MimeConfig(); + parserConfig.setMaxHeaderLen(-1); + parserConfig.setMaxLineLen(-1); + parserConfig.setMaxHeaderCount(-1); + return new MimeStreamParser(parserConfig); + } + + private static class MessageHeaderParserContentHandler implements ContentHandler { + private final Part part; + + public MessageHeaderParserContentHandler(Part part) { + this.part = part; + } + + @Override + public void field(Field rawField) throws MimeException { + String name = rawField.getName(); + String raw = rawField.getRaw().toString(); + try { + part.addRawHeader(name, raw); + } catch (MessagingException e) { + throw new RuntimeException(e); + } + } + + @Override + public void startMessage() throws MimeException { + /* do nothing */ + } + + @Override + public void endMessage() throws MimeException { + /* do nothing */ + } + + @Override + public void startBodyPart() throws MimeException { + /* do nothing */ + } + + @Override + public void endBodyPart() throws MimeException { + /* do nothing */ + } + + @Override + public void startHeader() throws MimeException { + /* do nothing */ + } + + @Override + public void endHeader() throws MimeException { + /* do nothing */ + } + + @Override + public void preamble(InputStream is) throws MimeException, IOException { + /* do nothing */ + } + + @Override + public void epilogue(InputStream is) throws MimeException, IOException { + /* do nothing */ + } + + @Override + public void startMultipart(BodyDescriptor bd) throws MimeException { + /* do nothing */ + } + + @Override + public void endMultipart() throws MimeException { + /* do nothing */ + } + + @Override + public void body(BodyDescriptor bd, InputStream is) throws MimeException, IOException { + /* do nothing */ + } + + @Override + public void raw(InputStream is) throws MimeException, IOException { + /* do nothing */ + } + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.java index c42139367..e02d84d84 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.java @@ -1456,13 +1456,9 @@ public class ImapStore extends RemoteStore { throws MessagingException { checkOpen(); //only need READ access - String[] parts = part.getHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA); - if (parts == null) { - return; - } + String partId = part.getServerExtra(); String fetch; - String partId = parts[0]; if ("TEXT".equalsIgnoreCase(partId)) { fetch = String.format(Locale.US, "BODY.PEEK[TEXT]<0.%d>", mStoreConfig.getMaximumAutoDownloadMessageSize()); @@ -1715,7 +1711,7 @@ public class ImapStore extends RemoteStore { break; } } - part.setBody(mp); + MimeMessageHelper.setBody(part, mp); } else { /* * This is a body. We need to add as much information as we can find out about @@ -1835,7 +1831,7 @@ public class ImapStore extends RemoteStore { if (part instanceof ImapMessage) { ((ImapMessage) part).setSize(size); } - part.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, id); + part.setServerExtra(id); } } diff --git a/k9mail/build.gradle b/k9mail/build.gradle index 8e935d7b7..39d36a55c 100644 --- a/k9mail/build.gradle +++ b/k9mail/build.gradle @@ -20,6 +20,7 @@ dependencies { compile 'com.android.support:support-v13:21.0.2' compile 'net.sourceforge.htmlcleaner:htmlcleaner:2.10' compile 'de.cketti.library.changelog:ckchangelog:1.2.1' + compile 'com.github.bumptech.glide:glide:3.4.0' androidTestCompile 'com.android.support.test:testing-support-lib:0.1' androidTestCompile 'com.android.support.test.espresso:espresso-core:2.0' diff --git a/k9mail/src/androidTest/java/com/fsck/k9/crypto/MessageDecryptVerifierTest.java b/k9mail/src/androidTest/java/com/fsck/k9/crypto/MessageDecryptVerifierTest.java new file mode 100644 index 000000000..e06bd7971 --- /dev/null +++ b/k9mail/src/androidTest/java/com/fsck/k9/crypto/MessageDecryptVerifierTest.java @@ -0,0 +1,78 @@ +package com.fsck.k9.crypto; + + +import java.util.List; + +import android.support.test.runner.AndroidJUnit4; + +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.mail.internet.MimeMessageHelper; +import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.mail.internet.TextBody; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertSame; + + +@RunWith(AndroidJUnit4.class) +public class MessageDecryptVerifierTest { + + @Test + public void findEncryptedPartsShouldReturnEmptyListForEmptyMessage() throws Exception { + MimeMessage emptyMessage = new MimeMessage(); + + List encryptedParts = MessageDecryptVerifier.findEncryptedParts(emptyMessage); + assertEquals(0, encryptedParts.size()); + } + + @Test + public void findEncryptedPartsShouldReturnEmptyListForSimpleMessage() throws Exception { + MimeMessage message = new MimeMessage(); + message.setBody(new TextBody("message text")); + + List encryptedParts = MessageDecryptVerifier.findEncryptedParts(message); + assertEquals(0, encryptedParts.size()); + } + + @Test + public void findEncryptedPartsShouldReturnEmptyEncryptedPart() throws Exception { + MimeMessage message = new MimeMessage(); + MimeMultipart mulitpartEncrypted = new MimeMultipart(); + mulitpartEncrypted.setSubType("encrypted"); + MimeMessageHelper.setBody(message, mulitpartEncrypted); + + List encryptedParts = MessageDecryptVerifier.findEncryptedParts(message); + assertEquals(1, encryptedParts.size()); + assertSame(message, encryptedParts.get(0)); + } + + @Test + public void findEncryptedPartsShouldReturnMultipleEncryptedParts() throws Exception { + MimeMessage message = new MimeMessage(); + MimeMultipart multipartMixed = new MimeMultipart(); + multipartMixed.setSubType("mixed"); + MimeMessageHelper.setBody(message, multipartMixed); + + MimeMultipart mulitpartEncryptedOne = new MimeMultipart(); + mulitpartEncryptedOne.setSubType("encrypted"); + MimeBodyPart bodyPartOne = new MimeBodyPart(mulitpartEncryptedOne); + multipartMixed.addBodyPart(bodyPartOne); + + MimeBodyPart bodyPartTwo = new MimeBodyPart(null, "text/plain"); + multipartMixed.addBodyPart(bodyPartTwo); + + MimeMultipart mulitpartEncryptedThree = new MimeMultipart(); + mulitpartEncryptedThree.setSubType("encrypted"); + MimeBodyPart bodyPartThree = new MimeBodyPart(mulitpartEncryptedThree); + multipartMixed.addBodyPart(bodyPartThree); + + List encryptedParts = MessageDecryptVerifier.findEncryptedParts(message); + assertEquals(2, encryptedParts.size()); + assertSame(bodyPartOne, encryptedParts.get(0)); + assertSame(bodyPartThree, encryptedParts.get(1)); + } +} diff --git a/k9mail/src/androidTest/java/com/fsck/k9/mailstore/LocalMessageExtractorTest.java b/k9mail/src/androidTest/java/com/fsck/k9/mailstore/LocalMessageExtractorTest.java index 6086118af..d3e7f40d2 100644 --- a/k9mail/src/androidTest/java/com/fsck/k9/mailstore/LocalMessageExtractorTest.java +++ b/k9mail/src/androidTest/java/com/fsck/k9/mailstore/LocalMessageExtractorTest.java @@ -1,6 +1,8 @@ package com.fsck.k9.mailstore; +import java.util.ArrayList; import java.util.Date; +import java.util.List; import java.util.Locale; import java.util.TimeZone; @@ -11,11 +13,14 @@ import com.fsck.k9.activity.K9ActivityCommon; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Message.RecipientType; import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MessageExtractor; import com.fsck.k9.mail.internet.MimeBodyPart; import com.fsck.k9.mail.internet.MimeMessage; import com.fsck.k9.mail.internet.MimeMessageHelper; import com.fsck.k9.mail.internet.MimeMultipart; import com.fsck.k9.mail.internet.TextBody; +import com.fsck.k9.mail.internet.Viewable; import org.junit.Test; import org.junit.runner.RunWith; @@ -38,7 +43,10 @@ public class LocalMessageExtractorTest { MimeMessageHelper.setBody(message, body); // Extract text - ViewableContainer container = extractTextAndAttachments(InstrumentationRegistry.getTargetContext(), message); + List attachments = new ArrayList(); + List viewables = MessageExtractor.getViewables(message, attachments); + ViewableContainer container = extractTextAndAttachments(InstrumentationRegistry.getTargetContext(), + viewables, attachments); String expectedText = bodyText; String expectedHtml = @@ -63,7 +71,10 @@ public class LocalMessageExtractorTest { MimeMessageHelper.setBody(message, body); // Extract text - ViewableContainer container = extractTextAndAttachments(InstrumentationRegistry.getTargetContext(), message); + List attachments = new ArrayList(); + List viewables = MessageExtractor.getViewables(message, attachments); + ViewableContainer container = extractTextAndAttachments(InstrumentationRegistry.getTargetContext(), + viewables, attachments); String expectedText = "K-9 Mail rocks :>"; String expectedHtml = @@ -94,7 +105,10 @@ public class LocalMessageExtractorTest { MimeMessageHelper.setBody(message, multipart); // Extract text - ViewableContainer container = extractTextAndAttachments(InstrumentationRegistry.getTargetContext(), message); + List attachments = new ArrayList(); + List viewables = MessageExtractor.getViewables(message, attachments); + ViewableContainer container = extractTextAndAttachments(InstrumentationRegistry.getTargetContext(), + viewables, attachments); String expectedText = bodyText1 + "\r\n\r\n" + @@ -151,7 +165,10 @@ public class LocalMessageExtractorTest { MimeMessageHelper.setBody(message, multipart); // Extract text - ViewableContainer container = extractTextAndAttachments(InstrumentationRegistry.getTargetContext(), message); + List attachments = new ArrayList(); + List viewables = MessageExtractor.getViewables(message, attachments); + ViewableContainer container = extractTextAndAttachments(InstrumentationRegistry.getTargetContext(), + viewables, attachments); String expectedText = bodyText + diff --git a/k9mail/src/androidTest/java/com/fsck/k9/mailstore/LocalMessageTest.java b/k9mail/src/androidTest/java/com/fsck/k9/mailstore/LocalMessageTest.java deleted file mode 100644 index e37c12da7..000000000 --- a/k9mail/src/androidTest/java/com/fsck/k9/mailstore/LocalMessageTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fsck.k9.mailstore; - - -import android.content.Context; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; - -import com.fsck.k9.Account; -import com.fsck.k9.Preferences; -import com.fsck.k9.mail.internet.MimeBodyPart; -import com.fsck.k9.mail.internet.MimeMultipart; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static junit.framework.Assert.assertEquals; - - -@RunWith(AndroidJUnit4.class) -public class LocalMessageTest { - private LocalMessage message; - private Account account; - private Preferences preferences; - - @Before - public void setUp() throws Exception { - Context targetContext = InstrumentationRegistry.getTargetContext(); - preferences = Preferences.getPreferences(targetContext); - account = preferences.newAccount(); - LocalStore store = LocalStore.getInstance(account, targetContext); - message = new LocalMessage(store, "uid", new LocalFolder(store, "test")); - } - - @After - public void tearDown() throws Exception { - preferences.deleteAccount(account); - } - - @Test - public void testGetDisplayTextWithPlainTextPart() throws Exception { - String textBodyText = "text body"; - - MimeMultipart multipart = new MimeMultipart(); - MimeBodyPart bodyPart1 = new MimeBodyPart(new LocalTextBody(textBodyText, textBodyText), "text/plain"); - multipart.addBodyPart(bodyPart1); - message.setBody(multipart); - assertEquals("text body", message.getTextForDisplay()); - } - - @Test - public void testGetDisplayTextWithHtmlPart() throws Exception { - String htmlBodyText = "html body"; - String textBodyText = "text body"; - - MimeMultipart multipart = new MimeMultipart(); - MimeBodyPart bodyPart1 = new MimeBodyPart(new LocalTextBody(htmlBodyText, htmlBodyText), "text/html"); - MimeBodyPart bodyPart2 = new MimeBodyPart(new LocalTextBody(textBodyText, textBodyText), "text/plain"); - multipart.addBodyPart(bodyPart1); - multipart.addBodyPart(bodyPart2); - message.setBody(multipart); - assertEquals("html body", message.getTextForDisplay()); - } -} diff --git a/k9mail/src/androidTest/java/com/fsck/k9/mailstore/MessageInfoExtractorTest.java b/k9mail/src/androidTest/java/com/fsck/k9/mailstore/MessageInfoExtractorTest.java new file mode 100644 index 000000000..3a7539240 --- /dev/null +++ b/k9mail/src/androidTest/java/com/fsck/k9/mailstore/MessageInfoExtractorTest.java @@ -0,0 +1,141 @@ +package com.fsck.k9.mailstore; + + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.mail.internet.TextBody; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; + + +@RunWith(AndroidJUnit4.class) +public class MessageInfoExtractorTest { + + @Test + public void shouldExtractPreviewFromSinglePlainTextPart() throws MessagingException { + MimeMessage message = new MimeMessage(); + message.addHeader("Content-Type", "text/plain"); + TextBody body = new TextBody("Message text "); + message.setBody(body); + + String preview = new MessageInfoExtractor(getContext(), message).getMessageTextPreview(); + + assertEquals("Message text", preview); + } + + @Test + public void shouldLimitPreviewTo512Characters() throws MessagingException { + MimeMessage message = new MimeMessage(); + message.addHeader("Content-Type", "text/plain"); + TextBody body = new TextBody("10--------20--------30--------40--------50--------" + + "60--------70--------80--------90--------100-------" + + "110-------120-------130-------140-------150-------" + + "160-------170-------180-------190-------200-------" + + "210-------220-------230-------240-------250-------" + + "260-------270-------280-------290-------300-------" + + "310-------320-------330-------340-------350-------" + + "360-------370-------380-------390-------400-------" + + "410-------420-------430-------440-------450-------" + + "460-------470-------480-------490-------500-------" + + "510-------520-------530-------540-------550-------" + + "560-------570-------580-------590-------600-------"); + message.setBody(body); + + String preview = new MessageInfoExtractor(getContext(), message).getMessageTextPreview(); + + assertEquals(512, preview.length()); + assertEquals('…', preview.charAt(511)); + } + + @Test + public void shouldExtractPreviewFromSingleHtmlPart() throws MessagingException { + MimeMessage message = new MimeMessage(); + message.addHeader("Content-Type", "text/html"); + TextBody body = new TextBody("
Message text
"); + message.setBody(body); + + String preview = new MessageInfoExtractor(getContext(), message).getMessageTextPreview(); + + assertEquals("Message text", preview); + } + + @Test + public void shouldExtractPreviewFromMultipartAlternative() throws MessagingException { + MimeMessage message = new MimeMessage(); + message.addHeader("Content-Type", "multipart/alternative"); + MimeMultipart multipart = new MimeMultipart(); + multipart.setSubType("alternative"); + message.setBody(multipart); + + TextBody textBody = new TextBody("text"); + MimeBodyPart textPart = new MimeBodyPart(textBody, "text/plain"); + multipart.addBodyPart(textPart); + + TextBody htmlBody = new TextBody("html"); + MimeBodyPart htmlPart = new MimeBodyPart(htmlBody, "text/html"); + multipart.addBodyPart(htmlPart); + + String preview = new MessageInfoExtractor(getContext(), message).getMessageTextPreview(); + + assertEquals("text", preview); + } + + @Test + public void shouldExtractPreviewFromMultipartMixed() throws MessagingException { + MimeMessage message = new MimeMessage(); + message.addHeader("Content-Type", "multipart/mixed"); + MimeMultipart multipart = new MimeMultipart(); + multipart.setSubType("mixed"); + message.setBody(multipart); + + TextBody textBody = new TextBody("text"); + MimeBodyPart textPart = new MimeBodyPart(textBody, "text/plain"); + multipart.addBodyPart(textPart); + + TextBody htmlBody = new TextBody("html"); + MimeBodyPart htmlPart = new MimeBodyPart(htmlBody, "text/html"); + multipart.addBodyPart(htmlPart); + + String preview = new MessageInfoExtractor(getContext(), message).getMessageTextPreview(); + + assertEquals("text / html", preview); + } + + @Test + public void shouldExtractPreviewFromMultipartMixedWithInnerMesssage() throws MessagingException { + MimeMessage message = new MimeMessage(); + message.addHeader("Content-Type", "multipart/mixed"); + MimeMultipart multipart = new MimeMultipart(); + multipart.setSubType("mixed"); + message.setBody(multipart); + + TextBody textBody = new TextBody("text"); + MimeBodyPart textPart = new MimeBodyPart(textBody, "text/plain"); + multipart.addBodyPart(textPart); + + MimeMessage innerMessage = new MimeMessage(); + innerMessage.addHeader("Content-Type", "text/html"); + innerMessage.addHeader("Subject", "inner message"); + TextBody htmlBody = new TextBody("html"); + innerMessage.setBody(htmlBody); + + MimeBodyPart messagePart = new MimeBodyPart(innerMessage, "message/rfc822"); + multipart.addBodyPart(messagePart); + + String preview = new MessageInfoExtractor(getContext(), message).getMessageTextPreview(); + + assertEquals("text / Includes message titled \"inner message\" containing: html", preview); + } + + private Context getContext() { + return InstrumentationRegistry.getTargetContext(); + } +} diff --git a/k9mail/src/androidTest/java/com/fsck/k9/mailstore/ReconstructMessageFromDatabaseTest.java b/k9mail/src/androidTest/java/com/fsck/k9/mailstore/ReconstructMessageFromDatabaseTest.java new file mode 100644 index 000000000..3b2c272e4 --- /dev/null +++ b/k9mail/src/androidTest/java/com/fsck/k9/mailstore/ReconstructMessageFromDatabaseTest.java @@ -0,0 +1,176 @@ +package com.fsck.k9.mailstore; + + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; + +import android.content.Context; +import android.test.ApplicationTestCase; +import android.test.RenamingDelegatingContext; + +import com.fsck.k9.Account; +import com.fsck.k9.K9; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.FetchProfile; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.BinaryTempFileBody; +import com.fsck.k9.mail.internet.MimeMessage; +import org.apache.james.mime4j.util.MimeUtil; + + +public class ReconstructMessageFromDatabaseTest extends ApplicationTestCase { + + public static final String MESSAGE_SOURCE = "From: from@example.com\r\n" + + "To: to@example.com\r\n" + + "Subject: Test Message \r\n" + + "Date: Thu, 13 Nov 2014 17:09:38 +0100\r\n" + + "Content-Type: multipart/mixed;\r\n" + + " boundary=\"----Boundary\"\r\n" + + "Content-Transfer-Encoding: 8bit\r\n" + + "MIME-Version: 1.0\r\n" + + "\r\n" + + "This is a multipart MIME message.\r\n" + + "------Boundary\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: 8bit\r\n" + + "\r\n" + + "Testing.\r\n" + + "This is a text body with some greek characters.\r\n" + + "αβγδεζηθ\r\n" + + "End of test.\r\n" + + "\r\n" + + "------Boundary\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "\r\n" + + "VGhpcyBpcyBhIHRl\r\n" + + "c3QgbWVzc2FnZQ==\r\n" + + "\r\n" + + "------Boundary--\r\n" + + "Hi, I'm the epilogue"; + + private Account account; + + public ReconstructMessageFromDatabaseTest() { + super(K9.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + + RenamingDelegatingContext context = new RenamingDelegatingContext(getContext(), "db-test-"); + setContext(context); + + BinaryTempFileBody.setTempDirectory(context.getCacheDir()); + + createApplication(); + + createDummyAccount(context); + } + + private void createDummyAccount(Context context) { + account = new DummyAccount(context); + } + + public void testThatByteIdenticalCopyOfMessageIsReconstructed() throws IOException, MessagingException { + + LocalFolder folder = createFolderInDatabase(); + + MimeMessage message = parseMessage(); + + saveMessageToDatabase(folder, message); + + LocalMessage localMessage = readMessageFromDatabase(folder, message); + + String reconstructedMessage = writeMessageToString(localMessage); + + assertEquals(MESSAGE_SOURCE, reconstructedMessage); + } + + public void testAddMissingPart() throws MessagingException, IOException { + LocalFolder folder = createFolderInDatabase(); + + MimeMessage message = new MimeMessage(); + message.addHeader("To", "to@example.com"); + message.addHeader("MIME-Version", "1.0"); + message.addHeader("Content-Type", "text/plain"); + message.setServerExtra("text"); + + saveMessageToDatabase(folder, message); + + LocalMessage localMessage = readMessageFromDatabase(folder, message); + + assertEquals("to@example.com", localMessage.getHeader("To")[0]); + assertEquals("text/plain", localMessage.getMimeType()); + assertEquals("text", localMessage.getServerExtra()); + assertNull(localMessage.getBody()); + + Body body = new BinaryMemoryBody("Test message body".getBytes(), MimeUtil.ENC_7BIT); + localMessage.setBody(body); + folder.addPartToMessage(localMessage, localMessage); + + LocalMessage completeLocalMessage = readMessageFromDatabase(folder, message); + String reconstructedMessage = writeMessageToString(completeLocalMessage); + + assertEquals("To: to@example.com\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "Test message body", + reconstructedMessage); + } + + protected MimeMessage parseMessage() throws IOException, MessagingException { + InputStream messageInputStream = new ByteArrayInputStream(MESSAGE_SOURCE.getBytes()); + try { + return new MimeMessage(messageInputStream, true); + } finally { + messageInputStream.close(); + } + } + + protected LocalFolder createFolderInDatabase() throws MessagingException { + LocalStore localStore = LocalStore.getInstance(account, getApplication()); + LocalFolder inbox = localStore.getFolder("INBOX"); + localStore.createFolders(Collections.singletonList(inbox), 10); + return inbox; + } + + protected void saveMessageToDatabase(LocalFolder folder, MimeMessage message) throws MessagingException { + folder.appendMessages(Collections.singletonList(message)); + } + + protected LocalMessage readMessageFromDatabase(LocalFolder folder, MimeMessage message) throws MessagingException { + LocalMessage localMessage = folder.getMessage(message.getUid()); + + FetchProfile fp = new FetchProfile(); + fp.add(FetchProfile.Item.ENVELOPE); + fp.add(FetchProfile.Item.BODY); + folder.fetch(Collections.singletonList(localMessage), fp, null); + folder.close(); + + return localMessage; + } + + protected String writeMessageToString(LocalMessage localMessage) throws IOException, MessagingException { + ByteArrayOutputStream messageOutputStream = new ByteArrayOutputStream(); + try { + localMessage.writeTo(messageOutputStream); + } finally { + messageOutputStream.close(); + } + + return new String(messageOutputStream.toByteArray()); + } + + static class DummyAccount extends Account { + + protected DummyAccount(Context context) { + super(context); + } + } +} diff --git a/k9mail/src/main/AndroidManifest.xml b/k9mail/src/main/AndroidManifest.xml index 34159eedd..ca890d986 100644 --- a/k9mail/src/main/AndroidManifest.xml +++ b/k9mail/src/main/AndroidManifest.xml @@ -415,5 +415,17 @@ android:authorities="${applicationId}.provider.email" android:exported="false"/> + + + + + + diff --git a/k9mail/src/main/java/com/fsck/k9/Account.java b/k9mail/src/main/java/com/fsck/k9/Account.java index 3ea0d1fcd..73f280ec5 100644 --- a/k9mail/src/main/java/com/fsck/k9/Account.java +++ b/k9mail/src/main/java/com/fsck/k9/Account.java @@ -461,7 +461,8 @@ public class Account implements BaseAccount, StoreConfig { mIsSignatureBeforeQuotedText = prefs.getBoolean(mUuid + ".signatureBeforeQuotedText", false); identities = loadIdentities(prefs); - mCryptoApp = prefs.getString(mUuid + ".cryptoApp", NO_OPENPGP_PROVIDER); + String cryptoApp = prefs.getString(mUuid + ".cryptoApp", NO_OPENPGP_PROVIDER); + setCryptoApp(cryptoApp); mAllowRemoteSearch = prefs.getBoolean(mUuid + ".allowRemoteSearch", false); mRemoteSearchFullText = prefs.getBoolean(mUuid + ".remoteSearchFullText", false); mRemoteSearchNumResults = prefs.getInt(mUuid + ".remoteSearchNumResults", DEFAULT_REMOTE_SEARCH_NUM_RESULTS); @@ -1597,7 +1598,11 @@ public class Account implements BaseAccount, StoreConfig { } public void setCryptoApp(String cryptoApp) { - mCryptoApp = cryptoApp; + if (cryptoApp == null || cryptoApp.equals("apg")) { + mCryptoApp = NO_OPENPGP_PROVIDER; + } else { + mCryptoApp = cryptoApp; + } } public boolean allowRemoteSearch() { @@ -1641,13 +1646,16 @@ public class Account implements BaseAccount, StoreConfig { } public synchronized String getOpenPgpProvider() { - // return null if set to "APG" or "None" - if (getCryptoApp().equals("apg") || getCryptoApp().equals("")) { + if (!isOpenPgpProviderConfigured()) { return null; } return getCryptoApp(); } + public synchronized boolean isOpenPgpProviderConfigured() { + return !NO_OPENPGP_PROVIDER.equals(getCryptoApp()); + } + public synchronized NotificationSetting getNotificationSetting() { return mNotificationSetting; } diff --git a/k9mail/src/main/java/com/fsck/k9/activity/Accounts.java b/k9mail/src/main/java/com/fsck/k9/activity/Accounts.java index 6454ebedf..2fe55a6a1 100644 --- a/k9mail/src/main/java/com/fsck/k9/activity/Accounts.java +++ b/k9mail/src/main/java/com/fsck/k9/activity/Accounts.java @@ -1283,7 +1283,8 @@ public class Accounts extends K9ListActivity implements OnItemClickListener { new String[] {"HtmlCleaner", "http://htmlcleaner.sourceforge.net/"}, new String[] {"Android-PullToRefresh", "https://github.com/chrisbanes/Android-PullToRefresh"}, new String[] {"ckChangeLog", "https://github.com/cketti/ckChangeLog"}, - new String[] {"HoloColorPicker", "https://github.com/LarsWerkman/HoloColorPicker"} + new String[] {"HoloColorPicker", "https://github.com/LarsWerkman/HoloColorPicker"}, + new String[] {"Glide", "https://github.com/bumptech/glide"} }; private void onAbout() { diff --git a/k9mail/src/main/java/com/fsck/k9/activity/MessageCompose.java b/k9mail/src/main/java/com/fsck/k9/activity/MessageCompose.java index 0129b82e1..2bc8a0932 100644 --- a/k9mail/src/main/java/com/fsck/k9/activity/MessageCompose.java +++ b/k9mail/src/main/java/com/fsck/k9/activity/MessageCompose.java @@ -16,7 +16,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.StringTokenizer; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -43,7 +42,6 @@ import android.os.Parcelable; import android.text.TextUtils; import android.text.TextWatcher; import android.text.util.Rfc822Tokenizer; -import android.util.AttributeSet; import android.util.Log; import android.util.TypedValue; import android.view.ContextThemeWrapper; @@ -89,7 +87,7 @@ import com.fsck.k9.crypto.PgpData; import com.fsck.k9.fragment.ProgressDialogFragment; import com.fsck.k9.helper.ContactItem; import com.fsck.k9.helper.Contacts; -import com.fsck.k9.mail.filter.Base64; +import com.fsck.k9.helper.SimpleTextWatcher; import com.fsck.k9.helper.HtmlConverter; import com.fsck.k9.helper.IdentityHelper; import com.fsck.k9.helper.Utility; @@ -102,21 +100,19 @@ import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.Multipart; import com.fsck.k9.mail.Part; import com.fsck.k9.mail.internet.MessageExtractor; -import com.fsck.k9.mail.internet.MimeBodyPart; -import com.fsck.k9.mail.internet.MimeHeader; import com.fsck.k9.mail.internet.MimeMessage; -import com.fsck.k9.mail.internet.MimeMessageHelper; -import com.fsck.k9.mail.internet.MimeMultipart; import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mail.internet.TextBody; -import com.fsck.k9.mailstore.LocalAttachmentBody; import com.fsck.k9.mailstore.LocalMessage; -import com.fsck.k9.mailstore.TempFileBody; -import com.fsck.k9.mailstore.TempFileMessageBody; +import com.fsck.k9.message.IdentityField; +import com.fsck.k9.message.IdentityHeaderParser; +import com.fsck.k9.message.InsertableHtmlContent; +import com.fsck.k9.message.MessageBuilder; +import com.fsck.k9.message.QuotedTextMode; +import com.fsck.k9.message.SimpleMessageFormat; +import com.fsck.k9.ui.EolConvertingEditText; import com.fsck.k9.view.MessageWebView; -import org.apache.james.mime4j.codec.EncoderUtil; -import org.apache.james.mime4j.util.MimeUtil; import org.htmlcleaner.CleanerProperties; import org.htmlcleaner.HtmlCleaner; import org.htmlcleaner.SimpleHtmlSerializer; @@ -264,12 +260,6 @@ public class MessageCompose extends K9Activity implements OnClickListener, */ private Action mAction; - private enum QuotedTextMode { - NONE, - SHOW, - HIDE - } - private boolean mReadReceipt = false; private QuotedTextMode mQuotedTextMode = QuotedTextMode.NONE; @@ -298,20 +288,14 @@ public class MessageCompose extends K9Activity implements OnClickListener, private Button mQuotedTextShow; private View mQuotedTextBar; private ImageButton mQuotedTextEdit; - private ImageButton mQuotedTextDelete; private EolConvertingEditText mQuotedText; private MessageWebView mQuotedHTML; private InsertableHtmlContent mQuotedHtmlContent; // Container for HTML reply as it's being built. - private View mEncryptLayout; private CheckBox mCryptoSignatureCheckbox; private CheckBox mEncryptCheckbox; private TextView mCryptoSignatureUserId; private TextView mCryptoSignatureUserIdRest; - private ImageButton mAddToFromContacts; - private ImageButton mAddCcFromContacts; - private ImageButton mAddBccFromContacts; - private PgpData mPgpData = null; private String mOpenPgpProvider; private OpenPgpServiceConnection mOpenPgpServiceConnection; @@ -322,11 +306,6 @@ public class MessageCompose extends K9Activity implements OnClickListener, private boolean mSourceProcessed = false; - enum SimpleMessageFormat { - TEXT, - HTML - } - /** * The currently used message format. * @@ -410,8 +389,6 @@ public class MessageCompose extends K9Activity implements OnClickListener, }; private Listener mListener = new Listener(); - private EmailAddressAdapter mAddressAdapter; - private Validator mAddressValidator; private FontSizes mFontSizes = K9.getFontSizes(); private ContextThemeWrapper mThemeContext; @@ -462,7 +439,6 @@ public class MessageCompose extends K9Activity implements OnClickListener, * Compose a new message as a reply to the given message. If replyAll is true the function * is reply all instead of simply reply. * @param context - * @param account * @param message * @param replyAll * @param messageBody optional, for decrypted messages, null if it should be grabbed from the given message @@ -522,11 +498,10 @@ public class MessageCompose extends K9Activity implements OnClickListener, // theme the whole content according to the theme (except the action bar) mThemeContext = new ContextThemeWrapper(this, K9.getK9ThemeResourceId(K9.getK9ComposerTheme())); - View v = ((LayoutInflater) mThemeContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE)). - inflate(R.layout.message_compose, null); + View v = LayoutInflater.from(mThemeContext).inflate(R.layout.message_compose, null); TypedValue outValue = new TypedValue(); // background color needs to be forced - mThemeContext.getTheme().resolveAttribute(R.attr.messageViewHeaderBackgroundColor, outValue, true); + mThemeContext.getTheme().resolveAttribute(R.attr.messageViewBackgroundColor, outValue, true); v.setBackgroundColor(outValue.data); setContentView(v); } else { @@ -566,8 +541,8 @@ public class MessageCompose extends K9Activity implements OnClickListener, mContacts = Contacts.getInstance(MessageCompose.this); - mAddressAdapter = new EmailAddressAdapter(mThemeContext); - mAddressValidator = new EmailAddressValidator(); + EmailAddressAdapter mAddressAdapter = new EmailAddressAdapter(mThemeContext); + Validator mAddressValidator = new EmailAddressValidator(); mChooseIdentityButton = (Button) findViewById(R.id.identity); mChooseIdentityButton.setOnClickListener(this); @@ -583,9 +558,10 @@ public class MessageCompose extends K9Activity implements OnClickListener, mSubjectView = (EditText) findViewById(R.id.subject); mSubjectView.getInputExtras(true).putBoolean("allowEmoji", true); - mAddToFromContacts = (ImageButton) findViewById(R.id.add_to); - mAddCcFromContacts = (ImageButton) findViewById(R.id.add_cc); - mAddBccFromContacts = (ImageButton) findViewById(R.id.add_bcc); + ImageButton mAddToFromContacts = (ImageButton) findViewById(R.id.add_to); + ImageButton mAddCcFromContacts = (ImageButton) findViewById(R.id.add_cc); + ImageButton mAddBccFromContacts = (ImageButton) findViewById(R.id.add_bcc); + mCcWrapper = (LinearLayout) findViewById(R.id.cc_wrapper); mBccWrapper = (LinearLayout) findViewById(R.id.bcc_wrapper); @@ -603,7 +579,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, mQuotedTextShow = (Button)findViewById(R.id.quoted_text_show); mQuotedTextBar = findViewById(R.id.quoted_text_bar); mQuotedTextEdit = (ImageButton)findViewById(R.id.quoted_text_edit); - mQuotedTextDelete = (ImageButton)findViewById(R.id.quoted_text_delete); + ImageButton mQuotedTextDelete = (ImageButton) findViewById(R.id.quoted_text_delete); mQuotedText = (EolConvertingEditText)findViewById(R.id.quoted_text); mQuotedText.getInputExtras(true).putBoolean("allowEmoji", true); @@ -618,81 +594,34 @@ public class MessageCompose extends K9Activity implements OnClickListener, } }); - TextWatcher watcher = new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int before, int after) { - /* do nothing */ - } - + TextWatcher draftNeedsChangingTextWatcher = new SimpleTextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { mDraftNeedsSaving = true; } - - @Override - public void afterTextChanged(android.text.Editable s) { /* do nothing */ } }; - // For watching changes to the To:, Cc:, and Bcc: fields for auto-encryption on a matching - // address. - TextWatcher recipientWatcher = new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int before, int after) { - /* do nothing */ - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - mDraftNeedsSaving = true; - } - - @Override - public void afterTextChanged(android.text.Editable s) { - /* do nothing */ - } - }; - - TextWatcher sigwatcher = new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int before, int after) { - /* do nothing */ - } - + TextWatcher signTextWatcher = new SimpleTextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { mDraftNeedsSaving = true; mSignatureChanged = true; } - - @Override - public void afterTextChanged(android.text.Editable s) { /* do nothing */ } }; - mToView.addTextChangedListener(recipientWatcher); - mCcView.addTextChangedListener(recipientWatcher); - mBccView.addTextChangedListener(recipientWatcher); - mSubjectView.addTextChangedListener(watcher); + mToView.addTextChangedListener(draftNeedsChangingTextWatcher); + mCcView.addTextChangedListener(draftNeedsChangingTextWatcher); + mBccView.addTextChangedListener(draftNeedsChangingTextWatcher); + mSubjectView.addTextChangedListener(draftNeedsChangingTextWatcher); - mMessageContentView.addTextChangedListener(watcher); - mQuotedText.addTextChangedListener(watcher); + mMessageContentView.addTextChangedListener(draftNeedsChangingTextWatcher); + mQuotedText.addTextChangedListener(draftNeedsChangingTextWatcher); - /* Yes, there really are poeple who ship versions of android without a contact picker */ + /* Yes, there really are people who ship versions of android without a contact picker */ if (mContacts.hasContactPicker()) { - mAddToFromContacts.setOnClickListener(new OnClickListener() { - @Override public void onClick(View v) { - doLaunchContactPicker(CONTACT_PICKER_TO); - } - }); - mAddCcFromContacts.setOnClickListener(new OnClickListener() { - @Override public void onClick(View v) { - doLaunchContactPicker(CONTACT_PICKER_CC); - } - }); - mAddBccFromContacts.setOnClickListener(new OnClickListener() { - @Override public void onClick(View v) { - doLaunchContactPicker(CONTACT_PICKER_BCC); - } - }); + mAddToFromContacts.setOnClickListener(new DoLaunchOnClickListener(CONTACT_PICKER_TO)); + mAddCcFromContacts.setOnClickListener(new DoLaunchOnClickListener(CONTACT_PICKER_CC)); + mAddBccFromContacts.setOnClickListener(new DoLaunchOnClickListener(CONTACT_PICKER_BCC)); } else { mAddToFromContacts.setVisibility(View.GONE); mAddCcFromContacts.setVisibility(View.GONE); @@ -762,7 +691,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, mSignatureView = lowerSignature; upperSignature.setVisibility(View.GONE); } - mSignatureView.addTextChangedListener(sigwatcher); + mSignatureView.addTextChangedListener(signTextWatcher); if (!mIdentity.getSignatureUse()) { mSignatureView.setVisibility(View.GONE); @@ -814,34 +743,31 @@ public class MessageCompose extends K9Activity implements OnClickListener, mMessageReference.flag = Flag.FORWARDED; } - mEncryptLayout = findViewById(R.id.layout_encrypt); - mCryptoSignatureCheckbox = (CheckBox)findViewById(R.id.cb_crypto_signature); - mCryptoSignatureCheckbox.setOnCheckedChangeListener(new OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - updateMessageFormat(); - } - }); - mCryptoSignatureUserId = (TextView)findViewById(R.id.userId); - mCryptoSignatureUserIdRest = (TextView)findViewById(R.id.userIdRest); - mEncryptCheckbox = (CheckBox)findViewById(R.id.cb_encrypt); - mEncryptCheckbox.setOnCheckedChangeListener(new OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - updateMessageFormat(); - } - }); - - if (mSourceMessageBody != null) { - // mSourceMessageBody is set to something when replying to and forwarding decrypted - // messages, so the sender probably wants the message to be encrypted. - mEncryptCheckbox.setChecked(true); - } + final View mEncryptLayout = findViewById(R.id.layout_encrypt); initializeCrypto(); mOpenPgpProvider = mAccount.getOpenPgpProvider(); - if (mOpenPgpProvider != null) { + if (isCryptoProviderEnabled()) { + mCryptoSignatureCheckbox = (CheckBox)findViewById(R.id.cb_crypto_signature); + final OnCheckedChangeListener updateListener = new OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + updateMessageFormat(); + } + }; + mCryptoSignatureCheckbox.setOnCheckedChangeListener(updateListener); + mCryptoSignatureUserId = (TextView)findViewById(R.id.userId); + mCryptoSignatureUserIdRest = (TextView)findViewById(R.id.userIdRest); + mEncryptCheckbox = (CheckBox)findViewById(R.id.cb_encrypt); + mEncryptCheckbox.setOnCheckedChangeListener(updateListener); + + if (mSourceMessageBody != null) { + // mSourceMessageBody is set to something when replying to and forwarding decrypted + // messages, so the sender probably wants the message to be encrypted. + mEncryptCheckbox.setChecked(true); + } + // New OpenPGP Provider API // bind to service @@ -1037,6 +963,10 @@ public class MessageCompose extends K9Activity implements OnClickListener, * Fill the encrypt layout with the latest data about signature key and encryption keys. */ public void updateEncryptLayout() { + if (!isCryptoProviderEnabled()) { + return; + } + if (!mPgpData.hasSignatureKey()) { mCryptoSignatureCheckbox.setText(R.string.btn_crypto_sign); mCryptoSignatureCheckbox.setChecked(false); @@ -1093,16 +1023,10 @@ public class MessageCompose extends K9Activity implements OnClickListener, @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); - 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); - } outState.putInt(STATE_KEY_NUM_ATTACHMENTS_LOADING, mNumAttachmentsLoading); outState.putString(STATE_KEY_WAITING_FOR_ATTACHMENTS, mWaitingForAttachments.name()); - outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, attachments); + outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, createAttachmentList()); outState.putBoolean(STATE_KEY_CC_SHOWN, mCcWrapper.getVisibility() == View.VISIBLE); outState.putBoolean(STATE_KEY_BCC_SHOWN, mBccWrapper.getVisibility() == View.VISIBLE); outState.putSerializable(STATE_KEY_QUOTED_TEXT_MODE, mQuotedTextMode); @@ -1249,435 +1173,55 @@ public class MessageCompose extends K9Activity implements OnClickListener, return Address.parseUnencoded(addresses.trim()); } - /* - * Build the Body that will contain the text of the message. We'll decide where to - * include it later. Draft messages are treated somewhat differently in that signatures are not - * appended and HTML separators between composed text and quoted text are not added. - * @param isDraft If we should build a message that will be saved as a draft (as opposed to sent). - */ private TextBody buildText(boolean isDraft) { - return buildText(isDraft, mMessageFormat); + return createMessageBuilder(isDraft).buildText(); } - /** - * Build the {@link Body} that will contain the text of the message. - * - *

- * Draft messages are treated somewhat differently in that signatures are not appended and HTML - * separators between composed text and quoted text are not added. - *

- * - * @param isDraft - * If {@code true} we build a message that will be saved as a draft (as opposed to - * sent). - * @param messageFormat - * Specifies what type of message to build ({@code text/plain} vs. {@code text/html}). - * - * @return {@link TextBody} instance that contains the entered text and possibly the quoted - * original message. - */ - private TextBody buildText(boolean isDraft, SimpleMessageFormat messageFormat) { - String messageText = mMessageContentView.getCharacters(); - - TextBodyBuilder textBodyBuilder = new TextBodyBuilder(messageText); - - /* - * Find out if we need to include the original message as quoted text. - * - * We include the quoted text in the body if the user didn't choose to - * hide it. We always include the quoted text when we're saving a draft. - * That's so the user is able to "un-hide" the quoted text if (s)he - * opens a saved draft. - */ - boolean includeQuotedText = (isDraft || mQuotedTextMode == QuotedTextMode.SHOW); - boolean isReplyAfterQuote = (mQuoteStyle == QuoteStyle.PREFIX && mAccount.isReplyAfterQuote()); - - textBodyBuilder.setIncludeQuotedText(false); - if (includeQuotedText) { - if (messageFormat == SimpleMessageFormat.HTML && mQuotedHtmlContent != null) { - textBodyBuilder.setIncludeQuotedText(true); - textBodyBuilder.setQuotedTextHtml(mQuotedHtmlContent); - textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote); - } - - String quotedText = mQuotedText.getCharacters(); - if (messageFormat == SimpleMessageFormat.TEXT && quotedText.length() > 0) { - textBodyBuilder.setIncludeQuotedText(true); - textBodyBuilder.setQuotedText(quotedText); - textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote); - } - } - - textBodyBuilder.setInsertSeparator(!isDraft); - - boolean useSignature = (!isDraft && mIdentity.getSignatureUse()); - if (useSignature) { - textBodyBuilder.setAppendSignature(true); - textBodyBuilder.setSignature(mSignatureView.getCharacters()); - textBodyBuilder.setSignatureBeforeQuotedText(mAccount.isSignatureBeforeQuotedText()); - } else { - textBodyBuilder.setAppendSignature(false); - } - - TextBody body; - if (messageFormat == SimpleMessageFormat.HTML) { - body = textBodyBuilder.buildTextHtml(); - } else { - body = textBodyBuilder.buildTextPlain(); - } - return body; - } - /** - * Build the final message to be sent (or saved). If there is another message quoted in this one, it will be baked - * into the final message here. - * @param isDraft Indicates if this message is a draft or not. Drafts do not have signatures - * appended and have some extra metadata baked into their header for use during thawing. - * @return Message to be sent. - * @throws MessagingException - */ - private MimeMessage createMessage(boolean isDraft) throws MessagingException { - MimeMessage message = new MimeMessage(); - message.addSentDate(new Date(), K9.hideTimeZone()); - Address from = new Address(mIdentity.getEmail(), mIdentity.getName()); - message.setFrom(from); - message.setRecipients(RecipientType.TO, getAddresses(mToView)); - message.setRecipients(RecipientType.CC, getAddresses(mCcView)); - message.setRecipients(RecipientType.BCC, getAddresses(mBccView)); - message.setSubject(mSubjectView.getText().toString()); - if (mReadReceipt) { - message.setHeader("Disposition-Notification-To", from.toEncodedString()); - message.setHeader("X-Confirm-Reading-To", from.toEncodedString()); - message.setHeader("Return-Receipt-To", from.toEncodedString()); - } - - if (!K9.hideUserAgent()) { - message.setHeader("User-Agent", getString(R.string.message_header_mua)); - } - - final String replyTo = mIdentity.getReplyTo(); - if (replyTo != null) { - message.setReplyTo(new Address[] { new Address(replyTo) }); - } - - if (mInReplyTo != null) { - message.setInReplyTo(mInReplyTo); - } - - if (mReferences != null) { - message.setReferences(mReferences); - } - - // Build the body. - // TODO FIXME - body can be either an HTML or Text part, depending on whether we're in - // HTML mode or not. Should probably fix this so we don't mix up html and text parts. - TextBody body = null; - if (mPgpData.getEncryptedData() != null) { - String text = mPgpData.getEncryptedData(); - body = new TextBody(text); - } else { - body = buildText(isDraft); - } - - // text/plain part when mMessageFormat == MessageFormat.HTML - TextBody bodyPlain = null; - - final boolean hasAttachments = mAttachments.getChildCount() > 0; - - if (mMessageFormat == SimpleMessageFormat.HTML) { - // HTML message (with alternative text part) - - // This is the compiled MIME part for an HTML message. - MimeMultipart composedMimeMessage = new MimeMultipart(); - composedMimeMessage.setSubType("alternative"); // Let the receiver select either the text or the HTML part. - composedMimeMessage.addBodyPart(new MimeBodyPart(body, "text/html")); - bodyPlain = buildText(isDraft, SimpleMessageFormat.TEXT); - composedMimeMessage.addBodyPart(new MimeBodyPart(bodyPlain, "text/plain")); - - if (hasAttachments) { - // If we're HTML and have attachments, we have a MimeMultipart container to hold the - // whole message (mp here), of which one part is a MimeMultipart container - // (composedMimeMessage) with the user's composed messages, and subsequent parts for - // the attachments. - MimeMultipart mp = new MimeMultipart(); - mp.addBodyPart(new MimeBodyPart(composedMimeMessage)); - addAttachmentsToMessage(mp); - MimeMessageHelper.setBody(message, mp); - } else { - // If no attachments, our multipart/alternative part is the only one we need. - MimeMessageHelper.setBody(message, composedMimeMessage); - } - } else if (mMessageFormat == SimpleMessageFormat.TEXT) { - // Text-only message. - if (hasAttachments) { - MimeMultipart mp = new MimeMultipart(); - mp.addBodyPart(new MimeBodyPart(body, "text/plain")); - addAttachmentsToMessage(mp); - MimeMessageHelper.setBody(message, mp); - } else { - // No attachments to include, just stick the text body in the message and call it good. - MimeMessageHelper.setBody(message, body); - } - } - - // If this is a draft, add metadata for thawing. - if (isDraft) { - // Add the identity to the message. - message.addHeader(K9.IDENTITY_HEADER, buildIdentityHeader(body, bodyPlain)); - } - - return message; + private MimeMessage createDraftMessage() throws MessagingException { + return createMessageBuilder(true).build(); } - /** - * Add attachments as parts into a MimeMultipart container. - * @param mp MimeMultipart container in which to insert parts. - * @throws MessagingException - */ - private void addAttachmentsToMessage(final MimeMultipart mp) throws MessagingException { - Body body; + private MimeMessage createMessage() throws MessagingException { + return createMessageBuilder(false).build(); + } + + private MessageBuilder createMessageBuilder(boolean isDraft) { + return new MessageBuilder(getApplicationContext()) + .setSubject(mSubjectView.getText().toString()) + .setTo(getAddresses(mToView)) + .setCc(getAddresses(mCcView)) + .setBcc(getAddresses(mBccView)) + .setInReplyTo(mInReplyTo) + .setReferences(mReferences) + .setRequestReadReceipt(mReadReceipt) + .setIdentity(mIdentity) + .setMessageFormat(mMessageFormat) + .setText(mMessageContentView.getCharacters()) + .setPgpData(mPgpData) + .setAttachments(createAttachmentList()) + .setSignature(mSignatureView.getCharacters()) + .setQuoteStyle(mQuoteStyle) + .setQuotedTextMode(mQuotedTextMode) + .setQuotedText(mQuotedText.getCharacters()) + .setQuotedHtmlContent(mQuotedHtmlContent) + .setReplyAfterQuote(mAccount.isReplyAfterQuote()) + .setSignatureBeforeQuotedText(mAccount.isSignatureBeforeQuotedText()) + .setIdentityChanged(mIdentityChanged) + .setSignatureChanged(mSignatureChanged) + .setCursorPosition(mMessageContentView.getSelectionStart()) + .setMessageReference(mMessageReference) + .setDraft(isDraft); + } + + private ArrayList createAttachmentList() { + ArrayList attachments = new ArrayList(); for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { - Attachment attachment = (Attachment) mAttachments.getChildAt(i).getTag(); - - if (attachment.state != Attachment.LoadingState.COMPLETE) { - continue; - } - - String contentType = attachment.contentType; - if (MimeUtil.isMessage(contentType)) { - body = new TempFileMessageBody(attachment.filename); - } else { - body = new TempFileBody(attachment.filename); - } - MimeBodyPart bp = new MimeBodyPart(body); - - /* - * Correctly encode the filename here. Otherwise the whole - * header value (all parameters at once) will be encoded by - * MimeHeader.writeTo(). - */ - bp.addHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\r\n name=\"%s\"", - contentType, - EncoderUtil.encodeIfNecessary(attachment.name, - EncoderUtil.Usage.WORD_ENTITY, 7))); - - bp.setEncoding(MimeUtility.getEncodingforType(contentType)); - - /* - * TODO: Oh the joys of MIME... - * - * From RFC 2183 (The Content-Disposition Header Field): - * "Parameter values longer than 78 characters, or which - * contain non-ASCII characters, MUST be encoded as specified - * in [RFC 2184]." - * - * Example: - * - * Content-Type: application/x-stuff - * title*1*=us-ascii'en'This%20is%20even%20more%20 - * title*2*=%2A%2A%2Afun%2A%2A%2A%20 - * title*3="isn't it!" - */ - bp.addHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, String.format(Locale.US, - "attachment;\r\n filename=\"%s\";\r\n size=%d", - attachment.name, attachment.size)); - - mp.addBodyPart(bp); - } - } - - // FYI, there's nothing in the code that requires these variables to one letter. They're one - // letter simply to save space. This name sucks. It's too similar to Account.Identity. - private enum IdentityField { - LENGTH("l"), - OFFSET("o"), - FOOTER_OFFSET("fo"), - PLAIN_LENGTH("pl"), - PLAIN_OFFSET("po"), - MESSAGE_FORMAT("f"), - MESSAGE_READ_RECEIPT("r"), - SIGNATURE("s"), - NAME("n"), - EMAIL("e"), - // TODO - store a reference to the message being replied so we can mark it at the time of send. - ORIGINAL_MESSAGE("m"), - CURSOR_POSITION("p"), // Where in the message your cursor was when you saved. - QUOTED_TEXT_MODE("q"), - QUOTE_STYLE("qs"); - - private final String value; - - IdentityField(String value) { - this.value = value; + View view = mAttachments.getChildAt(i); + Attachment attachment = (Attachment) view.getTag(); + attachments.add(attachment); } - public String value() { - return value; - } - - /** - * Get the list of IdentityFields that should be integer values. - * - *

- * These values are sanity checked for integer-ness during decoding. - *

- * - * @return The list of integer {@link IdentityField}s. - */ - public static IdentityField[] getIntegerFields() { - return new IdentityField[] { LENGTH, OFFSET, FOOTER_OFFSET, PLAIN_LENGTH, PLAIN_OFFSET }; - } - } - - // Version identifier for "new style" identity. ! is an impossible value in base64 encoding, so we - // use that to determine which version we're in. - private static final String IDENTITY_VERSION_1 = "!"; - - /** - * Build the identity header string. This string contains metadata about a draft message to be - * used upon loading a draft for composition. This should be generated at the time of saving a - * draft.
- *
- * This is a URL-encoded key/value pair string. The list of possible values are in {@link IdentityField}. - * @param body {@link TextBody} to analyze for body length and offset. - * @param bodyPlain {@link TextBody} to analyze for body length and offset. May be null. - * @return Identity string. - */ - private String buildIdentityHeader(final TextBody body, final TextBody bodyPlain) { - Uri.Builder uri = new Uri.Builder(); - if (body.getComposedMessageLength() != null && body.getComposedMessageOffset() != null) { - // See if the message body length is already in the TextBody. - uri.appendQueryParameter(IdentityField.LENGTH.value(), body.getComposedMessageLength().toString()); - uri.appendQueryParameter(IdentityField.OFFSET.value(), body.getComposedMessageOffset().toString()); - } else { - // If not, calculate it now. - uri.appendQueryParameter(IdentityField.LENGTH.value(), Integer.toString(body.getText().length())); - uri.appendQueryParameter(IdentityField.OFFSET.value(), Integer.toString(0)); - } - if (mQuotedHtmlContent != null) { - uri.appendQueryParameter(IdentityField.FOOTER_OFFSET.value(), - Integer.toString(mQuotedHtmlContent.getFooterInsertionPoint())); - } - if (bodyPlain != null) { - if (bodyPlain.getComposedMessageLength() != null && bodyPlain.getComposedMessageOffset() != null) { - // See if the message body length is already in the TextBody. - uri.appendQueryParameter(IdentityField.PLAIN_LENGTH.value(), bodyPlain.getComposedMessageLength().toString()); - uri.appendQueryParameter(IdentityField.PLAIN_OFFSET.value(), bodyPlain.getComposedMessageOffset().toString()); - } else { - // If not, calculate it now. - uri.appendQueryParameter(IdentityField.PLAIN_LENGTH.value(), Integer.toString(body.getText().length())); - uri.appendQueryParameter(IdentityField.PLAIN_OFFSET.value(), Integer.toString(0)); - } - } - // Save the quote style (useful for forwards). - uri.appendQueryParameter(IdentityField.QUOTE_STYLE.value(), mQuoteStyle.name()); - - // Save the message format for this offset. - uri.appendQueryParameter(IdentityField.MESSAGE_FORMAT.value(), mMessageFormat.name()); - - // If we're not using the standard identity of signature, append it on to the identity blob. - if (mIdentity.getSignatureUse() && mSignatureChanged) { - uri.appendQueryParameter(IdentityField.SIGNATURE.value(), mSignatureView.getCharacters()); - } - - if (mIdentityChanged) { - uri.appendQueryParameter(IdentityField.NAME.value(), mIdentity.getName()); - uri.appendQueryParameter(IdentityField.EMAIL.value(), mIdentity.getEmail()); - } - - if (mMessageReference != null) { - uri.appendQueryParameter(IdentityField.ORIGINAL_MESSAGE.value(), mMessageReference.toIdentityString()); - } - - uri.appendQueryParameter(IdentityField.CURSOR_POSITION.value(), Integer.toString(mMessageContentView.getSelectionStart())); - - uri.appendQueryParameter(IdentityField.QUOTED_TEXT_MODE.value(), mQuotedTextMode.name()); - - String k9identity = IDENTITY_VERSION_1 + uri.build().getEncodedQuery(); - - if (K9.DEBUG) { - Log.d(K9.LOG_TAG, "Generated identity: " + k9identity); - } - - return k9identity; - } - - /** - * Parse an identity string. Handles both legacy and new (!) style identities. - * - * @param identityString - * The encoded identity string that was saved in a drafts header. - * - * @return A map containing the value for each {@link IdentityField} in the identity string. - */ - private Map parseIdentityHeader(final String identityString) { - Map identity = new HashMap(); - - if (K9.DEBUG) { - Log.d(K9.LOG_TAG, "Decoding identity: " + identityString); - } - - if (identityString == null || identityString.length() < 1) { - return identity; - } - - // Check to see if this is a "next gen" identity. - if (identityString.charAt(0) == IDENTITY_VERSION_1.charAt(0) && identityString.length() > 2) { - Uri.Builder builder = new Uri.Builder(); - builder.encodedQuery(identityString.substring(1)); // Need to cut off the ! at the beginning. - Uri uri = builder.build(); - for (IdentityField key : IdentityField.values()) { - String value = uri.getQueryParameter(key.value()); - if (value != null) { - identity.put(key, value); - } - } - - if (K9.DEBUG) { - Log.d(K9.LOG_TAG, "Decoded identity: " + identity.toString()); - } - - // Sanity check our Integers so that recipients of this result don't have to. - for (IdentityField key : IdentityField.getIntegerFields()) { - if (identity.get(key) != null) { - try { - Integer.parseInt(identity.get(key)); - } catch (NumberFormatException e) { - Log.e(K9.LOG_TAG, "Invalid " + key.name() + " field in identity: " + identity.get(key)); - } - } - } - } else { - // Legacy identity - - if (K9.DEBUG) { - Log.d(K9.LOG_TAG, "Got a saved legacy identity: " + identityString); - } - StringTokenizer tokenizer = new StringTokenizer(identityString, ":", false); - - // First item is the body length. We use this to separate the composed reply from the quoted text. - if (tokenizer.hasMoreTokens()) { - String bodyLengthS = Base64.decode(tokenizer.nextToken()); - try { - identity.put(IdentityField.LENGTH, Integer.valueOf(bodyLengthS).toString()); - } catch (Exception e) { - Log.e(K9.LOG_TAG, "Unable to parse bodyLength '" + bodyLengthS + "'"); - } - } - if (tokenizer.hasMoreTokens()) { - identity.put(IdentityField.SIGNATURE, Base64.decode(tokenizer.nextToken())); - } - if (tokenizer.hasMoreTokens()) { - identity.put(IdentityField.NAME, Base64.decode(tokenizer.nextToken())); - } - if (tokenizer.hasMoreTokens()) { - identity.put(IdentityField.EMAIL, Base64.decode(tokenizer.nextToken())); - } - if (tokenizer.hasMoreTokens()) { - identity.put(IdentityField.QUOTED_TEXT_MODE, Base64.decode(tokenizer.nextToken())); - } - } - - return identity; + return attachments; } private void sendMessage() { @@ -1690,7 +1234,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, private void saveIfNeeded() { if (!mDraftNeedsSaving || mPreventDraftSaving || mPgpData.hasEncryptionKeys() || - mEncryptCheckbox.isChecked() || !mAccount.hasDraftsFolder()) { + shouldEncrypt() || !mAccount.hasDraftsFolder()) { return; } @@ -1734,7 +1278,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, } private void performSend() { - if (mOpenPgpProvider != null) { + if (isCryptoProviderEnabled()) { // OpenPGP Provider API // If not already encrypted but user wants to encrypt... @@ -1867,7 +1411,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, Log.e(K9.LOG_TAG, "OpenPGP Error Message:" + error.getMessage()); Toast.makeText(MessageCompose.this, - getString(R.string.openpgp_error) + " " + error.getMessage(), + getString(R.string.openpgp_error, error.getMessage()), Toast.LENGTH_LONG).show(); } }); @@ -1946,7 +1490,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, */ @SuppressLint("InlinedApi") private void onAddAttachment2(final String mime_type) { - if (mAccount.getOpenPgpProvider() != null) { + if (isCryptoProviderEnabled()) { Toast.makeText(this, R.string.attachment_encryption_unsupported, Toast.LENGTH_LONG).show(); } Intent i = new Intent(Intent.ACTION_GET_CONTENT); @@ -2235,11 +1779,6 @@ public class MessageCompose extends K9Activity implements OnClickListener, } } - public void doLaunchContactPicker(int resultId) { - mIgnoreOnPause = true; - startActivityForResult(mContacts.contactPickerIntent(), resultId); - } - private void onAccountChosen(Account account, Identity identity) { if (!mAccount.equals(account)) { if (K9.DEBUG) { @@ -2411,7 +1950,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, onSend(); break; case R.id.save: - if (mEncryptCheckbox.isChecked()) { + if (shouldEncrypt()) { showDialog(DIALOG_REFUSE_TO_SAVE_DRAFT_MARKED_ENCRYPTED); } else { onSave(); @@ -2461,7 +2000,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, @Override public void onBackPressed() { if (mDraftNeedsSaving) { - if (mEncryptCheckbox.isChecked()) { + if (shouldEncrypt()) { showDialog(DIALOG_REFUSE_TO_SAVE_DRAFT_MARKED_ENCRYPTED); } else if (!mAccount.hasDraftsFolder()) { showDialog(DIALOG_CONFIRM_DISCARD_ON_BACK); @@ -2622,17 +2161,19 @@ public class MessageCompose extends K9Activity implements OnClickListener, String name = MimeUtility.getHeaderParameter(contentType, "name"); if (name != null) { Body body = part.getBody(); - if (body instanceof LocalAttachmentBody) { - final Uri uri = ((LocalAttachmentBody) body).getContentUri(); - mHandler.post(new Runnable() { - @Override - public void run() { - addAttachment(uri); - } - }); - } else { - return false; - } + //FIXME +// if (body instanceof LocalAttachmentBody) { +// final Uri uri = ((LocalAttachmentBody) body).getContentUri(); +// mHandler.post(new Runnable() { +// @Override +// public void run() { +// addAttachment(uri); +// } +// }); +// } else { +// return false; +// } + return false; } return true; } @@ -2841,7 +2382,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, // See buildIdentityHeader(TextBody) for a detailed description of the composition of this blob. Map k9identity = new HashMap(); if (message.getHeader(K9.IDENTITY_HEADER) != null && message.getHeader(K9.IDENTITY_HEADER).length > 0 && message.getHeader(K9.IDENTITY_HEADER)[0] != null) { - k9identity = parseIdentityHeader(message.getHeader(K9.IDENTITY_HEADER)[0]); + k9identity = IdentityHeaderParser.parse(message.getHeader(K9.IDENTITY_HEADER)[0]); } Identity newIdentity = new Identity(); @@ -3400,7 +2941,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, } @Override - public void loadMessageForViewFinished(Account account, String folder, String uid, Message message) { + public void loadMessageForViewFinished(Account account, String folder, String uid, LocalMessage message) { if ((mMessageReference == null) || !mMessageReference.uid.equals(uid)) { return; } @@ -3554,7 +3095,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, */ MimeMessage message; try { - message = createMessage(false); // isDraft = true + message = createMessage(); } catch (MessagingException me) { Log.e(K9.LOG_TAG, "Failed to create new message for send or save.", me); throw new RuntimeException("Failed to create a new message for send or save.", me); @@ -3587,7 +3128,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, */ MimeMessage message; try { - message = createMessage(true); // isDraft = true + message = createDraftMessage(); } catch (MessagingException me) { Log.e(K9.LOG_TAG, "Failed to create new message for send or save.", me); throw new RuntimeException("Failed to create a new message for send or save.", me); @@ -3906,7 +3447,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, // Right now we send a text/plain-only message when the quoted text was edited, no // matter what the user selected for the message format. messageFormat = SimpleMessageFormat.TEXT; - } else if (mEncryptCheckbox.isChecked() || mCryptoSignatureCheckbox.isChecked()) { + } else if (shouldEncrypt() || shouldSign()) { // Right now we only support PGP inline which doesn't play well with HTML. So force // plain text in those cases. messageFormat = SimpleMessageFormat.TEXT; @@ -3952,34 +3493,30 @@ public class MessageCompose extends K9Activity implements OnClickListener, } } - /** - * An {@link EditText} extension with methods that convert line endings from - * {@code \r\n} to {@code \n} and back again when setting and getting text. - * - */ - public static class EolConvertingEditText extends EditText { + private boolean isCryptoProviderEnabled() { + return mOpenPgpProvider != null; + } - public EolConvertingEditText(Context context, AttributeSet attrs) { - super(context, attrs); + private boolean shouldEncrypt() { + return isCryptoProviderEnabled() && mEncryptCheckbox.isChecked(); + } + + private boolean shouldSign() { + return isCryptoProviderEnabled() && mCryptoSignatureCheckbox.isChecked(); + } + + class DoLaunchOnClickListener implements OnClickListener { + + private final int resultId; + + DoLaunchOnClickListener(int resultId) { + this.resultId = resultId; } - /** - * Return the text the EolConvertingEditText is displaying. - * - * @return A string with any line endings converted to {@code \r\n}. - */ - public String getCharacters() { - return getText().toString().replace("\n", "\r\n"); - } - - /** - * Sets the string value of the EolConvertingEditText. Any line endings - * in the string will be converted to {@code \n}. - * - * @param text - */ - public void setCharacters(CharSequence text) { - setText(text.toString().replace("\r\n", "\n")); + @Override + public void onClick(View v) { + mIgnoreOnPause = true; + startActivityForResult(mContacts.contactPickerIntent(), resultId); } } } diff --git a/k9mail/src/main/java/com/fsck/k9/activity/MessageList.java b/k9mail/src/main/java/com/fsck/k9/activity/MessageList.java index 357e0ccf6..9d08f2cc7 100644 --- a/k9mail/src/main/java/com/fsck/k9/activity/MessageList.java +++ b/k9mail/src/main/java/com/fsck/k9/activity/MessageList.java @@ -40,8 +40,8 @@ import com.fsck.k9.activity.setup.Prefs; import com.fsck.k9.crypto.PgpData; import com.fsck.k9.fragment.MessageListFragment; import com.fsck.k9.fragment.MessageListFragment.MessageListFragmentListener; -import com.fsck.k9.fragment.MessageViewFragment; -import com.fsck.k9.fragment.MessageViewFragment.MessageViewFragmentListener; +import com.fsck.k9.ui.messageview.MessageViewFragment; +import com.fsck.k9.ui.messageview.MessageViewFragment.MessageViewFragmentListener; import com.fsck.k9.mailstore.StorageManager; import com.fsck.k9.mailstore.LocalMessage; import com.fsck.k9.search.LocalSearch; @@ -51,7 +51,6 @@ import com.fsck.k9.search.SearchSpecification.Attribute; import com.fsck.k9.search.SearchSpecification.SearchCondition; import com.fsck.k9.search.SearchSpecification.SearchField; import com.fsck.k9.view.MessageHeader; -import com.fsck.k9.view.MessageOpenPgpView; import com.fsck.k9.view.MessageTitleView; import com.fsck.k9.view.ViewSwitcher; import com.fsck.k9.view.ViewSwitcher.OnSwitchCompleteListener; @@ -88,6 +87,7 @@ public class MessageList extends K9Activity implements MessageListFragmentListen private static final int PREVIOUS = 1; private static final int NEXT = 2; + public static void actionDisplaySearch(Context context, SearchSpecification search, boolean noThreading, boolean newTask) { actionDisplaySearch(context, search, noThreading, newTask, true); @@ -1562,13 +1562,8 @@ public class MessageList extends K9Activity implements MessageListFragmentListen protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - // handle OpenPGP results from PendingIntents in OpenPGP view - // must be handled in this main activity, because startIntentSenderForResult() does not support Fragments - MessageOpenPgpView openPgpView = (MessageOpenPgpView) findViewById(R.id.layout_decrypt_openpgp); - if (openPgpView != null && openPgpView.handleOnActivityResult(requestCode, resultCode, data)) { - return; + if (mMessageViewFragment != null) { + mMessageViewFragment.handleCryptoResult(requestCode, resultCode, data); } } - - } diff --git a/k9mail/src/main/java/com/fsck/k9/activity/setup/AccountSettings.java b/k9mail/src/main/java/com/fsck/k9/activity/setup/AccountSettings.java index 9ebeee4f9..19a718bf0 100644 --- a/k9mail/src/main/java/com/fsck/k9/activity/setup/AccountSettings.java +++ b/k9mail/src/main/java/com/fsck/k9/activity/setup/AccountSettings.java @@ -45,7 +45,7 @@ import com.fsck.k9.mailstore.LocalFolder; import com.fsck.k9.mailstore.StorageManager; import com.fsck.k9.service.MailService; -import org.openintents.openpgp.util.OpenPgpListPreference; +import org.openintents.openpgp.util.OpenPgpAppPreference; import org.openintents.openpgp.util.OpenPgpUtils; @@ -174,7 +174,7 @@ public class AccountSettings extends K9PreferenceActivity { private ListPreference mIdleRefreshPeriod; private ListPreference mMaxPushFolders; private boolean mHasCrypto = false; - private OpenPgpListPreference mCryptoApp; + private OpenPgpAppPreference mCryptoApp; private PreferenceScreen mSearchScreen; private CheckBoxPreference mCloudSearchEnabled; @@ -687,7 +687,7 @@ public class AccountSettings extends K9PreferenceActivity { mHasCrypto = OpenPgpUtils.isAvailable(this); if (mHasCrypto) { - mCryptoApp = (OpenPgpListPreference) findPreference(PREFERENCE_CRYPTO_APP); + mCryptoApp = (OpenPgpAppPreference) findPreference(PREFERENCE_CRYPTO_APP); mCryptoApp.setValue(String.valueOf(mAccount.getCryptoApp())); mCryptoApp.setSummary(mCryptoApp.getEntry()); diff --git a/k9mail/src/main/java/com/fsck/k9/controller/MessagingController.java b/k9mail/src/main/java/com/fsck/k9/controller/MessagingController.java index 9a5c356f9..ece8a2b5b 100644 --- a/k9mail/src/main/java/com/fsck/k9/controller/MessagingController.java +++ b/k9mail/src/main/java/com/fsck/k9/controller/MessagingController.java @@ -3115,64 +3115,39 @@ public class MessagingController implements Runnable { }); } - /** - * Mark the provided message as read if not disabled by the account setting. - * - * @param account - * The account the message belongs to. - * @param message - * The message to mark as read. This {@link Message} instance will be modify by calling - * {@link Message#setFlag(Flag, boolean)} on it. - * - * @throws MessagingException - * - * @see Account#isMarkMessageAsReadOnView() - */ - private void markMessageAsReadOnView(Account account, Message message) + public LocalMessage loadMessage(Account account, String folderName, String uid) throws MessagingException { + LocalStore localStore = account.getLocalStore(); + LocalFolder localFolder = localStore.getFolder(folderName); + localFolder.open(Folder.OPEN_MODE_RW); + + LocalMessage message = localFolder.getMessage(uid); + if (message == null || message.getId() == 0) { + throw new IllegalArgumentException("Message not found: folder=" + folderName + ", uid=" + uid); + } + + FetchProfile fp = new FetchProfile(); + fp.add(FetchProfile.Item.BODY); + localFolder.fetch(Collections.singletonList(message), fp, null); + localFolder.close(); + + markMessageAsReadOnView(account, message); + + return message; + } + + private void markMessageAsReadOnView(Account account, LocalMessage message) throws MessagingException { if (account.isMarkMessageAsReadOnView() && !message.isSet(Flag.SEEN)) { List messageIds = Collections.singletonList(message.getId()); setFlag(account, messageIds, Flag.SEEN, true); - ((LocalMessage) message).setFlagInternal(Flag.SEEN, true); + message.setFlagInternal(Flag.SEEN, true); } } - /** - * Attempts to load the attachment specified by part from the given account and message. - * @param account - * @param message - * @param part - * @param listener - */ - public void loadAttachment( - final Account account, - final Message message, - final Part part, - final Object tag, - final MessagingListener listener) { - /* - * Check if the attachment has already been downloaded. If it has there's no reason to - * download it, so we just tell the listener that it's ready to go. - */ - - if (part.getBody() != null) { - for (MessagingListener l : getListeners(listener)) { - l.loadAttachmentStarted(account, message, part, tag, false); - } - - for (MessagingListener l : getListeners(listener)) { - l.loadAttachmentFinished(account, message, part, tag); - } - return; - } - - - - for (MessagingListener l : getListeners(listener)) { - l.loadAttachmentStarted(account, message, part, tag, true); - } + public void loadAttachment(final Account account, final LocalMessage message, final Part part, + final MessagingListener listener) { put("loadAttachment", listener, new Runnable() { @Override @@ -3180,32 +3155,29 @@ public class MessagingController implements Runnable { Folder remoteFolder = null; LocalFolder localFolder = null; try { - LocalStore localStore = account.getLocalStore(); + String folderName = message.getFolder().getName(); + + LocalStore localStore = account.getLocalStore(); + localFolder = localStore.getFolder(folderName); - List attachments = MessageExtractor.collectAttachments(message); - for (Part attachment : attachments) { - attachment.setBody(null); - } Store remoteStore = account.getRemoteStore(); - localFolder = localStore.getFolder(message.getFolder().getName()); - remoteFolder = remoteStore.getFolder(message.getFolder().getName()); + remoteFolder = remoteStore.getFolder(folderName); remoteFolder.open(Folder.OPEN_MODE_RW); - //FIXME: This is an ugly hack that won't be needed once the Message objects have been united. Message remoteMessage = remoteFolder.getMessage(message.getUid()); - MimeMessageHelper.setBody(remoteMessage, message.getBody()); remoteFolder.fetchPart(remoteMessage, part, null); - localFolder.updateMessage((LocalMessage)message); + localFolder.addPartToMessage(message, part); + for (MessagingListener l : getListeners(listener)) { - l.loadAttachmentFinished(account, message, part, tag); + l.loadAttachmentFinished(account, message, part); } } catch (MessagingException me) { if (K9.DEBUG) Log.v(K9.LOG_TAG, "Exception loading attachment", me); for (MessagingListener l : getListeners(listener)) { - l.loadAttachmentFailed(account, message, part, tag, me.getMessage()); + l.loadAttachmentFailed(account, message, part, me.getMessage()); } notifyUserIfCertificateProblem(context, me, account, true); addErrorMessage(account, null, me); @@ -4015,7 +3987,7 @@ public class MessagingController implements Runnable { @Override public void act(final Account account, final Folder folder, - final List accountMessages) { + final List accountMessages) { suppressMessages(account, messages); putBackground("deleteMessages", null, new Runnable() { diff --git a/k9mail/src/main/java/com/fsck/k9/controller/MessagingListener.java b/k9mail/src/main/java/com/fsck/k9/controller/MessagingListener.java index 72bd93d18..5b6970d38 100644 --- a/k9mail/src/main/java/com/fsck/k9/controller/MessagingListener.java +++ b/k9mail/src/main/java/com/fsck/k9/controller/MessagingListener.java @@ -91,7 +91,7 @@ public class MessagingListener { Message message) {} public void loadMessageForViewFinished(Account account, String folder, String uid, - Message message) {} + LocalMessage message) {} public void loadMessageForViewFailed(Account account, String folder, String uid, Throwable t) {} @@ -133,13 +133,9 @@ public class MessagingListener { public void setPushActive(Account account, String folderName, boolean enabled) {} - public void loadAttachmentStarted(Account account, Message message, Part part, Object tag, - boolean requiresDownload) {} + public void loadAttachmentFinished(Account account, Message message, Part part) {} - public void loadAttachmentFinished(Account account, Message message, Part part, Object tag) {} - - public void loadAttachmentFailed(Account account, Message message, Part part, Object tag, - String reason) {} + public void loadAttachmentFailed(Account account, Message message, Part part, String reason) {} diff --git a/k9mail/src/main/java/com/fsck/k9/crypto/DecryptedTempFileBody.java b/k9mail/src/main/java/com/fsck/k9/crypto/DecryptedTempFileBody.java new file mode 100644 index 000000000..3d7e8845a --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/crypto/DecryptedTempFileBody.java @@ -0,0 +1,70 @@ +package com.fsck.k9.crypto; + + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.RawDataBody; +import com.fsck.k9.mail.internet.SizeAware; +import org.apache.commons.io.IOUtils; + + +public class DecryptedTempFileBody implements RawDataBody, SizeAware { + private final File tempDirectory; + private final String encoding; + private File file; + + + public DecryptedTempFileBody(String encoding, File tempDirectory) { + this.encoding = encoding; + this.tempDirectory = tempDirectory; + } + + @Override + public String getEncoding() { + return encoding; + } + + @Override + public void setEncoding(String encoding) throws MessagingException { + throw new RuntimeException("Not supported"); + } + + public OutputStream getOutputStream() throws IOException { + file = File.createTempFile("decrypted", null, tempDirectory); + return new FileOutputStream(file); + } + + @Override + public InputStream getInputStream() throws MessagingException { + try { + return new FileInputStream(file); + } catch (IOException ioe) { + throw new MessagingException("Unable to open body", ioe); + } + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + InputStream in = getInputStream(); + try { + IOUtils.copy(in, out); + } finally { + in.close(); + } + } + + @Override + public long getSize() { + return file.length(); + } + + public File getFile() { + return file; + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/crypto/MessageDecryptVerifier.java b/k9mail/src/main/java/com/fsck/k9/crypto/MessageDecryptVerifier.java new file mode 100644 index 000000000..eb58a11bb --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/crypto/MessageDecryptVerifier.java @@ -0,0 +1,134 @@ +package com.fsck.k9.crypto; + + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Stack; + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MessageExtractor; +import org.openintents.openpgp.util.OpenPgpUtils; + + +public class MessageDecryptVerifier { + private static final String MULTIPART_ENCRYPTED = "multipart/encrypted"; + private static final String MULTIPART_SIGNED = "multipart/signed"; + private static final String PROTOCOL_PARAMETER = "protocol"; + private static final String APPLICATION_PGP_ENCRYPTED = "application/pgp-encrypted"; + private static final String APPLICATION_PGP_SIGNATURE = "application/pgp-signature"; + private static final String TEXT_PLAIN = "text/plain"; + + + public static List findEncryptedParts(Part startPart) { + List encryptedParts = new ArrayList(); + Stack partsToCheck = new Stack(); + partsToCheck.push(startPart); + + while (!partsToCheck.isEmpty()) { + Part part = partsToCheck.pop(); + String mimeType = part.getMimeType(); + Body body = part.getBody(); + + if (MULTIPART_ENCRYPTED.equals(mimeType)) { + encryptedParts.add(part); + } else if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + for (int i = multipart.getCount() - 1; i >= 0; i--) { + BodyPart bodyPart = multipart.getBodyPart(i); + partsToCheck.push(bodyPart); + } + } + } + + return encryptedParts; + } + + public static List findSignedParts(Part startPart) { + List signedParts = new ArrayList(); + Stack partsToCheck = new Stack(); + partsToCheck.push(startPart); + + while (!partsToCheck.isEmpty()) { + Part part = partsToCheck.pop(); + String mimeType = part.getMimeType(); + Body body = part.getBody(); + + if (MULTIPART_SIGNED.equals(mimeType)) { + signedParts.add(part); + } else if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + for (int i = multipart.getCount() - 1; i >= 0; i--) { + BodyPart bodyPart = multipart.getBodyPart(i); + partsToCheck.push(bodyPart); + } + } + } + + return signedParts; + } + + public static List findPgpInlineParts(Part startPart) { + List inlineParts = new ArrayList(); + Stack partsToCheck = new Stack(); + partsToCheck.push(startPart); + + while (!partsToCheck.isEmpty()) { + Part part = partsToCheck.pop(); + String mimeType = part.getMimeType(); + Body body = part.getBody(); + + if (TEXT_PLAIN.equalsIgnoreCase(mimeType)) { + String text = MessageExtractor.getTextFromPart(part); + switch (OpenPgpUtils.parseMessage(text)) { + case OpenPgpUtils.PARSE_RESULT_MESSAGE: + case OpenPgpUtils.PARSE_RESULT_SIGNED_MESSAGE: + inlineParts.add(part); + } + } else if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + for (int i = multipart.getCount() - 1; i >= 0; i--) { + BodyPart bodyPart = multipart.getBodyPart(i); + partsToCheck.push(bodyPart); + } + } + } + + return inlineParts; + } + + public static byte[] getSignatureData(Part part) throws IOException, MessagingException { + + if (MULTIPART_SIGNED.equals(part.getMimeType())) { + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart multi = (Multipart) body; + BodyPart signatureBody = multi.getBodyPart(1); + if (APPLICATION_PGP_SIGNATURE.equals(signatureBody.getMimeType())) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + signatureBody.getBody().writeTo(bos); + return bos.toByteArray(); + } + } + } + + return null; + } + + public static boolean isPgpMimeSignedPart(Part part) { + return MULTIPART_SIGNED.equals(part.getMimeType()); + } + + public static boolean isPgpMimeEncryptedPart(Part part) { + //FIXME: Doesn't work right now because LocalMessage.getContentType() doesn't load headers from database +// String contentType = part.getContentType(); +// String protocol = MimeUtility.getHeaderParameter(contentType, PROTOCOL_PARAMETER); +// return APPLICATION_PGP_ENCRYPTED.equals(protocol); + return MULTIPART_ENCRYPTED.equals(part.getMimeType()); + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/helper/SimpleTextWatcher.java b/k9mail/src/main/java/com/fsck/k9/helper/SimpleTextWatcher.java new file mode 100644 index 000000000..94c2cc7b4 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/helper/SimpleTextWatcher.java @@ -0,0 +1,22 @@ +package com.fsck.k9.helper; + +import android.text.Editable; +import android.text.TextWatcher; + +/** + * all methods empty - but this way we can have TextWatchers with less boilder-plate where + * we just override the methods we want and not always al 3 + */ +public class SimpleTextWatcher implements TextWatcher { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/helper/Utility.java b/k9mail/src/main/java/com/fsck/k9/helper/Utility.java index 6be32825d..aa93f7d8c 100644 --- a/k9mail/src/main/java/com/fsck/k9/helper/Utility.java +++ b/k9mail/src/main/java/com/fsck/k9/helper/Utility.java @@ -393,7 +393,8 @@ public class Utility { public static boolean hasExternalImages(final String message) { Matcher imgMatches = IMG_PATTERN.matcher(message); while (imgMatches.find()) { - if (!imgMatches.group(1).equals("content")) { + String uriScheme = imgMatches.group(1); + if (uriScheme.equals("http") || uriScheme.equals("https")) { if (K9.DEBUG) { Log.d(K9.LOG_TAG, "External images found"); } diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/AttachmentViewInfo.java b/k9mail/src/main/java/com/fsck/k9/mailstore/AttachmentViewInfo.java new file mode 100644 index 000000000..88d1e1981 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/AttachmentViewInfo.java @@ -0,0 +1,36 @@ +package com.fsck.k9.mailstore; + + +import android.net.Uri; + +import com.fsck.k9.mail.Part; + + +public class AttachmentViewInfo { + public static final long UNKNOWN_SIZE = -1; + + public final String mimeType; + public final String displayName; + public final long size; + + /** + * A content provider URI that can be used to retrieve the decoded attachment. + *

+ * Note: All content providers must support an alternative MIME type appended as last URI segment. + * + * @see com.fsck.k9.ui.messageview.AttachmentController#getAttachmentUriForMimeType(AttachmentViewInfo, String) + */ + public final Uri uri; + public final boolean firstClassAttachment; + public final Part part; + + public AttachmentViewInfo(String mimeType, String displayName, long size, Uri uri, boolean firstClassAttachment, + Part part) { + this.mimeType = mimeType; + this.displayName = displayName; + this.size = size; + this.uri = uri; + this.firstClassAttachment = firstClassAttachment; + this.part = part; + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/BinaryMemoryBody.java b/k9mail/src/main/java/com/fsck/k9/mailstore/BinaryMemoryBody.java new file mode 100644 index 000000000..b4a289f3a --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/BinaryMemoryBody.java @@ -0,0 +1,48 @@ +package com.fsck.k9.mailstore; + + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.RawDataBody; +import com.fsck.k9.mail.internet.SizeAware; + + +public class BinaryMemoryBody implements Body, RawDataBody, SizeAware { + private final byte[] data; + private final String encoding; + + public BinaryMemoryBody(byte[] data, String encoding) { + this.data = data; + this.encoding = encoding; + } + + @Override + public String getEncoding() { + return encoding; + } + + @Override + public InputStream getInputStream() throws MessagingException { + return new ByteArrayInputStream(data); + } + + @Override + public void setEncoding(String encoding) throws UnavailableStorageException, MessagingException { + throw new RuntimeException("nope"); //FIXME + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + out.write(data); + } + + @Override + public long getSize() { + return data.length; + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/DecryptStreamParser.java b/k9mail/src/main/java/com/fsck/k9/mailstore/DecryptStreamParser.java new file mode 100644 index 000000000..ff8352b49 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/DecryptStreamParser.java @@ -0,0 +1,231 @@ +package com.fsck.k9.mailstore; + + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Stack; + +import android.content.Context; +import android.util.Log; + +import com.fsck.k9.K9; +import com.fsck.k9.crypto.DecryptedTempFileBody; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.mail.internet.MimeUtility; +import org.apache.commons.io.IOUtils; +import org.apache.james.mime4j.MimeException; +import org.apache.james.mime4j.codec.Base64InputStream; +import org.apache.james.mime4j.codec.QuotedPrintableInputStream; +import org.apache.james.mime4j.io.EOLConvertingInputStream; +import org.apache.james.mime4j.parser.ContentHandler; +import org.apache.james.mime4j.parser.MimeStreamParser; +import org.apache.james.mime4j.stream.BodyDescriptor; +import org.apache.james.mime4j.stream.Field; +import org.apache.james.mime4j.stream.MimeConfig; +import org.apache.james.mime4j.util.MimeUtil; + +// TODO rename this class? this class doesn't really bear any 'decrypted' semantics anymore... +public class DecryptStreamParser { + + private static final String DECRYPTED_CACHE_DIRECTORY = "decrypted"; + + public static MimeBodyPart parse(Context context, InputStream inputStream) throws MessagingException, IOException { + File decryptedTempDirectory = getDecryptedTempDirectory(context); + + MimeBodyPart decryptedRootPart = new MimeBodyPart(); + + MimeConfig parserConfig = new MimeConfig(); + parserConfig.setMaxHeaderLen(-1); + parserConfig.setMaxLineLen(-1); + parserConfig.setMaxHeaderCount(-1); + + MimeStreamParser parser = new MimeStreamParser(parserConfig); + parser.setContentHandler(new PartBuilder(decryptedTempDirectory, decryptedRootPart)); + parser.setRecurse(); + + inputStream = new BufferedInputStream(inputStream, 4096); + + try { + parser.parse(new EOLConvertingInputStream(inputStream)); + } catch (MimeException e) { + throw new MessagingException("Failed to parse decrypted content", e); + } + + return decryptedRootPart; + } + + private static Body createBody(InputStream inputStream, String transferEncoding, File decryptedTempDirectory) + throws IOException { + DecryptedTempFileBody body = new DecryptedTempFileBody(transferEncoding, decryptedTempDirectory); + OutputStream outputStream = body.getOutputStream(); + try { + InputStream decodingInputStream; + boolean closeStream; + if (MimeUtil.ENC_QUOTED_PRINTABLE.equals(transferEncoding)) { + decodingInputStream = new QuotedPrintableInputStream(inputStream, false); + closeStream = true; + } else if (MimeUtil.ENC_BASE64.equals(transferEncoding)) { + decodingInputStream = new Base64InputStream(inputStream); + closeStream = true; + } else { + decodingInputStream = inputStream; + closeStream = false; + } + + try { + IOUtils.copy(decodingInputStream, outputStream); + } finally { + if (closeStream) { + decodingInputStream.close(); + } + } + } finally { + outputStream.close(); + } + + return body; + } + + private static File getDecryptedTempDirectory(Context context) { + File directory = new File(context.getCacheDir(), DECRYPTED_CACHE_DIRECTORY); + if (!directory.exists()) { + if (!directory.mkdir()) { + Log.e(K9.LOG_TAG, "Error creating directory: " + directory.getAbsolutePath()); + } + } + + return directory; + } + + + private static class PartBuilder implements ContentHandler { + private final File decryptedTempDirectory; + private final MimeBodyPart decryptedRootPart; + private final Stack stack = new Stack(); + + public PartBuilder(File decryptedTempDirectory, MimeBodyPart decryptedRootPart) + throws MessagingException { + this.decryptedTempDirectory = decryptedTempDirectory; + this.decryptedRootPart = decryptedRootPart; + } + + @Override + public void startMessage() throws MimeException { + if (stack.isEmpty()) { + stack.push(decryptedRootPart); + } else { + Part part = (Part) stack.peek(); + + Message innerMessage = new MimeMessage(); + part.setBody(innerMessage); + + stack.push(innerMessage); + } + } + + @Override + public void endMessage() throws MimeException { + stack.pop(); + } + + @Override + public void startBodyPart() throws MimeException { + try { + Multipart multipart = (Multipart) stack.peek(); + + BodyPart bodyPart = new MimeBodyPart(); + multipart.addBodyPart(bodyPart); + + stack.push(bodyPart); + } catch (MessagingException e) { + throw new MimeException(e); + } + } + + @Override + public void endBodyPart() throws MimeException { + stack.pop(); + } + + @Override + public void startHeader() throws MimeException { + // Do nothing + } + + @Override + public void field(Field parsedField) throws MimeException { + try { + String name = parsedField.getName(); + String raw = parsedField.getRaw().toString(); + + Part part = (Part) stack.peek(); + part.addRawHeader(name, raw); + } catch (MessagingException e) { + throw new MimeException(e); + } + } + + @Override + public void endHeader() throws MimeException { + // Do nothing + } + + @Override + public void preamble(InputStream is) throws MimeException, IOException { + // Do nothing + } + + @Override + public void epilogue(InputStream is) throws MimeException, IOException { + // Do nothing + } + + @Override + public void startMultipart(BodyDescriptor bd) throws MimeException { + Part part = (Part) stack.peek(); + try { + String contentType = part.getContentType(); + String mimeType = MimeUtility.getHeaderParameter(contentType, null); + String boundary = MimeUtility.getHeaderParameter(contentType, "boundary"); + + MimeMultipart multipart = new MimeMultipart(mimeType, boundary); + part.setBody(multipart); + + stack.push(multipart); + } catch (MessagingException e) { + throw new MimeException(e); + } + } + + @Override + public void endMultipart() throws MimeException { + stack.pop(); + } + + @Override + public void body(BodyDescriptor bd, InputStream inputStream) throws MimeException, IOException { + Part part = (Part) stack.peek(); + + String transferEncoding = bd.getTransferEncoding(); + Body body = createBody(inputStream, transferEncoding, decryptedTempDirectory); + + part.setBody(body); + } + + @Override + public void raw(InputStream is) throws MimeException, IOException { + throw new IllegalStateException("Not implemented"); + } + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/FileBackedBody.java b/k9mail/src/main/java/com/fsck/k9/mailstore/FileBackedBody.java new file mode 100644 index 000000000..beb1c56c5 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/FileBackedBody.java @@ -0,0 +1,60 @@ +package com.fsck.k9.mailstore; + + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.RawDataBody; +import com.fsck.k9.mail.internet.SizeAware; +import org.apache.commons.io.IOUtils; + + +public class FileBackedBody implements Body, SizeAware, RawDataBody { + private final File file; + private final String encoding; + + public FileBackedBody(File file, String encoding) { + this.file = file; + this.encoding = encoding; + } + + @Override + public InputStream getInputStream() throws MessagingException { + try { + return new FileInputStream(file); + } catch (FileNotFoundException e) { + throw new MessagingException("File not found", e); + } + } + + @Override + public void setEncoding(String encoding) throws MessagingException { + throw new RuntimeException("not supported"); + } + + @Override + public void writeTo(OutputStream out) throws IOException, MessagingException { + InputStream in = getInputStream(); + try { + IOUtils.copy(in, out); + } finally { + in.close(); + } + } + + @Override + public long getSize() { + return file.length(); + } + + @Override + public String getEncoding() { + return encoding; + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalAttachmentBody.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalAttachmentBody.java deleted file mode 100644 index 0b191472a..000000000 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalAttachmentBody.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.fsck.k9.mailstore; - -import java.io.ByteArrayInputStream; -import java.io.FileNotFoundException; -import java.io.InputStream; - -import android.content.Context; -import android.net.Uri; - -import com.fsck.k9.mail.MessagingException; - -/** - * An attachment whose contents are loaded from an URI. - */ -public class LocalAttachmentBody extends BinaryAttachmentBody { - private Context context; - private Uri mUri; - - public LocalAttachmentBody(Uri uri, Context context) { - this.context = context; - mUri = uri; - } - - @Override - public InputStream getInputStream() throws MessagingException { - try { - return context.getContentResolver().openInputStream(mUri); - } catch (FileNotFoundException fnfe) { - /* - * Since it's completely normal for us to try to serve up attachments that - * have been blown away, we just return an empty stream. - */ - return new ByteArrayInputStream(LocalStore.EMPTY_BYTE_ARRAY); - } - } - - public Uri getContentUri() { - return mUri; - } -} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalAttachmentBodyPart.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalAttachmentBodyPart.java deleted file mode 100644 index ab6eae968..000000000 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalAttachmentBodyPart.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.fsck.k9.mailstore; - -import com.fsck.k9.mail.Body; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.internet.MimeBodyPart; - -public class LocalAttachmentBodyPart extends MimeBodyPart { - private long mAttachmentId = -1; - - public LocalAttachmentBodyPart(Body body, long attachmentId) throws MessagingException { - super(body); - mAttachmentId = attachmentId; - } - - /** - * Returns the local attachment id of this body, or -1 if it is not stored. - * @return - */ - public long getAttachmentId() { - return mAttachmentId; - } - - public void setAttachmentId(long attachmentId) { - mAttachmentId = attachmentId; - } - - @Override - public String toString() { - return "" + mAttachmentId; - } -} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalAttachmentMessageBody.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalAttachmentMessageBody.java deleted file mode 100644 index 82d62e092..000000000 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalAttachmentMessageBody.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.fsck.k9.mailstore; - -import java.io.IOException; -import java.io.OutputStream; - -import org.apache.james.mime4j.util.MimeUtil; - -import android.content.Context; -import android.net.Uri; - -import com.fsck.k9.mail.CompositeBody; -import com.fsck.k9.mail.MessagingException; - -/** - * A {@link LocalAttachmentBody} extension containing a message/rfc822 type body - * - */ -class LocalAttachmentMessageBody extends LocalAttachmentBody implements CompositeBody { - - public LocalAttachmentMessageBody(Uri uri, Context context) { - super(uri, context); - } - - @Override - public void writeTo(OutputStream out) throws IOException, MessagingException { - AttachmentMessageBodyUtil.writeTo(this, out); - } - - @Override - public void setUsing7bitTransport() throws MessagingException { - /* - * There's nothing to recurse into here, so there's nothing to do. - * The enclosing BodyPart already called setEncoding(MimeUtil.ENC_7BIT). Once - * writeTo() is called, the file with the rfc822 body will be opened - * for reading and will then be recursed. - */ - - } - - @Override - public void setEncoding(String encoding) throws MessagingException { - if (!MimeUtil.ENC_7BIT.equalsIgnoreCase(encoding) - && !MimeUtil.ENC_8BIT.equalsIgnoreCase(encoding)) { - throw new MessagingException( - "Incompatible content-transfer-encoding applied to a CompositeBody"); - } - mEncoding = encoding; - } -} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalBodyPart.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalBodyPart.java new file mode 100644 index 000000000..ede69874d --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalBodyPart.java @@ -0,0 +1,56 @@ +package com.fsck.k9.mailstore; + + +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.MimeBodyPart; + + +public class LocalBodyPart extends MimeBodyPart implements LocalPart { + private final String accountUuid; + private final LocalMessage message; + private final long messagePartId; + private final String displayName; + private final long size; + private final boolean firstClassAttachment; + + public LocalBodyPart(String accountUuid, LocalMessage message, long messagePartId, String displayName, long size, + boolean firstClassAttachment) throws MessagingException { + super(); + this.accountUuid = accountUuid; + this.message = message; + this.messagePartId = messagePartId; + this.displayName = displayName; + this.size = size; + this.firstClassAttachment = firstClassAttachment; + } + + @Override + public String getAccountUuid() { + return accountUuid; + } + + @Override + public long getId() { + return messagePartId; + } + + @Override + public String getDisplayName() { + return displayName; + } + + @Override + public long getSize() { + return size; + } + + @Override + public boolean isFirstClassAttachment() { + return firstClassAttachment; + } + + @Override + public LocalMessage getMessage() { + return message; + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalFolder.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalFolder.java index cc353a06e..7f8fa7bef 100644 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalFolder.java +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalFolder.java @@ -1,9 +1,14 @@ package com.fsck.k9.mailstore; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; @@ -14,27 +19,18 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.Stack; import java.util.UUID; -import java.util.regex.Pattern; - -import com.fsck.k9.mail.internet.MimeMessageHelper; -import org.apache.commons.io.IOUtils; -import org.apache.james.mime4j.util.MimeUtil; import android.content.ContentValues; -import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; -import android.net.Uri; import android.util.Log; import com.fsck.k9.Account; import com.fsck.k9.K9; -import com.fsck.k9.Account.MessageFormat; import com.fsck.k9.activity.Search; -import com.fsck.k9.mail.MessageRetrievalListener; -import com.fsck.k9.helper.HtmlConverter; import com.fsck.k9.helper.Utility; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Body; @@ -43,24 +39,30 @@ import com.fsck.k9.mail.FetchProfile; import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.Folder; import com.fsck.k9.mail.Message; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.Part; import com.fsck.k9.mail.Message.RecipientType; -import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.MessageRetrievalListener; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.filter.CountingOutputStream; +import com.fsck.k9.mail.internet.BinaryTempFileBody; import com.fsck.k9.mail.internet.MimeHeader; import com.fsck.k9.mail.internet.MimeMessage; import com.fsck.k9.mail.internet.MimeMultipart; -import com.fsck.k9.mail.internet.MimeUtility; -import com.fsck.k9.mail.internet.TextBody; +import com.fsck.k9.mail.internet.SizeAware; +import com.fsck.k9.mail.message.MessageHeaderParser; import com.fsck.k9.mailstore.LockableDatabase.DbCallback; import com.fsck.k9.mailstore.LockableDatabase.WrappedException; -import com.fsck.k9.provider.AttachmentProvider; +import org.apache.commons.io.IOUtils; +import org.apache.james.mime4j.util.MimeUtil; public class LocalFolder extends Folder implements Serializable { private static final long serialVersionUID = -1973296520918624767L; - + private static final int MAX_BODY_SIZE_FOR_DATABASE = 16 * 1024; + private static final long INVALID_MESSAGE_PART_ID = -1; + private final LocalStore localStore; private String mName = null; @@ -175,7 +177,7 @@ public class LocalFolder extends Folder implements Serializable { // does a DB update on setLastChecked super.setLastChecked(cursor.getLong(LocalStore.FOLDER_LAST_CHECKED_INDEX)); super.setLastPush(cursor.getLong(LocalStore.FOLDER_LAST_PUSHED_INDEX)); - mInTopGroup = (cursor.getInt(LocalStore.FOLDER_TOP_GROUP_INDEX)) == 1 ? true : false; + mInTopGroup = (cursor.getInt(LocalStore.FOLDER_TOP_GROUP_INDEX)) == 1 ? true : false; mIntegrate = (cursor.getInt(LocalStore.FOLDER_INTEGRATE_INDEX) == 1) ? true : false; String noClass = FolderClass.NO_CLASS.toString(); String displayClass = cursor.getString(LocalStore.FOLDER_DISPLAY_CLASS_INDEX); @@ -210,10 +212,8 @@ public class LocalFolder extends Folder implements Serializable { public Boolean doDbWork(final SQLiteDatabase db) throws WrappedException { Cursor cursor = null; try { - cursor = db.rawQuery("SELECT id FROM folders " - + "where folders.name = ?", new String[] { LocalFolder. - this.getName() - }); + cursor = db.rawQuery("SELECT id FROM folders where folders.name = ?", + new String[] { LocalFolder.this.getName() }); if (cursor.moveToFirst()) { int folderId = cursor.getInt(0); return (folderId > 0); @@ -271,10 +271,10 @@ public class LocalFolder extends Folder implements Serializable { } Cursor cursor = null; try { - cursor = db.rawQuery("SELECT COUNT(id) FROM messages WHERE (empty IS NULL OR empty != 1) AND deleted = 0 and folder_id = ?", - new String[] { - Long.toString(mFolderId) - }); + cursor = db.rawQuery( + "SELECT COUNT(id) FROM messages " + + "WHERE (empty IS NULL OR empty != 1) AND deleted = 0 and folder_id = ?", + new String[] { Long.toString(mFolderId) }); cursor.moveToFirst(); return cursor.getInt(0); //messagecount } finally { @@ -283,7 +283,7 @@ public class LocalFolder extends Folder implements Serializable { } }); } catch (WrappedException e) { - throw(MessagingException) e.getCause(); + throw (MessagingException) e.getCause(); } } @@ -403,6 +403,7 @@ public class LocalFolder extends Folder implements Serializable { public void setStatus(final String status) throws MessagingException { updateFolderColumn("status", status); } + public void setPushState(final String pushState) throws MessagingException { mPushState = pushState; updateFolderColumn("push_state", pushState); @@ -465,7 +466,6 @@ public class LocalFolder extends Folder implements Serializable { public void setDisplayClass(FolderClass displayClass) throws MessagingException { mDisplayClass = displayClass; updateFolderColumn("display_class", mDisplayClass.name()); - } public void setSyncClass(FolderClass syncClass) throws MessagingException { @@ -503,7 +503,6 @@ public class LocalFolder extends Folder implements Serializable { private String getPrefId() throws MessagingException { open(OPEN_MODE_RW); return getPrefId(mName); - } public void delete() throws MessagingException { @@ -606,7 +605,6 @@ public class LocalFolder extends Folder implements Serializable { } prefHolder.inTopGroup = preferences.getBoolean(id + ".inTopGroup", prefHolder.inTopGroup); prefHolder.integrate = preferences.getBoolean(id + ".integrate", prefHolder.integrate); - } @Override @@ -620,182 +618,9 @@ public class LocalFolder extends Folder implements Serializable { open(OPEN_MODE_RW); if (fp.contains(FetchProfile.Item.BODY)) { for (Message message : messages) { - LocalMessage localMessage = (LocalMessage)message; - Cursor cursor = null; - MimeMultipart mp = new MimeMultipart(); - mp.setSubType("mixed"); - try { - cursor = db.rawQuery("SELECT html_content, text_content, mime_type FROM messages " - + "WHERE id = ?", - new String[] { Long.toString(localMessage.getId()) }); - cursor.moveToNext(); - String htmlContent = cursor.getString(0); - String textContent = cursor.getString(1); - String mimeType = cursor.getString(2); - if (mimeType != null && mimeType.toLowerCase(Locale.US).startsWith("multipart/")) { - // If this is a multipart message, preserve both text - // and html parts, as well as the subtype. - mp.setSubType(mimeType.toLowerCase(Locale.US).replaceFirst("^multipart/", "")); - if (textContent != null) { - LocalTextBody body = new LocalTextBody(textContent, htmlContent); - MimeBodyPart bp = new MimeBodyPart(body, "text/plain"); - mp.addBodyPart(bp); - } + LocalMessage localMessage = (LocalMessage) message; - if (getAccount().getMessageFormat() != MessageFormat.TEXT) { - if (htmlContent != null) { - TextBody body = new TextBody(htmlContent); - MimeBodyPart bp = new MimeBodyPart(body, "text/html"); - mp.addBodyPart(bp); - } - - // If we have both text and html content and our MIME type - // isn't multipart/alternative, then corral them into a new - // multipart/alternative part and put that into the parent. - // If it turns out that this is the only part in the parent - // MimeMultipart, it'll get fixed below before we attach to - // the message. - if (textContent != null && htmlContent != null && !mimeType.equalsIgnoreCase("multipart/alternative")) { - MimeMultipart alternativeParts = mp; - alternativeParts.setSubType("alternative"); - mp = new MimeMultipart(); - mp.addBodyPart(new MimeBodyPart(alternativeParts)); - } - } - } else if (mimeType != null && mimeType.equalsIgnoreCase("text/plain")) { - // If it's text, add only the plain part. The MIME - // container will drop away below. - if (textContent != null) { - LocalTextBody body = new LocalTextBody(textContent, htmlContent); - MimeBodyPart bp = new MimeBodyPart(body, "text/plain"); - mp.addBodyPart(bp); - } - } else if (mimeType != null && mimeType.equalsIgnoreCase("text/html")) { - // If it's html, add only the html part. The MIME - // container will drop away below. - if (htmlContent != null) { - TextBody body = new TextBody(htmlContent); - MimeBodyPart bp = new MimeBodyPart(body, "text/html"); - mp.addBodyPart(bp); - } - } else { - // MIME type not set. Grab whatever part we can get, - // with Text taking precedence. This preserves pre-HTML - // composition behaviour. - if (textContent != null) { - LocalTextBody body = new LocalTextBody(textContent, htmlContent); - MimeBodyPart bp = new MimeBodyPart(body, "text/plain"); - mp.addBodyPart(bp); - } else if (htmlContent != null) { - TextBody body = new TextBody(htmlContent); - MimeBodyPart bp = new MimeBodyPart(body, "text/html"); - mp.addBodyPart(bp); - } - } - - } catch (Exception e) { - Log.e(K9.LOG_TAG, "Exception fetching message:", e); - } finally { - Utility.closeQuietly(cursor); - } - - try { - cursor = db.query( - "attachments", - new String[] { - "id", - "size", - "name", - "mime_type", - "store_data", - "content_uri", - "content_id", - "content_disposition" - }, - "message_id = ?", - new String[] { Long.toString(localMessage.getId()) }, - null, - null, - null); - - while (cursor.moveToNext()) { - long id = cursor.getLong(0); - int size = cursor.getInt(1); - String name = cursor.getString(2); - String type = cursor.getString(3); - String storeData = cursor.getString(4); - String contentUri = cursor.getString(5); - String contentId = cursor.getString(6); - String contentDisposition = cursor.getString(7); - String encoding = MimeUtility.getEncodingforType(type); - Body body = null; - - if (contentDisposition == null) { - contentDisposition = "attachment"; - } - - if (contentUri != null) { - if (MimeUtil.isMessage(type)) { - body = new LocalAttachmentMessageBody( - Uri.parse(contentUri), - LocalFolder.this.localStore.context); - } else { - body = new LocalAttachmentBody( - Uri.parse(contentUri), - LocalFolder.this.localStore.context); - } - } - - MimeBodyPart bp = new LocalAttachmentBodyPart(body, id); - bp.setEncoding(encoding); - if (name != null) { - bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, - String.format("%s;\r\n name=\"%s\"", - type, - name)); - bp.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, - String.format(Locale.US, "%s;\r\n filename=\"%s\";\r\n size=%d", - contentDisposition, - name, // TODO: Should use encoded word defined in RFC 2231. - size)); - } else { - bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, type); - bp.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, - String.format(Locale.US, "%s;\r\n size=%d", - contentDisposition, - size)); - } - - bp.setHeader(MimeHeader.HEADER_CONTENT_ID, contentId); - /* - * HEADER_ANDROID_ATTACHMENT_STORE_DATA is a custom header we add to that - * we can later pull the attachment from the remote store if necessary. - */ - bp.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, storeData); - - mp.addBodyPart(bp); - } - } finally { - Utility.closeQuietly(cursor); - } - - if (mp.getCount() == 0) { - // If we have no body, remove the container and create a - // dummy plain text body. This check helps prevents us from - // triggering T_MIME_NO_TEXT and T_TVD_MIME_NO_HEADERS - // SpamAssassin rules. - localMessage.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain"); - MimeMessageHelper.setBody(localMessage, new TextBody("")); - } else if (mp.getCount() == 1 && - !(mp.getBodyPart(0) instanceof LocalAttachmentBodyPart)) { - // If we have only one part, drop the MimeMultipart container. - BodyPart part = mp.getBodyPart(0); - localMessage.setHeader(MimeHeader.HEADER_CONTENT_TYPE, part.getContentType()); - MimeMessageHelper.setBody(localMessage, part.getBody()); - } else { - // Otherwise, attach the MimeMultipart to the message. - MimeMessageHelper.setBody(localMessage, mp); - } + loadMessageParts(db, localMessage); } } } catch (MessagingException e) { @@ -805,10 +630,113 @@ public class LocalFolder extends Folder implements Serializable { } }); } catch (WrappedException e) { - throw(MessagingException) e.getCause(); + throw (MessagingException) e.getCause(); } } + private void loadMessageParts(SQLiteDatabase db, LocalMessage message) throws MessagingException { + Map partById = new HashMap(); + + String[] columns = { + "id", // 0 + "type", // 1 + "parent", // 2 + "mime_type", // 3 + "decoded_body_size", // 4 + "display_name", // 5 + "header", // 6 + "encoding", // 7 + "charset", // 8 + "data_location", // 9 + "data", // 10 + "preamble", // 11 + "epilogue", // 12 + "boundary", // 13 + "content_id", // 14 + "server_extra", // 15 + }; + Cursor cursor = db.query("message_parts", columns, "root = ?", + new String[] { String.valueOf(message.getMessagePartId()) }, null, null, "seq"); + try { + while (cursor.moveToNext()) { + loadMessagePart(message, partById, cursor); + } + } finally { + cursor.close(); + } + } + + private void loadMessagePart(LocalMessage message, Map partById, Cursor cursor) + throws MessagingException { + + long id = cursor.getLong(0); + int type = cursor.getInt(1); + long parentId = cursor.getLong(2); + String mimeType = cursor.getString(3); + long size = cursor.getLong(4); + String displayName = cursor.getString(5); + byte[] header = cursor.getBlob(6); + int dataLocation = cursor.getInt(9); + String serverExtra = cursor.getString(15); + boolean firstClassAttachment = (type != MessagePartType.HIDDEN_ATTACHMENT); + + final Part part; + if (id == message.getMessagePartId()) { + part = message; + } else { + Part parentPart = partById.get(parentId); + if (parentPart == null) { + throw new IllegalStateException("Parent part not found"); + } + + String parentMimeType = parentPart.getMimeType(); + if (parentMimeType.startsWith("multipart/")) { + BodyPart bodyPart = new LocalBodyPart(getAccountUuid(), message, id, displayName, size, + firstClassAttachment); + ((Multipart) parentPart.getBody()).addBodyPart(bodyPart); + part = bodyPart; + } else if (parentMimeType.startsWith("message/")) { + Message innerMessage = new MimeMessage(); + parentPart.setBody(innerMessage); + part = innerMessage; + } else { + throw new IllegalStateException("Parent is neither a multipart nor a message"); + } + + parseHeaderBytes(part, header); + } + partById.put(id, part); + part.setServerExtra(serverExtra); + + boolean isMultipart = mimeType.startsWith("multipart/"); + if (isMultipart) { + byte[] preamble = cursor.getBlob(11); + byte[] epilogue = cursor.getBlob(12); + String boundary = cursor.getString(13); + + MimeMultipart multipart = new MimeMultipart(mimeType, boundary); + part.setBody(multipart); + multipart.setPreamble(preamble); + multipart.setEpilogue(epilogue); + } else if (dataLocation == DataLocation.IN_DATABASE) { + String encoding = cursor.getString(7); + byte[] data = cursor.getBlob(10); + + Body body = new BinaryMemoryBody(data, encoding); + part.setBody(body); + } else if (dataLocation == DataLocation.ON_DISK) { + String encoding = cursor.getString(7); + + File file = localStore.getAttachmentFile(Long.toString(id)); + Body body = new FileBackedBody(file, encoding); + part.setBody(body); + } + } + + private void parseHeaderBytes(Part part, byte[] header) throws MessagingException { + MessageHeaderParser.parse(part, new ByteArrayInputStream(header)); + } + @Override public List getMessages(int start, int end, Date earliestDate, MessageRetrievalListener listener) throws MessagingException { @@ -817,50 +745,16 @@ public class LocalFolder extends Folder implements Serializable { "LocalStore.getMessages(int, int, MessageRetrievalListener) not yet implemented"); } - /** - * Populate the header fields of the given list of messages by reading - * the saved header data from the database. - * - * @param messages - * The messages whose headers should be loaded. - * @throws UnavailableStorageException - */ - void populateHeaders(final List messages) throws MessagingException { + void populateHeaders(final LocalMessage message) throws MessagingException { this.localStore.database.execute(false, new DbCallback() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException, MessagingException { - Cursor cursor = null; - if (messages.isEmpty()) { - return null; - } + Cursor cursor = db.query("message_parts", new String[] { "header" }, "id = ?", + new String[] { Long.toString(message.getMessagePartId()) }, null, null, null); try { - Map popMessages = new HashMap(); - List ids = new ArrayList(); - StringBuilder questions = new StringBuilder(); - - for (int i = 0; i < messages.size(); i++) { - if (i != 0) { - questions.append(", "); - } - questions.append("?"); - LocalMessage message = messages.get(i); - Long id = message.getId(); - ids.add(Long.toString(id)); - popMessages.put(id, message); - - } - - cursor = db.rawQuery( - "SELECT message_id, name, value FROM headers " + "WHERE message_id in ( " + questions + ") ORDER BY id ASC", - ids.toArray(LocalStore.EMPTY_STRING_ARRAY)); - - - while (cursor.moveToNext()) { - Long id = cursor.getLong(0); - String name = cursor.getString(1); - String value = cursor.getString(2); - //Log.i(K9.LOG_TAG, "Retrieved header name= " + name + ", value = " + value + " for message " + id); - popMessages.get(id).addHeader(name, value); + if (cursor.moveToFirst()) { + byte[] header = cursor.getBlob(0); + parseHeaderBytes(message, header); } } finally { Utility.closeQuietly(cursor); @@ -881,11 +775,8 @@ public class LocalFolder extends Folder implements Serializable { try { cursor = db.rawQuery( - "SELECT uid FROM messages " + - "WHERE id = ? AND folder_id = ?", - new String[] { - Long.toString(id), Long.toString(mFolderId) - }); + "SELECT uid FROM messages WHERE id = ? AND folder_id = ?", + new String[] { Long.toString(id), Long.toString(mFolderId) }); if (!cursor.moveToNext()) { return null; } @@ -916,14 +807,13 @@ public class LocalFolder extends Folder implements Serializable { try { cursor = db.rawQuery( - "SELECT " + - LocalStore.GET_MESSAGES_COLS + - "FROM messages " + - "LEFT JOIN threads ON (threads.message_id = messages.id) " + - "WHERE uid = ? AND folder_id = ?", - new String[] { - message.getUid(), Long.toString(mFolderId) - }); + "SELECT " + + LocalStore.GET_MESSAGES_COLS + + "FROM messages " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + + "WHERE uid = ? AND folder_id = ?", + new String[] { message.getUid(), Long.toString(mFolderId) }); + if (!cursor.moveToNext()) { return null; } @@ -955,17 +845,14 @@ public class LocalFolder extends Folder implements Serializable { public List doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { try { open(OPEN_MODE_RW); - return LocalFolder.this.localStore.getMessages( - listener, - LocalFolder.this, - "SELECT " + LocalStore.GET_MESSAGES_COLS + - "FROM messages " + - "LEFT JOIN threads ON (threads.message_id = messages.id) " + - "WHERE (empty IS NULL OR empty != 1) AND " + - (includeDeleted ? "" : "deleted = 0 AND ") + - "folder_id = ? ORDER BY date DESC", - new String[] { Long.toString(mFolderId) } - ); + return LocalFolder.this.localStore.getMessages(listener, LocalFolder.this, + "SELECT " + LocalStore.GET_MESSAGES_COLS + + "FROM messages " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + + "WHERE (empty IS NULL OR empty != 1) AND " + + (includeDeleted ? "" : "deleted = 0 AND ") + + "folder_id = ? ORDER BY date DESC", + new String[] { Long.toString(mFolderId) }); } catch (MessagingException e) { throw new WrappedException(e); } @@ -1191,6 +1078,10 @@ public class LocalFolder extends Folder implements Serializable { } private ThreadInfo getThreadInfo(SQLiteDatabase db, String messageId, boolean onlyEmpty) { + if (messageId == null) { + return null; + } + String sql = "SELECT t.id, t.message_id, t.root, t.parent " + "FROM messages m " + "LEFT JOIN threads t ON (t.message_id = m.id) " + @@ -1233,7 +1124,8 @@ public class LocalFolder extends Folder implements Serializable { * @param copy * @return uidMap of srcUids -> destUids */ - private Map appendMessages(final List messages, final boolean copy) throws MessagingException { + private Map appendMessages(final List messages, final boolean copy) + throws MessagingException { open(OPEN_MODE_RW); try { final Map uidMap = new HashMap(); @@ -1242,136 +1134,7 @@ public class LocalFolder extends Folder implements Serializable { public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { try { for (Message message : messages) { - long oldMessageId = -1; - String uid = message.getUid(); - if (uid == null || copy) { - /* - * Create a new message in the database - */ - String randomLocalUid = K9.LOCAL_UID_PREFIX + - UUID.randomUUID().toString(); - - if (copy) { - // Save mapping: source UID -> target UID - uidMap.put(uid, randomLocalUid); - } else { - // Modify the Message instance to reference the new UID - message.setUid(randomLocalUid); - } - - // The message will be saved with the newly generated UID - uid = randomLocalUid; - } else { - /* - * Replace an existing message in the database - */ - LocalMessage oldMessage = getMessage(uid); - - if (oldMessage != null) { - oldMessageId = oldMessage.getId(); - } - - deleteAttachments(message.getUid()); - } - - long rootId = -1; - long parentId = -1; - - if (oldMessageId == -1) { - // This is a new message. Do the message threading. - ThreadInfo threadInfo = doMessageThreading(db, message); - oldMessageId = threadInfo.msgId; - rootId = threadInfo.rootId; - parentId = threadInfo.parentId; - } - - boolean isDraft = (message.getHeader(K9.IDENTITY_HEADER) != null); - - List attachments; - String text; - String html; - if (isDraft) { - // Don't modify the text/plain or text/html part of our own - // draft messages because this will cause the values stored in - // the identity header to be wrong. - ViewableContainer container = - LocalMessageExtractor.extractPartsFromDraft(message); - - text = container.text; - html = container.html; - attachments = container.attachments; - } else { - ViewableContainer container = - LocalMessageExtractor.extractTextAndAttachments(LocalFolder.this.localStore.context, message); - - attachments = container.attachments; - text = container.text; - html = HtmlConverter.convertEmoji2Img(container.html); - } - - String preview = Message.calculateContentPreview(text); - - try { - ContentValues cv = new ContentValues(); - cv.put("uid", uid); - cv.put("subject", message.getSubject()); - cv.put("sender_list", Address.pack(message.getFrom())); - cv.put("date", message.getSentDate() == null - ? System.currentTimeMillis() : message.getSentDate().getTime()); - cv.put("flags", LocalFolder.this.localStore.serializeFlags(message.getFlags())); - cv.put("deleted", message.isSet(Flag.DELETED) ? 1 : 0); - cv.put("read", message.isSet(Flag.SEEN) ? 1 : 0); - cv.put("flagged", message.isSet(Flag.FLAGGED) ? 1 : 0); - cv.put("answered", message.isSet(Flag.ANSWERED) ? 1 : 0); - cv.put("forwarded", message.isSet(Flag.FORWARDED) ? 1 : 0); - cv.put("folder_id", mFolderId); - cv.put("to_list", Address.pack(message.getRecipients(RecipientType.TO))); - cv.put("cc_list", Address.pack(message.getRecipients(RecipientType.CC))); - cv.put("bcc_list", Address.pack(message.getRecipients(RecipientType.BCC))); - cv.put("html_content", html.length() > 0 ? html : null); - cv.put("text_content", text.length() > 0 ? text : null); - cv.put("preview", preview.length() > 0 ? preview : null); - cv.put("reply_to_list", Address.pack(message.getReplyTo())); - cv.put("attachment_count", attachments.size()); - cv.put("internal_date", message.getInternalDate() == null - ? System.currentTimeMillis() : message.getInternalDate().getTime()); - cv.put("mime_type", message.getMimeType()); - cv.put("empty", 0); - - String messageId = message.getMessageId(); - if (messageId != null) { - cv.put("message_id", messageId); - } - - long msgId; - - if (oldMessageId == -1) { - msgId = db.insert("messages", "uid", cv); - - // Create entry in 'threads' table - cv.clear(); - cv.put("message_id", msgId); - - if (rootId != -1) { - cv.put("root", rootId); - } - if (parentId != -1) { - cv.put("parent", parentId); - } - - db.insert("threads", null, cv); - } else { - db.update("messages", cv, "id = ?", new String[] { Long.toString(oldMessageId) }); - msgId = oldMessageId; - } - - for (Part attachment : attachments) { - saveAttachment(msgId, attachment, copy); - } - saveHeaders(msgId, (MimeMessage)message); - } catch (Exception e) { - throw new MessagingException("Error appending message", e); - } + saveMessage(db, message, copy, uidMap); } } catch (MessagingException e) { throw new WrappedException(e); @@ -1384,328 +1147,370 @@ public class LocalFolder extends Folder implements Serializable { return uidMap; } catch (WrappedException e) { - throw(MessagingException) e.getCause(); + throw (MessagingException) e.getCause(); } } - /** - * Update the given message in the LocalStore without first deleting the existing - * message (contrast with appendMessages). This method is used to store changes - * to the given message while updating attachments and not removing existing - * attachment data. - * TODO In the future this method should be combined with appendMessages since the Message - * contains enough data to decide what to do. - * @param message - * @throws MessagingException - */ - public void updateMessage(final LocalMessage message) throws MessagingException { - open(OPEN_MODE_RW); - try { - this.localStore.database.execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - try { - message.buildMimeRepresentation(); - - ViewableContainer container = - LocalMessageExtractor.extractTextAndAttachments(LocalFolder.this.localStore.context, message); - - List attachments = container.attachments; - String text = container.text; - String html = HtmlConverter.convertEmoji2Img(container.html); - - String preview = Message.calculateContentPreview(text); - - try { - db.execSQL("UPDATE messages SET " - + "uid = ?, subject = ?, sender_list = ?, date = ?, flags = ?, " - + "folder_id = ?, to_list = ?, cc_list = ?, bcc_list = ?, " - + "html_content = ?, text_content = ?, preview = ?, reply_to_list = ?, " - + "attachment_count = ?, read = ?, flagged = ?, answered = ?, forwarded = ? " - + "WHERE id = ?", - new Object[] { - message.getUid(), - message.getSubject(), - Address.pack(message.getFrom()), - message.getSentDate() == null ? System - .currentTimeMillis() : message.getSentDate() - .getTime(), - LocalFolder.this.localStore.serializeFlags(message.getFlags()), - mFolderId, - Address.pack(message - .getRecipients(RecipientType.TO)), - Address.pack(message - .getRecipients(RecipientType.CC)), - Address.pack(message - .getRecipients(RecipientType.BCC)), - html.length() > 0 ? html : null, - text.length() > 0 ? text : null, - preview.length() > 0 ? preview : null, - Address.pack(message.getReplyTo()), - attachments.size(), - message.isSet(Flag.SEEN) ? 1 : 0, - message.isSet(Flag.FLAGGED) ? 1 : 0, - message.isSet(Flag.ANSWERED) ? 1 : 0, - message.isSet(Flag.FORWARDED) ? 1 : 0, - message.getId() - }); - - for (int i = 0, count = attachments.size(); i < count; i++) { - Part attachment = attachments.get(i); - saveAttachment(message.getId(), attachment, false); - } - saveHeaders(message.getId(), message); - } catch (Exception e) { - throw new MessagingException("Error appending message", e); - } - } catch (MessagingException e) { - throw new WrappedException(e); - } - return null; - } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); + protected void saveMessage(SQLiteDatabase db, Message message, boolean copy, Map uidMap) + throws MessagingException { + if (!(message instanceof MimeMessage)) { + throw new Error("LocalStore can only store Messages that extend MimeMessage"); } - this.localStore.notifyChange(); - } + long oldMessageId = -1; + String uid = message.getUid(); + boolean shouldCreateNewMessage = uid == null || copy; + if (shouldCreateNewMessage) { + String randomLocalUid = K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString(); - /** - * Save the headers of the given message. Note that the message is not - * necessarily a {@link LocalMessage} instance. - * @param id - * @param message - * @throws com.fsck.k9.mail.MessagingException - */ - private void saveHeaders(final long id, final MimeMessage message) throws MessagingException { - this.localStore.database.execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, MessagingException { - - deleteHeaders(id); - for (String name : message.getHeaderNames()) { - String[] values = message.getHeader(name); - for (String value : values) { - ContentValues cv = new ContentValues(); - cv.put("message_id", id); - cv.put("name", name); - cv.put("value", value); - db.insert("headers", "name", cv); - } - } - - // Remember that all headers for this message have been saved, so it is - // not necessary to download them again in case the user wants to see all headers. - List appendedFlags = new ArrayList(); - appendedFlags.addAll(message.getFlags()); - appendedFlags.add(Flag.X_GOT_ALL_HEADERS); - - db.execSQL("UPDATE messages " + "SET flags = ? " + " WHERE id = ?", - new Object[] - { LocalFolder.this.localStore.serializeFlags(appendedFlags), id }); - - return null; + if (copy) { + // Save mapping: source UID -> target UID + uidMap.put(uid, randomLocalUid); + } else { + // Modify the Message instance to reference the new UID + message.setUid(randomLocalUid); } - }); + + // The message will be saved with the newly generated UID + uid = randomLocalUid; + } else { + LocalMessage oldMessage = getMessage(uid); + + if (oldMessage != null) { + oldMessageId = oldMessage.getId(); + + long oldRootMessagePartId = oldMessage.getMessagePartId(); + deleteMessagePartsAndDataFromDisk(oldRootMessagePartId); + } + } + + long rootId = -1; + long parentId = -1; + + if (oldMessageId == -1) { + // This is a new message. Do the message threading. + ThreadInfo threadInfo = doMessageThreading(db, message); + oldMessageId = threadInfo.msgId; + rootId = threadInfo.rootId; + parentId = threadInfo.parentId; + } + + try { + MessageInfoExtractor messageExtractor = new MessageInfoExtractor(localStore.context, message); + String preview = messageExtractor.getMessageTextPreview(); + int attachmentCount = messageExtractor.getAttachmentCount(); + + long rootMessagePartId = saveMessageParts(db, message); + + ContentValues cv = new ContentValues(); + cv.put("message_part_id", rootMessagePartId); + cv.put("uid", uid); + cv.put("subject", message.getSubject()); + cv.put("sender_list", Address.pack(message.getFrom())); + cv.put("date", message.getSentDate() == null + ? System.currentTimeMillis() : message.getSentDate().getTime()); + cv.put("flags", this.localStore.serializeFlags(message.getFlags())); + cv.put("deleted", message.isSet(Flag.DELETED) ? 1 : 0); + cv.put("read", message.isSet(Flag.SEEN) ? 1 : 0); + cv.put("flagged", message.isSet(Flag.FLAGGED) ? 1 : 0); + cv.put("answered", message.isSet(Flag.ANSWERED) ? 1 : 0); + cv.put("forwarded", message.isSet(Flag.FORWARDED) ? 1 : 0); + cv.put("folder_id", mFolderId); + cv.put("to_list", Address.pack(message.getRecipients(RecipientType.TO))); + cv.put("cc_list", Address.pack(message.getRecipients(RecipientType.CC))); + cv.put("bcc_list", Address.pack(message.getRecipients(RecipientType.BCC))); + cv.put("preview", preview); + cv.put("reply_to_list", Address.pack(message.getReplyTo())); + cv.put("attachment_count", attachmentCount); + cv.put("internal_date", message.getInternalDate() == null + ? System.currentTimeMillis() : message.getInternalDate().getTime()); + cv.put("mime_type", message.getMimeType()); + cv.put("empty", 0); + + String messageId = message.getMessageId(); + if (messageId != null) { + cv.put("message_id", messageId); + } + + if (oldMessageId == -1) { + long msgId = db.insert("messages", "uid", cv); + + // Create entry in 'threads' table + cv.clear(); + cv.put("message_id", msgId); + + if (rootId != -1) { + cv.put("root", rootId); + } + if (parentId != -1) { + cv.put("parent", parentId); + } + + db.insert("threads", null, cv); + } else { + db.update("messages", cv, "id = ?", new String[] { Long.toString(oldMessageId) }); + } + } catch (Exception e) { + throw new MessagingException("Error appending message", e); + } } - void deleteHeaders(final long id) throws MessagingException { - this.localStore.database.execute(false, new DbCallback() { + private long saveMessageParts(SQLiteDatabase db, Message message) throws IOException, MessagingException { + long rootMessagePartId = saveMessagePart(db, new PartContainer(-1, message), -1, 0); + + Stack partsToSave = new Stack(); + addChildrenToStack(partsToSave, message, rootMessagePartId); + + int order = 1; + while (!partsToSave.isEmpty()) { + PartContainer partContainer = partsToSave.pop(); + long messagePartId = saveMessagePart(db, partContainer, rootMessagePartId, order); + order++; + + addChildrenToStack(partsToSave, partContainer.part, messagePartId); + } + + return rootMessagePartId; + } + + private long saveMessagePart(SQLiteDatabase db, PartContainer partContainer, long rootMessagePartId, int order) + throws IOException, MessagingException { + + Part part = partContainer.part; + + ContentValues cv = new ContentValues(); + if (rootMessagePartId != -1) { + cv.put("root", rootMessagePartId); + } + cv.put("parent", partContainer.parent); + cv.put("seq", order); + cv.put("server_extra", part.getServerExtra()); + + return updateOrInsertMessagePart(db, cv, part, INVALID_MESSAGE_PART_ID); + } + + private void renameTemporaryFile(File file, String messagePartId) { + File destination = localStore.getAttachmentFile(messagePartId); + if (!file.renameTo(destination)) { + Log.w(K9.LOG_TAG, "Couldn't rename temporary file " + file.getAbsolutePath() + + " to " + destination.getAbsolutePath()); + } + } + + private long updateOrInsertMessagePart(SQLiteDatabase db, ContentValues cv, Part part, long existingMessagePartId) + throws IOException, MessagingException { + byte[] headerBytes = getHeaderBytes(part); + + cv.put("mime_type", part.getMimeType()); + cv.put("header", headerBytes); + cv.put("type", MessagePartType.UNKNOWN); + + File file = null; + Body body = part.getBody(); + if (body instanceof Multipart) { + multipartToContentValues(cv, (Multipart) body); + } else if (body == null) { + missingPartToContentValues(cv, part); + } else { + file = leafPartToContentValues(cv, part, body); + } + + long messagePartId; + if (existingMessagePartId != INVALID_MESSAGE_PART_ID) { + messagePartId = existingMessagePartId; + db.update("message_parts", cv, "id = ?", new String[] { Long.toString(messagePartId) }); + } else { + messagePartId = db.insertOrThrow("message_parts", null, cv); + } + + if (file != null) { + renameTemporaryFile(file, Long.toString(messagePartId)); + } + + return messagePartId; + } + + private void multipartToContentValues(ContentValues cv, Multipart multipart) { + cv.put("data_location", DataLocation.IN_DATABASE); + cv.put("preamble", multipart.getPreamble()); + cv.put("epilogue", multipart.getEpilogue()); + cv.put("boundary", multipart.getBoundary()); + } + + private void missingPartToContentValues(ContentValues cv, Part part) throws MessagingException { + AttachmentViewInfo attachment = LocalMessageExtractor.extractAttachmentInfo(part); + cv.put("display_name", attachment.displayName); + cv.put("data_location", DataLocation.MISSING); + cv.put("decoded_body_size", attachment.size); + } + + private File leafPartToContentValues(ContentValues cv, Part part, Body body) + throws MessagingException, IOException { + AttachmentViewInfo attachment = LocalMessageExtractor.extractAttachmentInfo(part); + cv.put("display_name", attachment.displayName); + + String encoding = getTransferEncoding(part); + + if (!(body instanceof SizeAware)) { + throw new IllegalStateException("Body needs to implement SizeAware"); + } + + SizeAware sizeAwareBody = (SizeAware) body; + long fileSize = sizeAwareBody.getSize(); + + File file = null; + int dataLocation; + if (fileSize > MAX_BODY_SIZE_FOR_DATABASE) { + dataLocation = DataLocation.ON_DISK; + + file = writeBodyToDiskIfNecessary(part); + + long size = decodeAndCountBytes(file, encoding, fileSize); + cv.put("decoded_body_size", size); + } else { + dataLocation = DataLocation.IN_DATABASE; + + byte[] bodyData = getBodyBytes(body); + cv.put("data", bodyData); + + long size = decodeAndCountBytes(bodyData, encoding, bodyData.length); + cv.put("decoded_body_size", size); + } + cv.put("data_location", dataLocation); + cv.put("encoding", encoding); + cv.put("content_id", part.getContentId()); + + return file; + } + + private File writeBodyToDiskIfNecessary(Part part) throws MessagingException, IOException { + Body body = part.getBody(); + if (body instanceof BinaryTempFileBody) { + return ((BinaryTempFileBody) body).getFile(); + } else { + return writeBodyToDisk(body); + } + } + + private File writeBodyToDisk(Body body) throws IOException, MessagingException { + File file = File.createTempFile("body", null, BinaryTempFileBody.getTempDirectory()); + OutputStream out = new FileOutputStream(file); + try { + body.writeTo(out); + } finally { + out.close(); + } + + return file; + } + + private long decodeAndCountBytes(byte[] bodyData, String encoding, long fallbackValue) { + ByteArrayInputStream rawInputStream = new ByteArrayInputStream(bodyData); + return decodeAndCountBytes(rawInputStream, encoding, fallbackValue); + } + + private long decodeAndCountBytes(File file, String encoding, long fallbackValue) + throws MessagingException, IOException { + InputStream inputStream = new FileInputStream(file); + try { + return decodeAndCountBytes(inputStream, encoding, fallbackValue); + } finally { + inputStream.close(); + } + } + + private long decodeAndCountBytes(InputStream rawInputStream, String encoding, long fallbackValue) { + InputStream decodingInputStream = localStore.getDecodingInputStream(rawInputStream, encoding); + try { + CountingOutputStream countingOutputStream = new CountingOutputStream(); + try { + IOUtils.copy(decodingInputStream, countingOutputStream); + + return countingOutputStream.getCount(); + } catch (IOException e) { + return fallbackValue; + } + } finally { + try { + decodingInputStream.close(); + } catch (IOException e) { /* ignore */ } + } + } + + private byte[] getHeaderBytes(Part part) throws IOException, MessagingException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + part.writeHeaderTo(output); + return output.toByteArray(); + } + + private byte[] getBodyBytes(Body body) throws IOException, MessagingException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + body.writeTo(output); + return output.toByteArray(); + } + + private String getTransferEncoding(Part part) throws MessagingException { + String[] contentTransferEncoding = part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING); + if (contentTransferEncoding != null && contentTransferEncoding.length > 0) { + return contentTransferEncoding[0].toLowerCase(Locale.US); + } + + return MimeUtil.ENC_7BIT; + } + + private void addChildrenToStack(Stack stack, Part part, long parentMessageId) { + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + for (int i = multipart.getCount() - 1; i >= 0; i--) { + BodyPart childPart = multipart.getBodyPart(i); + stack.push(new PartContainer(parentMessageId, childPart)); + } + } + } + + private static class PartContainer { + public final long parent; + public final Part part; + + PartContainer(long parent, Part part) { + this.parent = parent; + this.part = part; + } + } + + public void addPartToMessage(final LocalMessage message, final Part part) throws MessagingException { + open(OPEN_MODE_RW); + + localStore.database.execute(false, new DbCallback() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - db.execSQL("DELETE FROM headers WHERE message_id = ?", new Object[] - { id }); + long messagePartId; + + Cursor cursor = db.query("message_parts", new String[] { "id" }, "root = ? AND server_extra = ?", + new String[] { Long.toString(message.getMessagePartId()), part.getServerExtra() }, + null, null, null); + try { + if (!cursor.moveToFirst()) { + throw new IllegalStateException("Message part not found"); + } + + messagePartId = cursor.getLong(0); + } finally { + cursor.close(); + } + + try { + updateOrInsertMessagePart(db, new ContentValues(), part, messagePartId); + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Error writing message part", e); + } + return null; } }); - } - /** - * @param messageId - * @param attachment - * @param saveAsNew - * @throws IOException - * @throws MessagingException - */ - private void saveAttachment(final long messageId, final Part attachment, final boolean saveAsNew) - throws IOException, MessagingException { - try { - this.localStore.database.execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - try { - long attachmentId = -1; - Uri contentUri = null; - int size = -1; - File tempAttachmentFile = null; - - if ((!saveAsNew) && (attachment instanceof LocalAttachmentBodyPart)) { - attachmentId = ((LocalAttachmentBodyPart) attachment).getAttachmentId(); - } - - final File attachmentDirectory = StorageManager.getInstance(LocalFolder.this.localStore.context).getAttachmentDirectory(LocalFolder.this.localStore.uUid, LocalFolder.this.localStore.database.getStorageProviderId()); - if (attachment.getBody() != null) { - Body body = attachment.getBody(); - if (body instanceof LocalAttachmentBody) { - contentUri = ((LocalAttachmentBody) body).getContentUri(); - } else if (body instanceof Message) { - // It's a message, so use Message.writeTo() to output the - // message including all children. - Message message = (Message) body; - tempAttachmentFile = File.createTempFile("att", null, attachmentDirectory); - FileOutputStream out = new FileOutputStream(tempAttachmentFile); - try { - message.writeTo(out); - } finally { - out.close(); - } - size = (int) (tempAttachmentFile.length() & 0x7FFFFFFFL); - } else { - /* - * If the attachment has a body we're expected to save it into the local store - * so we copy the data into a cached attachment file. - */ - InputStream in = MimeUtility.decodeBody(attachment.getBody()); - try { - tempAttachmentFile = File.createTempFile("att", null, attachmentDirectory); - FileOutputStream out = new FileOutputStream(tempAttachmentFile); - try { - size = IOUtils.copy(in, out); - } finally { - out.close(); - } - } finally { - try { in.close(); } catch (Throwable ignore) {} - } - } - } - - if (size == -1) { - /* - * If the attachment is not yet downloaded see if we can pull a size - * off the Content-Disposition. - */ - String disposition = attachment.getDisposition(); - if (disposition != null) { - String sizeParam = MimeUtility.getHeaderParameter(disposition, "size"); - if (sizeParam != null) { - try { - size = Integer.parseInt(sizeParam); - } catch (NumberFormatException e) { /* Ignore */ } - } - } - } - if (size == -1) { - size = 0; - } - - String storeData = - Utility.combine(attachment.getHeader( - MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA), ','); - - String name = MimeUtility.getHeaderParameter(attachment.getContentType(), "name"); - String contentId = MimeUtility.getHeaderParameter(attachment.getContentId(), null); - - String contentDisposition = MimeUtility.unfoldAndDecode(attachment.getDisposition()); - String dispositionType = contentDisposition; - - if (dispositionType != null) { - int pos = dispositionType.indexOf(';'); - if (pos != -1) { - // extract the disposition-type, "attachment", "inline" or extension-token (see the RFC 2183) - dispositionType = dispositionType.substring(0, pos); - } - } - - if (name == null && contentDisposition != null) { - name = MimeUtility.getHeaderParameter(contentDisposition, "filename"); - } - if (attachmentId == -1) { - ContentValues cv = new ContentValues(); - cv.put("message_id", messageId); - cv.put("content_uri", contentUri != null ? contentUri.toString() : null); - cv.put("store_data", storeData); - cv.put("size", size); - cv.put("name", name); - cv.put("mime_type", attachment.getMimeType()); - cv.put("content_id", contentId); - cv.put("content_disposition", dispositionType); - - attachmentId = db.insert("attachments", "message_id", cv); - } else { - ContentValues cv = new ContentValues(); - cv.put("content_uri", contentUri != null ? contentUri.toString() : null); - cv.put("size", size); - db.update("attachments", cv, "id = ?", new String[] - { Long.toString(attachmentId) }); - } - - if (attachmentId != -1 && tempAttachmentFile != null) { - File attachmentFile = new File(attachmentDirectory, Long.toString(attachmentId)); - tempAttachmentFile.renameTo(attachmentFile); - contentUri = AttachmentProvider.getAttachmentUri( - getAccount(), - attachmentId); - if (MimeUtil.isMessage(attachment.getMimeType())) { - LocalAttachmentMessageBody body = new LocalAttachmentMessageBody( - contentUri, LocalFolder.this.localStore.context); - MimeMessageHelper.setBody(attachment, body); - } else { - LocalAttachmentBody body = new LocalAttachmentBody( - contentUri, LocalFolder.this.localStore.context); - MimeMessageHelper.setBody(attachment, body); - } - ContentValues cv = new ContentValues(); - cv.put("content_uri", contentUri != null ? contentUri.toString() : null); - db.update("attachments", cv, "id = ?", new String[] - { Long.toString(attachmentId) }); - } - - /* The message has attachment with Content-ID */ - if (contentId != null && contentUri != null) { - Cursor cursor = db.query("messages", new String[] - { "html_content" }, "id = ?", new String[] - { Long.toString(messageId) }, null, null, null); - try { - if (cursor.moveToNext()) { - String htmlContent = cursor.getString(0); - - if (htmlContent != null) { - String newHtmlContent = htmlContent.replaceAll( - Pattern.quote("cid:" + contentId), - contentUri.toString()); - - ContentValues cv = new ContentValues(); - cv.put("html_content", newHtmlContent); - db.update("messages", cv, "id = ?", new String[] - { Long.toString(messageId) }); - } - } - } finally { - Utility.closeQuietly(cursor); - } - } - - if (attachmentId != -1 && attachment instanceof LocalAttachmentBodyPart) { - ((LocalAttachmentBodyPart) attachment).setAttachmentId(attachmentId); - } - return null; - } catch (MessagingException e) { - throw new WrappedException(e); - } catch (IOException e) { - throw new WrappedException(e); - } - } - }); - } catch (WrappedException e) { - final Throwable cause = e.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - } - - throw (MessagingException) cause; - } + localStore.notifyChange(); } /** @@ -1722,7 +1527,7 @@ public class LocalFolder extends Folder implements Serializable { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { db.update("messages", cv, "id = ?", new String[] - { Long.toString(message.getId()) }); + { Long.toString(message.getId()) }); return null; } }); @@ -1776,17 +1581,12 @@ public class LocalFolder extends Folder implements Serializable { public void clearMessagesOlderThan(long cutoff) throws MessagingException { open(OPEN_MODE_RO); - List messages = this.localStore.getMessages( - null, - this, - "SELECT " + LocalStore.GET_MESSAGES_COLS + - "FROM messages " + - "LEFT JOIN threads ON (threads.message_id = messages.id) " + - "WHERE (empty IS NULL OR empty != 1) AND " + - "(folder_id = ? and date < ?)", - new String[] { - Long.toString(mFolderId), Long.toString(cutoff) - }); + List messages = this.localStore.getMessages(null, this, + "SELECT " + LocalStore.GET_MESSAGES_COLS + + "FROM messages " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + + "WHERE (empty IS NULL OR empty != 1) AND (folder_id = ? and date < ?)", + new String[] { Long.toString(mFolderId), Long.toString(cutoff) }); for (Message message : messages) { message.destroy(); @@ -1805,21 +1605,18 @@ public class LocalFolder extends Folder implements Serializable { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException { try { - // Get UIDs for all messages to delete - Cursor cursor = db.query("messages", new String[] { "uid" }, + Cursor cursor = db.query("messages", new String[] { "message_part_id" }, "folder_id = ? AND (empty IS NULL OR empty != 1)", folderIdArg, null, null, null); - try { - // Delete attachments of these messages while (cursor.moveToNext()) { - deleteAttachments(cursor.getString(0)); + long messagePartId = cursor.getLong(0); + deleteMessageDataFromDisk(messagePartId); } } finally { cursor.close(); } - // Delete entries in 'threads' and 'messages' db.execSQL("DELETE FROM threads WHERE message_id IN " + "(SELECT id FROM messages WHERE folder_id = ?)", folderIdArg); db.execSQL("DELETE FROM messages WHERE folder_id = ?", folderIdArg); @@ -1851,9 +1648,9 @@ public class LocalFolder extends Folder implements Serializable { try { // We need to open the folder first to make sure we've got it's id open(OPEN_MODE_RO); - List messages = getMessages(null); - for (Message message : messages) { - deleteAttachments(message.getUid()); + List messages = getMessages(null); + for (LocalMessage message : messages) { + deleteMessageDataFromDisk(message.getMessagePartId()); } } catch (MessagingException e) { throw new WrappedException(e); @@ -1881,75 +1678,47 @@ public class LocalFolder extends Folder implements Serializable { return mName.hashCode(); } - void deleteAttachments(final long messageId) throws MessagingException { - open(OPEN_MODE_RW); - this.localStore.database.execute(false, new DbCallback() { + void deleteMessagePartsAndDataFromDisk(final long rootMessagePartId) throws MessagingException { + deleteMessageDataFromDisk(rootMessagePartId); + deleteMessageParts(rootMessagePartId); + } + + private void deleteMessageParts(final long rootMessagePartId) throws MessagingException { + localStore.database.execute(false, new DbCallback() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - Cursor attachmentsCursor = null; - try { - String accountUuid = getAccountUuid(); - Context context = LocalFolder.this.localStore.context; - - // Get attachment IDs - String[] whereArgs = new String[] { Long.toString(messageId) }; - attachmentsCursor = db.query("attachments", new String[] { "id" }, - "message_id = ?", whereArgs, null, null, null); - - final File attachmentDirectory = StorageManager.getInstance(LocalFolder.this.localStore.context) - .getAttachmentDirectory(LocalFolder.this.localStore.uUid, LocalFolder.this.localStore.database.getStorageProviderId()); - - while (attachmentsCursor.moveToNext()) { - String attachmentId = Long.toString(attachmentsCursor.getLong(0)); - try { - // Delete stored attachment - File file = new File(attachmentDirectory, attachmentId); - if (file.exists()) { - file.delete(); - } - - // Delete thumbnail file - AttachmentProvider.deleteThumbnail(context, accountUuid, - attachmentId); - } catch (Exception e) { /* ignore */ } - } - - // Delete attachment metadata from the database - db.delete("attachments", "message_id = ?", whereArgs); - } finally { - Utility.closeQuietly(attachmentsCursor); - } + db.delete("message_parts", "root = ?", new String[] { Long.toString(rootMessagePartId) }); return null; } }); } - private void deleteAttachments(final String uid) throws MessagingException { - open(OPEN_MODE_RW); - try { - this.localStore.database.execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - Cursor messagesCursor = null; - try { - messagesCursor = db.query("messages", new String[] - { "id" }, "folder_id = ? AND uid = ?", new String[] - { Long.toString(mFolderId), uid }, null, null, null); - while (messagesCursor.moveToNext()) { - long messageId = messagesCursor.getLong(0); - deleteAttachments(messageId); + private void deleteMessageDataFromDisk(final long rootMessagePartId) throws MessagingException { + localStore.database.execute(false, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { + deleteMessagePartsFromDisk(db, rootMessagePartId); + return null; + } + }); + } - } - } catch (MessagingException e) { - throw new WrappedException(e); - } finally { - Utility.closeQuietly(messagesCursor); + private void deleteMessagePartsFromDisk(SQLiteDatabase db, long rootMessagePartId) { + Cursor cursor = db.query("message_parts", new String[] { "id" }, + "root = ? AND data_location = " + DataLocation.ON_DISK, + new String[] { Long.toString(rootMessagePartId) }, null, null, null); + try { + while (cursor.moveToNext()) { + String messagePartId = cursor.getString(0); + File file = localStore.getAttachmentFile(messagePartId); + if (file.exists()) { + if (!file.delete() && K9.DEBUG) { + Log.d(K9.LOG_TAG, "Couldn't delete message part file: " + file.getAbsolutePath()); } - return null; } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); + } + } finally { + cursor.close(); } } @@ -2208,4 +1977,22 @@ public class LocalFolder extends Folder implements Serializable { private Account getAccount() { return localStore.getAccount(); } + + // Note: The contents of the 'message_parts' table depend on these values. + private static class MessagePartType { + static final int UNKNOWN = 0; + static final int ALTERNATIVE_PLAIN = 1; + static final int ALTERNATIVE_HTML = 2; + static final int TEXT = 3; + static final int RELATED = 4; + static final int ATTACHMENT = 5; + static final int HIDDEN_ATTACHMENT = 6; + } + + // Note: The contents of the 'message_parts' table depend on these values. + static class DataLocation { + static final int MISSING = 0; + static final int IN_DATABASE = 1; + static final int ON_DISK = 2; + } } diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalMessage.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalMessage.java index 48ed576d2..b8fd2c1ec 100644 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalMessage.java +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalMessage.java @@ -2,9 +2,7 @@ package com.fsck.k9.mailstore; import java.io.IOException; import java.io.OutputStream; -import java.util.ArrayList; import java.util.Date; -import java.util.List; import java.util.Set; import android.content.ContentValues; @@ -37,10 +35,11 @@ public class LocalMessage extends MimeMessage { private String mPreview = ""; private boolean mHeadersLoaded = false; - private boolean mMessageDirty = false; private long mThreadId; private long mRootId; + private long messagePartId; + private String mimeType; private LocalMessage(LocalStore localStore) { this.localStore = localStore; @@ -111,30 +110,19 @@ public class LocalMessage extends MimeMessage { setFlagInternal(Flag.FLAGGED, flagged); setFlagInternal(Flag.ANSWERED, answered); setFlagInternal(Flag.FORWARDED, forwarded); + + messagePartId = cursor.getLong(22); + mimeType = cursor.getString(23); } - /** - * Fetch the message text for display. This always returns an HTML-ified version of the - * message, even if it was originally a text-only message. - * @return HTML version of message for display purposes or null. - * @throws MessagingException - */ - public String getTextForDisplay() throws MessagingException { - String text = null; // First try and fetch an HTML part. - Part part = MimeUtility.findFirstPartByMimeType(this, "text/html"); - if (part == null) { - // If that fails, try and get a text part. - part = MimeUtility.findFirstPartByMimeType(this, "text/plain"); - if (part != null && part.getBody() instanceof LocalTextBody) { - text = ((LocalTextBody) part.getBody()).getBodyForDisplay(); - } - } else { - // We successfully found an HTML part; do the necessary character set decoding. - text = MessageExtractor.getTextFromPart(part); - } - return text; + long getMessagePartId() { + return messagePartId; } + @Override + public String getMimeType() { + return mimeType; + } /* Custom version of writeTo that updates the MIME message based on localMessage * changes. @@ -142,30 +130,13 @@ public class LocalMessage extends MimeMessage { @Override public void writeTo(OutputStream out) throws IOException, MessagingException { - if (mMessageDirty) buildMimeRepresentation(); + if (!mHeadersLoaded) { + loadHeaders(); + } + super.writeTo(out); } - void buildMimeRepresentation() throws MessagingException { - if (!mMessageDirty) { - return; - } - - super.setSubject(mSubject); - if (this.mFrom != null && this.mFrom.length > 0) { - super.setFrom(this.mFrom[0]); - } - - super.setReplyTo(mReplyTo); - super.setSentDate(this.getSentDate(), K9.hideTimeZone()); - super.setRecipients(RecipientType.TO, mTo); - super.setRecipients(RecipientType.CC, mCc); - super.setRecipients(RecipientType.BCC, mBcc); - if (mMessageId != null) super.setMessageId(mMessageId); - - mMessageDirty = false; - } - @Override public String getPreview() { return mPreview; @@ -180,14 +151,12 @@ public class LocalMessage extends MimeMessage { @Override public void setSubject(String subject) throws MessagingException { mSubject = subject; - mMessageDirty = true; } @Override public void setMessageId(String messageId) { mMessageId = messageId; - mMessageDirty = true; } @Override @@ -208,7 +177,6 @@ public class LocalMessage extends MimeMessage { @Override public void setFrom(Address from) throws MessagingException { this.mFrom = new Address[] { from }; - mMessageDirty = true; } @@ -219,7 +187,6 @@ public class LocalMessage extends MimeMessage { } else { mReplyTo = replyTo; } - mMessageDirty = true; } @@ -250,7 +217,6 @@ public class LocalMessage extends MimeMessage { } else { throw new MessagingException("Unrecognized recipient type."); } - mMessageDirty = true; } public void setFlagInternal(Flag flag, boolean set) throws MessagingException { @@ -301,23 +267,14 @@ public class LocalMessage extends MimeMessage { } /* - * If a message is being marked as deleted we want to clear out it's content - * and attachments as well. Delete will not actually remove the row since we need - * to retain the uid for synchronization purposes. + * If a message is being marked as deleted we want to clear out its content. Delete will not actually remove the + * row since we need to retain the UID for synchronization purposes. */ - private void delete() throws MessagingException - - { - /* - * Delete all of the message's content to save space. - */ + private void delete() throws MessagingException { try { - this.localStore.database.execute(true, new DbCallback() { + localStore.database.execute(true, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, - UnavailableStorageException { - String[] idArg = new String[] { Long.toString(mId) }; - + public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { ContentValues cv = new ContentValues(); cv.put("deleted", 1); cv.put("empty", 1); @@ -328,33 +285,25 @@ public class LocalMessage extends MimeMessage { cv.putNull("cc_list"); cv.putNull("bcc_list"); cv.putNull("preview"); - cv.putNull("html_content"); - cv.putNull("text_content"); cv.putNull("reply_to_list"); + cv.putNull("message_part_id"); - db.update("messages", cv, "id = ?", idArg); + db.update("messages", cv, "id = ?", new String[] { Long.toString(mId) }); - /* - * Delete all of the message's attachments to save space. - * We do this explicit deletion here because we're not deleting the record - * in messages, which means our ON DELETE trigger for messages won't cascade - */ try { - ((LocalFolder) mFolder).deleteAttachments(mId); + ((LocalFolder) mFolder).deleteMessagePartsAndDataFromDisk(messagePartId); } catch (MessagingException e) { throw new WrappedException(e); } - db.delete("attachments", "message_id = ?", idArg); return null; } }); } catch (WrappedException e) { - throw(MessagingException) e.getCause(); + throw (MessagingException) e.getCause(); } - ((LocalFolder)mFolder).deleteHeaders(mId); - this.localStore.notifyChange(); + localStore.notifyChange(); } /* @@ -372,7 +321,7 @@ public class LocalMessage extends MimeMessage { try { LocalFolder localFolder = (LocalFolder) mFolder; - localFolder.deleteAttachments(mId); + localFolder.deleteMessagePartsAndDataFromDisk(messagePartId); if (hasThreadChildren(db, mId)) { // This message has children in the thread structure so we need to @@ -500,23 +449,8 @@ public class LocalMessage extends MimeMessage { } private void loadHeaders() throws MessagingException { - List messages = new ArrayList(); - messages.add(this); - mHeadersLoaded = true; // set true before calling populate headers to stop recursion - getFolder().populateHeaders(messages); - - } - - @Override - public void addHeader(String name, String value) throws MessagingException { - if (!mHeadersLoaded) - loadHeaders(); - super.addHeader(name, value); - } - - @Override - public void addRawHeader(String name, String raw) { - throw new RuntimeException("Not supported"); + mHeadersLoaded = true; + getFolder().populateHeaders(this); } @Override @@ -557,7 +491,6 @@ public class LocalMessage extends MimeMessage { message.mSubject = mSubject; message.mPreview = mPreview; message.mHeadersLoaded = mHeadersLoaded; - message.mMessageDirty = mMessageDirty; return message; } @@ -621,4 +554,8 @@ public class LocalMessage extends MimeMessage { private String getAccountUuid() { return getAccount().getUuid(); } + + public boolean isBodyMissing() { + return getBody() == null; + } } diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalMessageExtractor.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalMessageExtractor.java index 864b9132f..2835046a4 100644 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalMessageExtractor.java +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalMessageExtractor.java @@ -1,19 +1,28 @@ package com.fsck.k9.mailstore; import android.content.Context; +import android.net.Uri; import com.fsck.k9.R; +import com.fsck.k9.crypto.DecryptedTempFileBody; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Body; import com.fsck.k9.mail.BodyPart; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; import com.fsck.k9.mail.Part; import com.fsck.k9.helper.HtmlConverter; import com.fsck.k9.mail.internet.MessageExtractor; -import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.mail.internet.MimeHeader; +import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mail.internet.Viewable; +import com.fsck.k9.mailstore.MessageViewInfo.MessageViewContainer; +import com.fsck.k9.provider.AttachmentProvider; +import com.fsck.k9.provider.K9FileProvider; +import com.fsck.k9.ui.crypto.MessageCryptoAnnotations; +import java.io.File; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -25,7 +34,7 @@ import static com.fsck.k9.mail.internet.Viewable.MessageHeader; import static com.fsck.k9.mail.internet.Viewable.Text; import static com.fsck.k9.mail.internet.Viewable.Textual; -class LocalMessageExtractor { +public class LocalMessageExtractor { private static final String TEXT_DIVIDER = "------------------------------------------------------------------------"; private static final int TEXT_DIVIDER_LENGTH = TEXT_DIVIDER.length(); @@ -33,24 +42,26 @@ class LocalMessageExtractor { private static final int FILENAME_PREFIX_LENGTH = FILENAME_PREFIX.length(); private static final String FILENAME_SUFFIX = " "; private static final int FILENAME_SUFFIX_LENGTH = FILENAME_SUFFIX.length(); + private static final OpenPgpResultAnnotation NO_ANNOTATIONS = null; private LocalMessageExtractor() {} /** * Extract the viewable textual parts of a message and return the rest as attachments. * * @param context A {@link android.content.Context} instance that will be used to get localized strings. + * @param viewables + * @param attachments * @return A {@link ViewableContainer} instance containing the textual parts of the message as * plain text and HTML, and a list of message parts considered attachments. * * @throws com.fsck.k9.mail.MessagingException * In case of an error. */ - public static ViewableContainer extractTextAndAttachments(Context context, Message message) throws MessagingException { + public static ViewableContainer extractTextAndAttachments(Context context, List viewables, + List attachments) throws MessagingException { try { - List attachments = new ArrayList(); // Collect all viewable parts - List viewables = MessageExtractor.getViewables(message, attachments); /* * Convert the tree of viewable parts into text and HTML @@ -118,35 +129,6 @@ class LocalMessageExtractor { } } - public static ViewableContainer extractPartsFromDraft(Message message) - throws MessagingException { - - Body body = message.getBody(); - if (message.isMimeType("multipart/mixed") && body instanceof MimeMultipart) { - MimeMultipart multipart = (MimeMultipart) body; - - ViewableContainer container; - int count = multipart.getCount(); - if (count >= 1) { - // The first part is either a text/plain or a multipart/alternative - BodyPart firstPart = multipart.getBodyPart(0); - container = extractTextual(firstPart); - - // The rest should be attachments - for (int i = 1; i < count; i++) { - BodyPart bodyPart = multipart.getBodyPart(i); - container.attachments.add(bodyPart); - } - } else { - container = new ViewableContainer("", "", new ArrayList()); - } - - return container; - } - - return extractTextual(message); - } - /** * Use the contents of a {@link com.fsck.k9.mail.internet.Viewable} to create the HTML to be displayed. * @@ -439,32 +421,167 @@ class LocalMessageExtractor { html.append(""); } - private static ViewableContainer extractTextual(Part part) throws MessagingException { - String text = ""; - String html = ""; - List attachments = new ArrayList(); + public static MessageViewInfo decodeMessageForView(Context context, + Message message, MessageCryptoAnnotations annotations) throws MessagingException { - Body firstBody = part.getBody(); - if (part.isMimeType("text/plain")) { - String bodyText = MessageExtractor.getTextFromPart(part); - if (bodyText != null) { - text = bodyText; - html = HtmlConverter.textToHtml(text); + // 1. break mime structure on encryption/signature boundaries + List parts = getCryptPieces(message, annotations); + + // 2. extract viewables/attachments of parts + ArrayList containers = new ArrayList(); + for (Part part : parts) { + OpenPgpResultAnnotation pgpAnnotation = annotations.get(part); + + // TODO properly handle decrypted data part - this just replaces the part + if (pgpAnnotation != NO_ANNOTATIONS && pgpAnnotation.hasOutputData()) { + part = pgpAnnotation.getOutputData(); } - } else if (part.isMimeType("multipart/alternative") && - firstBody instanceof MimeMultipart) { - MimeMultipart multipart = (MimeMultipart) firstBody; - for (BodyPart bodyPart : multipart.getBodyParts()) { - String bodyText = MessageExtractor.getTextFromPart(bodyPart); - if (bodyText != null) { - if (text.isEmpty() && bodyPart.isMimeType("text/plain")) { - text = bodyText; - } else if (html.isEmpty() && bodyPart.isMimeType("text/html")) { - html = bodyText; - } + + ArrayList attachments = new ArrayList(); + List viewables = MessageExtractor.getViewables(part, attachments); + + // 3. parse viewables into html string + ViewableContainer viewable = LocalMessageExtractor.extractTextAndAttachments(context, viewables, + attachments); + List attachmentInfos = extractAttachmentInfos(context, attachments); + + MessageViewContainer messageViewContainer = + new MessageViewContainer(viewable.html, part, attachmentInfos, pgpAnnotation); + + containers.add(messageViewContainer); + } + + return new MessageViewInfo(containers, message); + } + + public static List getCryptPieces(Message message, MessageCryptoAnnotations annotations) throws MessagingException { + + // TODO make sure this method does what it is supposed to + /* This method returns a list of mime parts which are to be parsed into + * individual MessageViewContainers for display, which each have their + * own crypto header. This means parts should be individual for each + * multipart/encrypted, multipart/signed, or a multipart/* which does + * not contain children of the former types. + */ + + + ArrayList parts = new ArrayList(); + if (!getCryptSubPieces(message, parts, annotations)) { + parts.add(message); + } + + return parts; + } + + public static boolean getCryptSubPieces(Part part, ArrayList parts, + MessageCryptoAnnotations annotations) throws MessagingException { + + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart multi = (Multipart) body; + if ("multipart/mixed".equals(part.getMimeType())) { + boolean foundSome = false; + for (BodyPart sub : multi.getBodyParts()) { + foundSome |= getCryptSubPieces(sub, parts, annotations); } + if (!foundSome) { + parts.add(part); + return true; + } + } else if (annotations.has(part)) { + parts.add(part); + return true; } } - return new ViewableContainer(text, html, attachments); + return false; + } + + private static List extractAttachmentInfos(Context context, List attachmentParts) + throws MessagingException { + + List attachments = new ArrayList(); + for (Part part : attachmentParts) { + attachments.add(extractAttachmentInfo(context, part)); + } + + return attachments; + } + + public static AttachmentViewInfo extractAttachmentInfo(Context context, Part part) throws MessagingException { + if (part instanceof LocalPart) { + LocalPart localPart = (LocalPart) part; + String accountUuid = localPart.getAccountUuid(); + long messagePartId = localPart.getId(); + String mimeType = part.getMimeType(); + String displayName = localPart.getDisplayName(); + long size = localPart.getSize(); + boolean firstClassAttachment = localPart.isFirstClassAttachment(); + Uri uri = AttachmentProvider.getAttachmentUri(accountUuid, messagePartId); + + return new AttachmentViewInfo(mimeType, displayName, size, uri, firstClassAttachment, part); + } else { + Body body = part.getBody(); + if (body instanceof DecryptedTempFileBody) { + DecryptedTempFileBody decryptedTempFileBody = (DecryptedTempFileBody) body; + File file = decryptedTempFileBody.getFile(); + Uri uri = K9FileProvider.getUriForFile(context, file, part.getMimeType()); + long size = file.length(); + return extractAttachmentInfo(part, uri, size); + } else { + throw new RuntimeException("Not supported"); + } + } + } + + public static AttachmentViewInfo extractAttachmentInfo(Part part) throws MessagingException { + return extractAttachmentInfo(part, Uri.EMPTY, AttachmentViewInfo.UNKNOWN_SIZE); + } + + private static AttachmentViewInfo extractAttachmentInfo(Part part, Uri uri, long size) throws MessagingException { + boolean firstClassAttachment = true; + + String mimeType = part.getMimeType(); + String contentTypeHeader = MimeUtility.unfoldAndDecode(part.getContentType()); + String contentDisposition = MimeUtility.unfoldAndDecode(part.getDisposition()); + + String name = MimeUtility.getHeaderParameter(contentDisposition, "filename"); + if (name == null) { + name = MimeUtility.getHeaderParameter(contentTypeHeader, "name"); + } + + if (name == null) { + firstClassAttachment = false; + String extension = MimeUtility.getExtensionByMimeType(mimeType); + name = "noname" + ((extension != null) ? "." + extension : ""); + } + + // Inline parts with a content-id are almost certainly components of an HTML message + // not attachments. Only show them if the user pressed the button to show more + // attachments. + if (contentDisposition != null && + MimeUtility.getHeaderParameter(contentDisposition, null).matches("^(?i:inline)") && + part.getHeader(MimeHeader.HEADER_CONTENT_ID) != null) { + firstClassAttachment = false; + } + + long attachmentSize = extractAttachmentSize(contentDisposition, size); + + return new AttachmentViewInfo(mimeType, name, attachmentSize, uri, firstClassAttachment, part); + } + + private static long extractAttachmentSize(String contentDisposition, long size) { + if (size != AttachmentViewInfo.UNKNOWN_SIZE) { + return size; + } + + long result = AttachmentViewInfo.UNKNOWN_SIZE; + String sizeParam = MimeUtility.getHeaderParameter(contentDisposition, "size"); + if (sizeParam != null) { + try { + result = Integer.parseInt(sizeParam); + } catch (NumberFormatException e) { /* ignore */ } + } + + return result; } } diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalPart.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalPart.java new file mode 100644 index 000000000..134212812 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalPart.java @@ -0,0 +1,11 @@ +package com.fsck.k9.mailstore; + + +public interface LocalPart { + String getAccountUuid(); + long getId(); + String getDisplayName(); + long getSize(); + boolean isFirstClassAttachment(); + LocalMessage getMessage(); +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalStore.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalStore.java index f4bb9fcf7..5c9eeb13d 100644 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalStore.java +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalStore.java @@ -21,6 +21,7 @@ import com.fsck.k9.mail.Folder; import com.fsck.k9.mail.MessageRetrievalListener; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.Store; +import com.fsck.k9.mailstore.LocalFolder.DataLocation; import com.fsck.k9.mailstore.StorageManager.StorageProvider; import com.fsck.k9.mailstore.LockableDatabase.DbCallback; import com.fsck.k9.mailstore.LockableDatabase.WrappedException; @@ -30,8 +31,16 @@ import com.fsck.k9.search.LocalSearch; import com.fsck.k9.search.SearchSpecification.Attribute; import com.fsck.k9.search.SearchSpecification.SearchField; import com.fsck.k9.search.SqlQueryBuilder; +import org.apache.james.mime4j.codec.Base64InputStream; +import org.apache.james.mime4j.codec.QuotedPrintableInputStream; +import org.apache.james.mime4j.util.MimeUtil; +import java.io.ByteArrayInputStream; import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; @@ -75,7 +84,7 @@ public class LocalStore extends Store implements Serializable { "subject, sender_list, date, uid, flags, messages.id, to_list, cc_list, " + "bcc_list, reply_to_list, attachment_count, internal_date, messages.message_id, " + "folder_id, preview, threads.id, threads.root, deleted, read, flagged, answered, " + - "forwarded "; + "forwarded, message_part_id, mime_type "; static final String GET_FOLDER_COLS = "folders.id, name, visible_limit, last_updated, status, push_state, last_pushed, " + @@ -118,7 +127,7 @@ public class LocalStore extends Store implements Serializable { */ private static final int THREAD_FLAG_UPDATE_BATCH_SIZE = 500; - public static final int DB_VERSION = 50; + public static final int DB_VERSION = 51; public static String getColumnNameForFlag(Flag flag) { @@ -275,7 +284,7 @@ public class LocalStore extends Store implements Serializable { if (K9.DEBUG) Log.i(K9.LOG_TAG, "Before prune size = " + getSize()); - pruneCachedAttachments(true); + deleteAllMessageDataFromDisk(); if (K9.DEBUG) { Log.i(K9.LOG_TAG, "After prune / before compaction size = " + getSize()); @@ -284,24 +293,16 @@ public class LocalStore extends Store implements Serializable { Log.i(K9.LOG_TAG, "After prune / before clear size = " + getSize()); } - // don't delete messages that are Local, since there is no copy on the server. - // Don't delete deleted messages. They are essentially placeholders for UIDs of messages that have - // been deleted locally. They take up insignificant space + database.execute(false, new DbCallback() { @Override public Void doDbWork(final SQLiteDatabase db) { - // Delete entries from 'threads' table - db.execSQL("DELETE FROM threads WHERE message_id IN " + - "(SELECT id FROM messages WHERE deleted = 0 AND uid NOT LIKE 'Local%')"); + // We don't care about threads of deleted messages, so delete the whole table. + db.delete("threads", null, null); - // Set 'root' and 'parent' of remaining entries in 'thread' table to 'NULL' to make - // sure the thread structure is in a valid state (this may destroy existing valid - // thread trees, but is much faster than adjusting the tree by removing messages - // one by one). - db.execSQL("UPDATE threads SET root=id, parent=NULL"); - - // Delete entries from 'messages' table - db.execSQL("DELETE FROM messages WHERE deleted = 0 AND uid NOT LIKE 'Local%'"); + // Don't delete deleted messages. They are essentially placeholders for UIDs of messages that have + // been deleted locally. + db.delete("messages", "deleted = 0", null); return null; } }); @@ -405,73 +406,39 @@ public class LocalStore extends Store implements Serializable { database.recreate(); } - /** - * Deletes all cached attachments for the entire store. - * @param force - * @throws com.fsck.k9.mail.MessagingException - */ - //TODO this method seems to be only called with force=true, simplify accordingly - private void pruneCachedAttachments(final boolean force) throws MessagingException { + private void deleteAllMessageDataFromDisk() throws MessagingException { + markAllMessagePartsDataAsMissing(); + deleteAllMessagePartsDataFromDisk(); + } + + private void markAllMessagePartsDataAsMissing() throws MessagingException { database.execute(false, new DbCallback() { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException { - if (force) { - ContentValues cv = new ContentValues(); - cv.putNull("content_uri"); - db.update("attachments", cv, null, null); - } - final StorageManager storageManager = StorageManager.getInstance(context); - File[] files = storageManager.getAttachmentDirectory(uUid, database.getStorageProviderId()).listFiles(); - for (File file : files) { - if (file.exists()) { - if (!force) { - Cursor cursor = null; - try { - cursor = db.query( - "attachments", - new String[] { "store_data" }, - "id = ?", - new String[] { file.getName() }, - null, - null, - null); - if (cursor.moveToNext()) { - if (cursor.getString(0) == null) { - if (K9.DEBUG) - Log.d(K9.LOG_TAG, "Attachment " + file.getAbsolutePath() + " has no store data, not deleting"); - /* - * If the attachment has no store data it is not recoverable, so - * we won't delete it. - */ - continue; - } - } - } finally { - Utility.closeQuietly(cursor); - } - } - if (!force) { - try { - ContentValues cv = new ContentValues(); - cv.putNull("content_uri"); - db.update("attachments", cv, "id = ?", new String[] { file.getName() }); - } catch (Exception e) { - /* - * If the row has gone away before we got to mark it not-downloaded that's - * okay. - */ - } - } - if (!file.delete()) { - file.deleteOnExit(); - } - } - } + ContentValues cv = new ContentValues(); + cv.put("data_location", DataLocation.MISSING); + db.update("message_parts", cv, null, null); + return null; } }); } + private void deleteAllMessagePartsDataFromDisk() { + final StorageManager storageManager = StorageManager.getInstance(context); + File attachmentDirectory = storageManager.getAttachmentDirectory(uUid, database.getStorageProviderId()); + File[] files = attachmentDirectory.listFiles(); + if (files == null) { + return; + } + + for (File file : files) { + if (file.exists() && !file.delete()) { + file.deleteOnExit(); + } + } + } + public void resetVisibleLimits(int visibleLimit) throws MessagingException { final ContentValues cv = new ContentValues(); cv.put("visible_limit", Integer.toString(visibleLimit)); @@ -679,40 +646,110 @@ public class LocalStore extends Store implements Serializable { return database.execute(false, new DbCallback() { @Override public AttachmentInfo doDbWork(final SQLiteDatabase db) throws WrappedException { - String name; - String type; - int size; - Cursor cursor = null; + Cursor cursor = db.query("message_parts", + new String[] { "display_name", "decoded_body_size", "mime_type" }, + "id = ?", + new String[] { attachmentId }, + null, null, null); try { - cursor = db.query( - "attachments", - new String[] { "name", "size", "mime_type" }, - "id = ?", - new String[] { attachmentId }, - null, - null, - null); if (!cursor.moveToFirst()) { return null; } - name = cursor.getString(0); - size = cursor.getInt(1); - type = cursor.getString(2); + String name = cursor.getString(0); + long size = cursor.getLong(1); + String mimeType = cursor.getString(2); + final AttachmentInfo attachmentInfo = new AttachmentInfo(); attachmentInfo.name = name; attachmentInfo.size = size; - attachmentInfo.type = type; + attachmentInfo.type = mimeType; + return attachmentInfo; } finally { - Utility.closeQuietly(cursor); + cursor.close(); } } }); } + public InputStream getAttachmentInputStream(final String attachmentId) throws MessagingException { + return database.execute(false, new DbCallback() { + @Override + public InputStream doDbWork(final SQLiteDatabase db) throws WrappedException { + Cursor cursor = db.query("message_parts", + new String[] { "data_location", "data", "encoding" }, + "id = ?", + new String[] { attachmentId }, + null, null, null); + try { + if (!cursor.moveToFirst()) { + return null; + } + + int location = cursor.getInt(0); + String encoding = cursor.getString(2); + + InputStream rawInputStream = getRawAttachmentInputStream(cursor, location, attachmentId); + return getDecodingInputStream(rawInputStream, encoding); + } finally { + cursor.close(); + } + } + }); + } + + private InputStream getRawAttachmentInputStream(Cursor cursor, int location, String attachmentId) { + switch (location) { + case DataLocation.IN_DATABASE: { + byte[] data = cursor.getBlob(1); + return new ByteArrayInputStream(data); + } + case DataLocation.ON_DISK: { + File file = getAttachmentFile(attachmentId); + try { + return new FileInputStream(file); + } catch (FileNotFoundException e) { + throw new WrappedException(e); + } + } + default: { + throw new IllegalStateException("No attachment data available"); + } + } + } + + InputStream getDecodingInputStream(final InputStream rawInputStream, String encoding) { + if (MimeUtil.ENC_BASE64.equals(encoding)) { + return new Base64InputStream(rawInputStream) { + @Override + public void close() throws IOException { + super.close(); + rawInputStream.close(); + } + }; + } + if (MimeUtil.ENC_QUOTED_PRINTABLE.equals(encoding)) { + return new QuotedPrintableInputStream(rawInputStream) { + @Override + public void close() throws IOException { + super.close(); + rawInputStream.close(); + } + }; + } + + return rawInputStream; + } + + File getAttachmentFile(String attachmentId) { + final StorageManager storageManager = StorageManager.getInstance(context); + final File attachmentDirectory = storageManager.getAttachmentDirectory(uUid, database.getStorageProviderId()); + return new File(attachmentDirectory, attachmentId); + } + public static class AttachmentInfo { public String name; - public int size; + public long size; public String type; } diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalTextBody.java b/k9mail/src/main/java/com/fsck/k9/mailstore/LocalTextBody.java deleted file mode 100644 index 57c4963cf..000000000 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/LocalTextBody.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.fsck.k9.mailstore; - -import com.fsck.k9.mail.internet.TextBody; - -class LocalTextBody extends TextBody { - /** - * This is an HTML-ified version of the message for display purposes. - */ - private final String mBodyForDisplay; - - public LocalTextBody(String body, String bodyForDisplay) { - super(body); - this.mBodyForDisplay = bodyForDisplay; - } - - public String getBodyForDisplay() { - return mBodyForDisplay; - } - -}//LocalTextBody diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/MessageHelper.java b/k9mail/src/main/java/com/fsck/k9/mailstore/MessageHelper.java new file mode 100644 index 000000000..a08612e2b --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/MessageHelper.java @@ -0,0 +1,47 @@ +package com.fsck.k9.mailstore; + + +import java.util.Stack; + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MimeBodyPart; + + +public class MessageHelper { + + public static boolean isCompletePartAvailable(Part part) { + Stack partsToCheck = new Stack(); + partsToCheck.push(part); + + while (!partsToCheck.isEmpty()) { + Part currentPart = partsToCheck.pop(); + Body body = currentPart.getBody(); + + boolean isBodyMissing = body == null; + if (isBodyMissing) { + return false; + } + + if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + for (BodyPart bodyPart : multipart.getBodyParts()) { + partsToCheck.push(bodyPart); + } + } + } + + return true; + } + + public static MimeBodyPart createEmptyPart() { + try { + return new MimeBodyPart(null); + } catch (MessagingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/MessageInfoExtractor.java b/k9mail/src/main/java/com/fsck/k9/mailstore/MessageInfoExtractor.java new file mode 100644 index 000000000..c0e5f66dc --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/MessageInfoExtractor.java @@ -0,0 +1,43 @@ +package com.fsck.k9.mailstore; + + +import java.util.ArrayList; +import java.util.List; + +import android.content.Context; + +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MessageExtractor; +import com.fsck.k9.mail.internet.Viewable; + + +class MessageInfoExtractor { + private final Context context; + private final Message message; + private List viewables; + private List attachments; + + public MessageInfoExtractor(Context context, Message message) { + this.context = context; + this.message = message; + } + + public String getMessageTextPreview() throws MessagingException { + getViewablesIfNecessary(); + return MessagePreviewExtractor.extractPreview(context, viewables); + } + + public int getAttachmentCount() throws MessagingException { + getViewablesIfNecessary(); + return attachments.size(); + } + + private void getViewablesIfNecessary() throws MessagingException { + if (viewables == null) { + attachments = new ArrayList(); + viewables = MessageExtractor.getViewables(message, attachments); + } + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/MessagePreviewExtractor.java b/k9mail/src/main/java/com/fsck/k9/mailstore/MessagePreviewExtractor.java new file mode 100644 index 000000000..bafa7c82f --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/MessagePreviewExtractor.java @@ -0,0 +1,154 @@ +package com.fsck.k9.mailstore; + + +import java.util.List; + +import android.content.Context; +import android.text.TextUtils; + +import com.fsck.k9.R; +import com.fsck.k9.helper.HtmlConverter; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MessageExtractor; +import com.fsck.k9.mail.internet.Viewable; +import com.fsck.k9.mail.internet.Viewable.Alternative; +import com.fsck.k9.mail.internet.Viewable.Html; +import com.fsck.k9.mail.internet.Viewable.MessageHeader; +import com.fsck.k9.mail.internet.Viewable.Textual; + + +class MessagePreviewExtractor { + private static final int MAX_PREVIEW_LENGTH = 512; + private static final int MAX_CHARACTERS_CHECKED_FOR_PREVIEW = 8192; + + public static String extractPreview(Context context, List viewables) { + StringBuilder text = new StringBuilder(); + boolean divider = false; + + for (Viewable viewable : viewables) { + if (viewable instanceof Textual) { + appendText(text, viewable, divider); + divider = true; + } else if (viewable instanceof MessageHeader) { + appendMessagePreview(context, text, (MessageHeader) viewable, divider); + divider = false; + } else if (viewable instanceof Alternative) { + appendAlternative(text, (Alternative) viewable, divider); + divider = true; + } + + if (hasMaxPreviewLengthBeenReached(text)) { + break; + } + } + + if (hasMaxPreviewLengthBeenReached(text)) { + text.setLength(MAX_PREVIEW_LENGTH - 1); + text.append('…'); + } + + return text.toString(); + } + + private static void appendText(StringBuilder text, Viewable viewable, boolean prependDivider) { + if (viewable instanceof Textual) { + appendTextual(text, (Textual) viewable, prependDivider); + } else if (viewable instanceof Alternative) { + appendAlternative(text, (Alternative) viewable, prependDivider); + } else { + throw new IllegalArgumentException("Unknown Viewable"); + } + } + + private static void appendTextual(StringBuilder text, Textual textual, boolean prependDivider) { + Part part = textual.getPart(); + + if (prependDivider) { + appendDivider(text); + } + + String textFromPart = MessageExtractor.getTextFromPart(part); + if (textFromPart == null) { + textFromPart = ""; + } else if (textual instanceof Html) { + textFromPart = HtmlConverter.htmlToText(textFromPart); + } + + text.append(stripTextForPreview(textFromPart)); + } + + private static void appendAlternative(StringBuilder text, Alternative alternative, boolean prependDivider) { + List textAlternative = alternative.getText().isEmpty() ? + alternative.getHtml() : alternative.getText(); + + boolean divider = prependDivider; + for (Viewable textViewable : textAlternative) { + appendText(text, textViewable, divider); + divider = true; + + if (hasMaxPreviewLengthBeenReached(text)) { + break; + } + } + } + + private static void appendMessagePreview(Context context, StringBuilder text, MessageHeader messageHeader, + boolean divider) { + if (divider) { + appendDivider(text); + } + + String subject = messageHeader.getMessage().getSubject(); + if (TextUtils.isEmpty(subject)) { + text.append(context.getString(R.string.preview_untitled_inner_message)); + } else { + text.append(context.getString(R.string.preview_inner_message, subject)); + } + } + + private static void appendDivider(StringBuilder text) { + text.append(" / "); + } + + private static String stripTextForPreview(String text) { + if (text == null) { + return ""; + } + + // Only look at the first 8k of a message when calculating + // the preview. This should avoid unnecessary + // memory usage on large messages + if (text.length() > MAX_CHARACTERS_CHECKED_FOR_PREVIEW) { + text = text.substring(0, MAX_CHARACTERS_CHECKED_FOR_PREVIEW); + } + + // Remove (correctly delimited by '-- \n') signatures + text = text.replaceAll("(?ms)^-- [\\r\\n]+.*", ""); + // try to remove lines of dashes in the preview + text = text.replaceAll("(?m)^----.*?$", ""); + // remove quoted text from the preview + text = text.replaceAll("(?m)^[#>].*$", ""); + // Remove a common quote header from the preview + text = text.replaceAll("(?m)^On .*wrote.?$", ""); + // Remove a more generic quote header from the preview + text = text.replaceAll("(?m)^.*\\w+:$", ""); + // Remove horizontal rules. + text = text.replaceAll("\\s*([-=_]{30,}+)\\s*", " "); + + // URLs in the preview should just be shown as "..." - They're not + // clickable and they usually overwhelm the preview + text = text.replaceAll("https?://\\S+", "..."); + // Don't show newlines in the preview + text = text.replaceAll("(\\r|\\n)+", " "); + // Collapse whitespace in the preview + text = text.replaceAll("\\s+", " "); + // Remove any whitespace at the beginning and end of the string. + text = text.trim(); + + return (text.length() <= MAX_PREVIEW_LENGTH) ? text : text.substring(0, MAX_PREVIEW_LENGTH); + } + + private static boolean hasMaxPreviewLengthBeenReached(StringBuilder text) { + return text.length() >= MAX_PREVIEW_LENGTH; + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/MessageViewInfo.java b/k9mail/src/main/java/com/fsck/k9/mailstore/MessageViewInfo.java new file mode 100644 index 000000000..888f59ea3 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/MessageViewInfo.java @@ -0,0 +1,36 @@ +package com.fsck.k9.mailstore; + + +import java.util.List; + +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.Part; + + +public class MessageViewInfo { + public final Message message; + public final List containers; + + + public MessageViewInfo(List containers, Message message) { + this.containers = containers; + this.message = message; + } + + + public static class MessageViewContainer { + public final String text; + public final Part rootPart; + public final List attachments; + public final OpenPgpResultAnnotation cryptoAnnotation; + + + MessageViewContainer(String text, Part rootPart, List attachments, + OpenPgpResultAnnotation cryptoAnnotation) { + this.text = text; + this.rootPart = rootPart; + this.attachments = attachments; + this.cryptoAnnotation = cryptoAnnotation; + } + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/OpenPgpResultAnnotation.java b/k9mail/src/main/java/com/fsck/k9/mailstore/OpenPgpResultAnnotation.java new file mode 100644 index 000000000..797709afa --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/OpenPgpResultAnnotation.java @@ -0,0 +1,79 @@ +package com.fsck.k9.mailstore; + + +import android.app.PendingIntent; + +import com.fsck.k9.mail.internet.MimeBodyPart; +import org.openintents.openpgp.OpenPgpError; +import org.openintents.openpgp.OpenPgpSignatureResult; + + +public final class OpenPgpResultAnnotation { + private boolean wasEncrypted; + private OpenPgpSignatureResult signatureResult; + private OpenPgpError error; + private CryptoError errorType = CryptoError.NONE; + private PendingIntent pendingIntent; + private MimeBodyPart outputData; + + public OpenPgpSignatureResult getSignatureResult() { + return signatureResult; + } + + public PendingIntent getPendingIntent() { + return pendingIntent; + } + + public void setSignatureResult(OpenPgpSignatureResult signatureResult) { + this.signatureResult = signatureResult; + } + + public void setPendingIntent(PendingIntent pendingIntent) { + this.pendingIntent = pendingIntent; + } + + public OpenPgpError getError() { + return error; + } + + public void setError(OpenPgpError error) { + this.error = error; + setErrorType(CryptoError.CRYPTO_API_RETURNED_ERROR); + } + + public CryptoError getErrorType() { + return errorType; + } + + public void setErrorType(CryptoError errorType) { + this.errorType = errorType; + } + + public boolean hasOutputData() { + return outputData != null; + } + + public void setOutputData(MimeBodyPart outputData) { + this.outputData = outputData; + } + + public MimeBodyPart getOutputData() { + return outputData; + } + + public boolean wasEncrypted() { + return wasEncrypted; + } + + public void setWasEncrypted(boolean wasEncrypted) { + this.wasEncrypted = wasEncrypted; + } + + + public static enum CryptoError { + NONE, + CRYPTO_API_RETURNED_ERROR, + SIGNED_BUT_INCOMPLETE, + ENCRYPTED_BUT_INCOMPLETE + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/StoreSchemaDefinition.java b/k9mail/src/main/java/com/fsck/k9/mailstore/StoreSchemaDefinition.java index 39bed6b3e..c8c2b187d 100644 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/StoreSchemaDefinition.java +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/StoreSchemaDefinition.java @@ -53,8 +53,6 @@ class StoreSchemaDefinition implements LockableDatabase.SchemaDefinition { Log.i(K9.LOG_TAG, String.format(Locale.US, "Upgrading database from version %d to version %d", db.getVersion(), LocalStore.DB_VERSION)); - AttachmentProvider.clear(this.localStore.context); - db.beginTransaction(); try { // schema version 29 was when we moved to incremental updates @@ -83,8 +81,6 @@ class StoreSchemaDefinition implements LockableDatabase.SchemaDefinition { "cc_list TEXT, " + "bcc_list TEXT, " + "reply_to_list TEXT, " + - "html_content TEXT, " + - "text_content TEXT, " + "attachment_count INTEGER, " + "internal_date INTEGER, " + "message_id TEXT, " + @@ -95,12 +91,36 @@ class StoreSchemaDefinition implements LockableDatabase.SchemaDefinition { "read INTEGER default 0, " + "flagged INTEGER default 0, " + "answered INTEGER default 0, " + - "forwarded INTEGER default 0" + + "forwarded INTEGER default 0, " + + "message_part_id INTEGER" + ")"); - db.execSQL("DROP TABLE IF EXISTS headers"); - db.execSQL("CREATE TABLE headers (id INTEGER PRIMARY KEY, message_id INTEGER, name TEXT, value TEXT)"); - db.execSQL("CREATE INDEX IF NOT EXISTS header_folder ON headers (message_id)"); + db.execSQL("CREATE TABLE message_parts (" + + "id INTEGER PRIMARY KEY, " + + "type INTEGER NOT NULL, " + + "root INTEGER, " + + "parent INTEGER NOT NULL, " + + "seq INTEGER NOT NULL, " + + "mime_type TEXT, " + + "decoded_body_size INTEGER, " + + "display_name TEXT, " + + "header TEXT, " + + "encoding TEXT, " + + "charset TEXT, " + + "data_location INTEGER NOT NULL, " + + "data BLOB, " + + "preamble TEXT, " + + "epilogue TEXT, " + + "boundary TEXT, " + + "content_id TEXT, " + + "server_extra TEXT" + + ")"); + + db.execSQL("CREATE TRIGGER set_message_part_root " + + "AFTER INSERT ON message_parts " + + "BEGIN " + + "UPDATE message_parts SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " + + "END"); db.execSQL("CREATE INDEX IF NOT EXISTS msg_uid ON messages (uid, folder_id)"); db.execSQL("DROP INDEX IF EXISTS msg_folder_id"); @@ -145,11 +165,6 @@ class StoreSchemaDefinition implements LockableDatabase.SchemaDefinition { "UPDATE threads SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " + "END"); - db.execSQL("DROP TABLE IF EXISTS attachments"); - db.execSQL("CREATE TABLE attachments (id INTEGER PRIMARY KEY, message_id INTEGER," - + "store_data TEXT, content_uri TEXT, size INTEGER, name TEXT," - + "mime_type TEXT, content_id TEXT, content_disposition TEXT)"); - db.execSQL("DROP TABLE IF EXISTS pending_commands"); db.execSQL("CREATE TABLE pending_commands " + "(id INTEGER PRIMARY KEY, command TEXT, arguments TEXT)"); @@ -158,8 +173,11 @@ class StoreSchemaDefinition implements LockableDatabase.SchemaDefinition { db.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;"); db.execSQL("DROP TRIGGER IF EXISTS delete_message"); - db.execSQL("CREATE TRIGGER delete_message BEFORE DELETE ON messages BEGIN DELETE FROM attachments WHERE old.id = message_id; " - + "DELETE FROM headers where old.id = message_id; END;"); + db.execSQL("CREATE TRIGGER delete_message " + + "BEFORE DELETE ON messages " + + "BEGIN " + + "DELETE FROM message_parts WHERE root = OLD.message_part_id;" + + "END"); } else { // in the case that we're starting out at 29 or newer, run all the needed updates @@ -541,6 +559,9 @@ class StoreSchemaDefinition implements LockableDatabase.SchemaDefinition { db.update("folders", cv, "name = ?", new String[] { this.localStore.getAccount().getInboxFolderName() }); } + if (db.getVersion() < 51) { + throw new IllegalStateException("Database upgrade not supported yet!"); + } } db.setVersion(LocalStore.DB_VERSION); diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/TempFileBody.java b/k9mail/src/main/java/com/fsck/k9/mailstore/TempFileBody.java index 934b3bd68..5dea69d53 100644 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/TempFileBody.java +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/TempFileBody.java @@ -7,11 +7,13 @@ import java.io.FileNotFoundException; import java.io.InputStream; import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.SizeAware; + /** * An attachment whose contents are contained in a file. */ -public class TempFileBody extends BinaryAttachmentBody { +public class TempFileBody extends BinaryAttachmentBody implements SizeAware { private final File mFile; public TempFileBody(String filename) { @@ -26,4 +28,9 @@ public class TempFileBody extends BinaryAttachmentBody { return new ByteArrayInputStream(LocalStore.EMPTY_BYTE_ARRAY); } } + + @Override + public long getSize() { + return mFile.length(); + } } diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/TempFileMessageBody.java b/k9mail/src/main/java/com/fsck/k9/mailstore/TempFileMessageBody.java index c310ae2e4..7730b20e5 100644 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/TempFileMessageBody.java +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/TempFileMessageBody.java @@ -9,8 +9,7 @@ import com.fsck.k9.mail.CompositeBody; import com.fsck.k9.mail.MessagingException; /** - * An attachment containing a body of type message/rfc822 - * whose contents are contained in a file. + * An attachment containing a body of type message/rfc822 whose contents are contained in a file. */ public class TempFileMessageBody extends TempFileBody implements CompositeBody { @@ -25,7 +24,12 @@ public class TempFileMessageBody extends TempFileBody implements CompositeBody { @Override public void setUsing7bitTransport() throws MessagingException { - // see LocalAttachmentMessageBody.setUsing7bitTransport() + /* + * There's nothing to recurse into here, so there's nothing to do. + * The enclosing BodyPart already called setEncoding(MimeUtil.ENC_7BIT). Once + * writeTo() is called, the file with the rfc822 body will be opened + * for reading and will then be recursed. + */ } @Override diff --git a/k9mail/src/main/java/com/fsck/k9/mailstore/ViewableContainer.java b/k9mail/src/main/java/com/fsck/k9/mailstore/ViewableContainer.java index 2e538cd22..64e68527e 100644 --- a/k9mail/src/main/java/com/fsck/k9/mailstore/ViewableContainer.java +++ b/k9mail/src/main/java/com/fsck/k9/mailstore/ViewableContainer.java @@ -10,7 +10,7 @@ import java.util.List; * * @see LocalMessageExtractor#extractTextAndAttachments(android.content.Context, com.fsck.k9.mail.Message) */ -class ViewableContainer { +public class ViewableContainer { /** * The viewable text of the message in plain text. */ diff --git a/k9mail/src/main/java/com/fsck/k9/message/IdentityField.java b/k9mail/src/main/java/com/fsck/k9/message/IdentityField.java new file mode 100644 index 000000000..c00dc604b --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/message/IdentityField.java @@ -0,0 +1,49 @@ +package com.fsck.k9.message; + + +// FYI, there's nothing in the code that requires these variables to one letter. They're one +// letter simply to save space. This name sucks. It's too similar to Account.Identity. +public enum IdentityField { + LENGTH("l"), + OFFSET("o"), + FOOTER_OFFSET("fo"), + PLAIN_LENGTH("pl"), + PLAIN_OFFSET("po"), + MESSAGE_FORMAT("f"), + MESSAGE_READ_RECEIPT("r"), + SIGNATURE("s"), + NAME("n"), + EMAIL("e"), + // TODO - store a reference to the message being replied so we can mark it at the time of send. + ORIGINAL_MESSAGE("m"), + CURSOR_POSITION("p"), // Where in the message your cursor was when you saved. + QUOTED_TEXT_MODE("q"), + QUOTE_STYLE("qs"); + + private final String value; + + IdentityField(String value) { + this.value = value; + } + + public String value() { + return value; + } + + /** + * Get the list of IdentityFields that should be integer values. + * + *

+ * These values are sanity checked for integer-ness during decoding. + *

+ * + * @return The list of integer {@link IdentityField}s. + */ + public static IdentityField[] getIntegerFields() { + return new IdentityField[] { LENGTH, OFFSET, FOOTER_OFFSET, PLAIN_LENGTH, PLAIN_OFFSET }; + } + + // Version identifier for "new style" identity. ! is an impossible value in base64 encoding, so we + // use that to determine which version we're in. + static final String IDENTITY_VERSION_1 = "!"; +} diff --git a/k9mail/src/main/java/com/fsck/k9/message/IdentityHeaderBuilder.java b/k9mail/src/main/java/com/fsck/k9/message/IdentityHeaderBuilder.java new file mode 100644 index 000000000..e754dba36 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/message/IdentityHeaderBuilder.java @@ -0,0 +1,181 @@ +package com.fsck.k9.message; + + +import android.net.Uri; +import android.net.Uri.Builder; +import android.util.Log; + +import com.fsck.k9.Account.QuoteStyle; +import com.fsck.k9.Identity; +import com.fsck.k9.K9; +import com.fsck.k9.activity.MessageReference; +import com.fsck.k9.mail.internet.TextBody; + + +public class IdentityHeaderBuilder { + private InsertableHtmlContent quotedHtmlContent; + private QuoteStyle quoteStyle; + private SimpleMessageFormat messageFormat; + private Identity identity; + private boolean signatureChanged; + private String signature; + private boolean identityChanged; + private QuotedTextMode quotedTextMode; + private MessageReference messageReference; + private TextBody body; + private TextBody bodyPlain; + private int cursorPosition; + + private Builder uri; + + + /** + * Build the identity header string. This string contains metadata about a draft message to be + * used upon loading a draft for composition. This should be generated at the time of saving a + * draft.
+ *
+ * This is a URL-encoded key/value pair string. The list of possible values are in {@link IdentityField}. + * + * @return Identity string. + */ + public String build() { + //FIXME: check arguments + + uri = new Uri.Builder(); + + if (body.getComposedMessageLength() != null && body.getComposedMessageOffset() != null) { + // See if the message body length is already in the TextBody. + appendValue(IdentityField.LENGTH, body.getComposedMessageLength()); + appendValue(IdentityField.OFFSET, body.getComposedMessageOffset()); + } else { + // If not, calculate it now. + appendValue(IdentityField.LENGTH, body.getText().length()); + appendValue(IdentityField.OFFSET, 0); + } + + if (quotedHtmlContent != null) { + appendValue(IdentityField.FOOTER_OFFSET, quotedHtmlContent.getFooterInsertionPoint()); + } + + if (bodyPlain != null) { + Integer composedMessageLength = bodyPlain.getComposedMessageLength(); + Integer composedMessageOffset = bodyPlain.getComposedMessageOffset(); + if (composedMessageLength != null && composedMessageOffset != null) { + // See if the message body length is already in the TextBody. + appendValue(IdentityField.PLAIN_LENGTH, composedMessageLength); + appendValue(IdentityField.PLAIN_OFFSET, composedMessageOffset); + } else { + // If not, calculate it now. + appendValue(IdentityField.PLAIN_LENGTH, body.getText().length()); + appendValue(IdentityField.PLAIN_OFFSET, 0); + } + } + + // Save the quote style (useful for forwards). + appendValue(IdentityField.QUOTE_STYLE, quoteStyle); + + // Save the message format for this offset. + appendValue(IdentityField.MESSAGE_FORMAT, messageFormat); + + // If we're not using the standard identity of signature, append it on to the identity blob. + if (identity.getSignatureUse() && signatureChanged) { + appendValue(IdentityField.SIGNATURE, signature); + } + + if (identityChanged) { + appendValue(IdentityField.NAME, identity.getName()); + appendValue(IdentityField.EMAIL, identity.getEmail()); + } + + if (messageReference != null) { + appendValue(IdentityField.ORIGINAL_MESSAGE, messageReference.toIdentityString()); + } + + appendValue(IdentityField.CURSOR_POSITION, cursorPosition); + appendValue(IdentityField.QUOTED_TEXT_MODE, quotedTextMode); + + String k9identity = IdentityField.IDENTITY_VERSION_1 + uri.build().getEncodedQuery(); + + if (K9.DEBUG) { + Log.d(K9.LOG_TAG, "Generated identity: " + k9identity); + } + + return k9identity; + } + + private void appendValue(IdentityField field, int value) { + appendValue(field, Integer.toString(value)); + } + + private void appendValue(IdentityField field, Integer value) { + appendValue(field, value.toString()); + } + + private void appendValue(IdentityField field, Enum value) { + appendValue(field, value.name()); + } + + private void appendValue(IdentityField field, String value) { + uri.appendQueryParameter(field.value(), value); + } + + public IdentityHeaderBuilder setQuotedHtmlContent(InsertableHtmlContent quotedHtmlContent) { + this.quotedHtmlContent = quotedHtmlContent; + return this; + } + + public IdentityHeaderBuilder setQuoteStyle(QuoteStyle quoteStyle) { + this.quoteStyle = quoteStyle; + return this; + } + + public IdentityHeaderBuilder setQuoteTextMode(QuotedTextMode quotedTextMode) { + this.quotedTextMode = quotedTextMode; + return this; + } + + public IdentityHeaderBuilder setMessageFormat(SimpleMessageFormat messageFormat) { + this.messageFormat = messageFormat; + return this; + } + + public IdentityHeaderBuilder setIdentity(Identity identity) { + this.identity = identity; + return this; + } + + public IdentityHeaderBuilder setIdentityChanged(boolean identityChanged) { + this.identityChanged = identityChanged; + return this; + } + + public IdentityHeaderBuilder setSignature(String signature) { + this.signature = signature; + return this; + } + + public IdentityHeaderBuilder setSignatureChanged(boolean signatureChanged) { + this.signatureChanged = signatureChanged; + return this; + } + + public IdentityHeaderBuilder setMessageReference(MessageReference messageReference) { + this.messageReference = messageReference; + return this; + } + + public IdentityHeaderBuilder setBody(TextBody body) { + this.body = body; + return this; + } + + public IdentityHeaderBuilder setBodyPlain(TextBody bodyPlain) { + this.bodyPlain = bodyPlain; + return this; + } + + public IdentityHeaderBuilder setCursorPosition(int cursorPosition) { + this.cursorPosition = cursorPosition; + return this; + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/message/IdentityHeaderParser.java b/k9mail/src/main/java/com/fsck/k9/message/IdentityHeaderParser.java new file mode 100644 index 000000000..873a3e728 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/message/IdentityHeaderParser.java @@ -0,0 +1,94 @@ +package com.fsck.k9.message; + + +import java.util.HashMap; +import java.util.Map; +import java.util.StringTokenizer; + +import android.net.Uri; +import android.util.Log; + +import com.fsck.k9.K9; +import com.fsck.k9.mail.filter.Base64; + + +public class IdentityHeaderParser { + /** + * Parse an identity string. Handles both legacy and new (!) style identities. + * + * @param identityString + * The encoded identity string that was saved in a drafts header. + * + * @return A map containing the value for each {@link IdentityField} in the identity string. + */ + public static Map parse(final String identityString) { + Map identity = new HashMap(); + + if (K9.DEBUG) { + Log.d(K9.LOG_TAG, "Decoding identity: " + identityString); + } + + if (identityString == null || identityString.length() < 1) { + return identity; + } + + // Check to see if this is a "next gen" identity. + if (identityString.charAt(0) == IdentityField.IDENTITY_VERSION_1.charAt(0) && identityString.length() > 2) { + Uri.Builder builder = new Uri.Builder(); + builder.encodedQuery(identityString.substring(1)); // Need to cut off the ! at the beginning. + Uri uri = builder.build(); + for (IdentityField key : IdentityField.values()) { + String value = uri.getQueryParameter(key.value()); + if (value != null) { + identity.put(key, value); + } + } + + if (K9.DEBUG) { + Log.d(K9.LOG_TAG, "Decoded identity: " + identity.toString()); + } + + // Sanity check our Integers so that recipients of this result don't have to. + for (IdentityField key : IdentityField.getIntegerFields()) { + if (identity.get(key) != null) { + try { + Integer.parseInt(identity.get(key)); + } catch (NumberFormatException e) { + Log.e(K9.LOG_TAG, "Invalid " + key.name() + " field in identity: " + identity.get(key)); + } + } + } + } else { + // Legacy identity + + if (K9.DEBUG) { + Log.d(K9.LOG_TAG, "Got a saved legacy identity: " + identityString); + } + StringTokenizer tokenizer = new StringTokenizer(identityString, ":", false); + + // First item is the body length. We use this to separate the composed reply from the quoted text. + if (tokenizer.hasMoreTokens()) { + String bodyLengthS = Base64.decode(tokenizer.nextToken()); + try { + identity.put(IdentityField.LENGTH, Integer.valueOf(bodyLengthS).toString()); + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Unable to parse bodyLength '" + bodyLengthS + "'"); + } + } + if (tokenizer.hasMoreTokens()) { + identity.put(IdentityField.SIGNATURE, Base64.decode(tokenizer.nextToken())); + } + if (tokenizer.hasMoreTokens()) { + identity.put(IdentityField.NAME, Base64.decode(tokenizer.nextToken())); + } + if (tokenizer.hasMoreTokens()) { + identity.put(IdentityField.EMAIL, Base64.decode(tokenizer.nextToken())); + } + if (tokenizer.hasMoreTokens()) { + identity.put(IdentityField.QUOTED_TEXT_MODE, Base64.decode(tokenizer.nextToken())); + } + } + + return identity; + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/activity/InsertableHtmlContent.java b/k9mail/src/main/java/com/fsck/k9/message/InsertableHtmlContent.java similarity index 98% rename from k9mail/src/main/java/com/fsck/k9/activity/InsertableHtmlContent.java rename to k9mail/src/main/java/com/fsck/k9/message/InsertableHtmlContent.java index 851f0ca9a..f984fa7cb 100644 --- a/k9mail/src/main/java/com/fsck/k9/activity/InsertableHtmlContent.java +++ b/k9mail/src/main/java/com/fsck/k9/message/InsertableHtmlContent.java @@ -1,4 +1,4 @@ -package com.fsck.k9.activity; +package com.fsck.k9.message; import java.io.Serializable; @@ -12,7 +12,7 @@ import java.io.Serializable; * * TODO: This container should also have a text part, along with its insertion point. Or maybe a generic InsertableContent and maintain one each for Html and Text? */ -class InsertableHtmlContent implements Serializable { +public class InsertableHtmlContent implements Serializable { private static final long serialVersionUID = 2397327034L; // Default to a headerInsertionPoint at the beginning of the message. private int headerInsertionPoint = 0; diff --git a/k9mail/src/main/java/com/fsck/k9/message/MessageBuilder.java b/k9mail/src/main/java/com/fsck/k9/message/MessageBuilder.java new file mode 100644 index 000000000..79f491ef5 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/message/MessageBuilder.java @@ -0,0 +1,450 @@ +package com.fsck.k9.message; + + +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import android.content.Context; + +import com.fsck.k9.Account.QuoteStyle; +import com.fsck.k9.Identity; +import com.fsck.k9.K9; +import com.fsck.k9.R; +import com.fsck.k9.activity.MessageReference; +import com.fsck.k9.activity.misc.Attachment; +import com.fsck.k9.crypto.PgpData; +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.Message.RecipientType; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.MimeHeader; +import com.fsck.k9.mail.internet.MimeMessage; +import com.fsck.k9.mail.internet.MimeMessageHelper; +import com.fsck.k9.mail.internet.MimeMultipart; +import com.fsck.k9.mail.internet.MimeUtility; +import com.fsck.k9.mail.internet.TextBody; +import com.fsck.k9.mailstore.TempFileBody; +import com.fsck.k9.mailstore.TempFileMessageBody; +import org.apache.james.mime4j.codec.EncoderUtil; +import org.apache.james.mime4j.util.MimeUtil; + + +public class MessageBuilder { + private final Context context; + + private String subject; + private Address[] to; + private Address[] cc; + private Address[] bcc; + private String inReplyTo; + private String references; + private boolean requestReadReceipt; + private Identity identity; + private SimpleMessageFormat messageFormat; + private String text; + private PgpData pgpData; + private List attachments; + private String signature; + private QuoteStyle quoteStyle; + private QuotedTextMode quotedTextMode; + private String quotedText; + private InsertableHtmlContent quotedHtmlContent; + private boolean isReplyAfterQuote; + private boolean isSignatureBeforeQuotedText; + private boolean identityChanged; + private boolean signatureChanged; + private int cursorPosition; + private MessageReference messageReference; + private boolean isDraft; + + + public MessageBuilder(Context context) { + this.context = context; + } + + /** + * Build the final message to be sent (or saved). If there is another message quoted in this one, it will be baked + * into the final message here. + */ + public MimeMessage build() throws MessagingException { + //FIXME: check arguments + + MimeMessage message = new MimeMessage(); + + buildHeader(message); + buildBody(message); + + return message; + } + + private void buildHeader(MimeMessage message) throws MessagingException { + message.addSentDate(new Date(), K9.hideTimeZone()); + Address from = new Address(identity.getEmail(), identity.getName()); + message.setFrom(from); + message.setRecipients(RecipientType.TO, to); + message.setRecipients(RecipientType.CC, cc); + message.setRecipients(RecipientType.BCC, bcc); + message.setSubject(subject); + + if (requestReadReceipt) { + message.setHeader("Disposition-Notification-To", from.toEncodedString()); + message.setHeader("X-Confirm-Reading-To", from.toEncodedString()); + message.setHeader("Return-Receipt-To", from.toEncodedString()); + } + + if (!K9.hideUserAgent()) { + message.setHeader("User-Agent", context.getString(R.string.message_header_mua)); + } + + final String replyTo = identity.getReplyTo(); + if (replyTo != null) { + message.setReplyTo(new Address[] { new Address(replyTo) }); + } + + if (inReplyTo != null) { + message.setInReplyTo(inReplyTo); + } + + if (references != null) { + message.setReferences(references); + } + + message.generateMessageId(); + } + + private void buildBody(MimeMessage message) throws MessagingException { + // Build the body. + // TODO FIXME - body can be either an HTML or Text part, depending on whether we're in + // HTML mode or not. Should probably fix this so we don't mix up html and text parts. + TextBody body; + if (pgpData.getEncryptedData() != null) { + String text = pgpData.getEncryptedData(); + body = new TextBody(text); + } else { + body = buildText(isDraft); + } + + // text/plain part when messageFormat == MessageFormat.HTML + TextBody bodyPlain = null; + + final boolean hasAttachments = !attachments.isEmpty(); + + if (messageFormat == SimpleMessageFormat.HTML) { + // HTML message (with alternative text part) + + // This is the compiled MIME part for an HTML message. + MimeMultipart composedMimeMessage = new MimeMultipart(); + composedMimeMessage.setSubType("alternative"); // Let the receiver select either the text or the HTML part. + composedMimeMessage.addBodyPart(new MimeBodyPart(body, "text/html")); + bodyPlain = buildText(isDraft, SimpleMessageFormat.TEXT); + composedMimeMessage.addBodyPart(new MimeBodyPart(bodyPlain, "text/plain")); + + if (hasAttachments) { + // If we're HTML and have attachments, we have a MimeMultipart container to hold the + // whole message (mp here), of which one part is a MimeMultipart container + // (composedMimeMessage) with the user's composed messages, and subsequent parts for + // the attachments. + MimeMultipart mp = new MimeMultipart(); + mp.addBodyPart(new MimeBodyPart(composedMimeMessage)); + addAttachmentsToMessage(mp); + MimeMessageHelper.setBody(message, mp); + } else { + // If no attachments, our multipart/alternative part is the only one we need. + MimeMessageHelper.setBody(message, composedMimeMessage); + } + } else if (messageFormat == SimpleMessageFormat.TEXT) { + // Text-only message. + if (hasAttachments) { + MimeMultipart mp = new MimeMultipart(); + mp.addBodyPart(new MimeBodyPart(body, "text/plain")); + addAttachmentsToMessage(mp); + MimeMessageHelper.setBody(message, mp); + } else { + // No attachments to include, just stick the text body in the message and call it good. + MimeMessageHelper.setBody(message, body); + } + } + + // If this is a draft, add metadata for thawing. + if (isDraft) { + // Add the identity to the message. + message.addHeader(K9.IDENTITY_HEADER, buildIdentityHeader(body, bodyPlain)); + } + } + + public TextBody buildText() { + return buildText(isDraft, messageFormat); + } + + private String buildIdentityHeader(TextBody body, TextBody bodyPlain) { + return new IdentityHeaderBuilder() + .setCursorPosition(cursorPosition) + .setIdentity(identity) + .setIdentityChanged(identityChanged) + .setMessageFormat(messageFormat) + .setMessageReference(messageReference) + .setQuotedHtmlContent(quotedHtmlContent) + .setQuoteStyle(quoteStyle) + .setQuoteTextMode(quotedTextMode) + .setSignature(signature) + .setSignatureChanged(signatureChanged) + .setBody(body) + .setBodyPlain(bodyPlain) + .build(); + } + + /** + * Add attachments as parts into a MimeMultipart container. + * @param mp MimeMultipart container in which to insert parts. + * @throws MessagingException + */ + private void addAttachmentsToMessage(final MimeMultipart mp) throws MessagingException { + Body body; + for (Attachment attachment : attachments) { + if (attachment.state != Attachment.LoadingState.COMPLETE) { + continue; + } + + String contentType = attachment.contentType; + if (MimeUtil.isMessage(contentType)) { + body = new TempFileMessageBody(attachment.filename); + } else { + body = new TempFileBody(attachment.filename); + } + MimeBodyPart bp = new MimeBodyPart(body); + + /* + * Correctly encode the filename here. Otherwise the whole + * header value (all parameters at once) will be encoded by + * MimeHeader.writeTo(). + */ + bp.addHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\r\n name=\"%s\"", + contentType, + EncoderUtil.encodeIfNecessary(attachment.name, + EncoderUtil.Usage.WORD_ENTITY, 7))); + + bp.setEncoding(MimeUtility.getEncodingforType(contentType)); + + /* + * TODO: Oh the joys of MIME... + * + * From RFC 2183 (The Content-Disposition Header Field): + * "Parameter values longer than 78 characters, or which + * contain non-ASCII characters, MUST be encoded as specified + * in [RFC 2184]." + * + * Example: + * + * Content-Type: application/x-stuff + * title*1*=us-ascii'en'This%20is%20even%20more%20 + * title*2*=%2A%2A%2Afun%2A%2A%2A%20 + * title*3="isn't it!" + */ + bp.addHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, String.format(Locale.US, + "attachment;\r\n filename=\"%s\";\r\n size=%d", + attachment.name, attachment.size)); + + mp.addBodyPart(bp); + } + } + + /** + * Build the Body that will contain the text of the message. We'll decide where to + * include it later. Draft messages are treated somewhat differently in that signatures are not + * appended and HTML separators between composed text and quoted text are not added. + * @param isDraft If we should build a message that will be saved as a draft (as opposed to sent). + */ + private TextBody buildText(boolean isDraft) { + return buildText(isDraft, messageFormat); + } + + /** + * Build the {@link Body} that will contain the text of the message. + * + *

+ * Draft messages are treated somewhat differently in that signatures are not appended and HTML + * separators between composed text and quoted text are not added. + *

+ * + * @param isDraft + * If {@code true} we build a message that will be saved as a draft (as opposed to + * sent). + * @param simpleMessageFormat + * Specifies what type of message to build ({@code text/plain} vs. {@code text/html}). + * + * @return {@link TextBody} instance that contains the entered text and possibly the quoted + * original message. + */ + private TextBody buildText(boolean isDraft, SimpleMessageFormat simpleMessageFormat) { + String messageText = text; + + TextBodyBuilder textBodyBuilder = new TextBodyBuilder(messageText); + + /* + * Find out if we need to include the original message as quoted text. + * + * We include the quoted text in the body if the user didn't choose to + * hide it. We always include the quoted text when we're saving a draft. + * That's so the user is able to "un-hide" the quoted text if (s)he + * opens a saved draft. + */ + boolean includeQuotedText = (isDraft || quotedTextMode == QuotedTextMode.SHOW); + boolean isReplyAfterQuote = (quoteStyle == QuoteStyle.PREFIX && this.isReplyAfterQuote); + + textBodyBuilder.setIncludeQuotedText(false); + if (includeQuotedText) { + if (simpleMessageFormat == SimpleMessageFormat.HTML && quotedHtmlContent != null) { + textBodyBuilder.setIncludeQuotedText(true); + textBodyBuilder.setQuotedTextHtml(quotedHtmlContent); + textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote); + } + + if (simpleMessageFormat == SimpleMessageFormat.TEXT && quotedText.length() > 0) { + textBodyBuilder.setIncludeQuotedText(true); + textBodyBuilder.setQuotedText(quotedText); + textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote); + } + } + + textBodyBuilder.setInsertSeparator(!isDraft); + + boolean useSignature = (!isDraft && identity.getSignatureUse()); + if (useSignature) { + textBodyBuilder.setAppendSignature(true); + textBodyBuilder.setSignature(signature); + textBodyBuilder.setSignatureBeforeQuotedText(isSignatureBeforeQuotedText); + } else { + textBodyBuilder.setAppendSignature(false); + } + + TextBody body; + if (simpleMessageFormat == SimpleMessageFormat.HTML) { + body = textBodyBuilder.buildTextHtml(); + } else { + body = textBodyBuilder.buildTextPlain(); + } + return body; + } + + public MessageBuilder setSubject(String subject) { + this.subject = subject; + return this; + } + + public MessageBuilder setTo(Address[] to) { + this.to = to; + return this; + } + + public MessageBuilder setCc(Address[] cc) { + this.cc = cc; + return this; + } + + public MessageBuilder setBcc(Address[] bcc) { + this.bcc = bcc; + return this; + } + + public MessageBuilder setInReplyTo(String inReplyTo) { + this.inReplyTo = inReplyTo; + return this; + } + + public MessageBuilder setReferences(String references) { + this.references = references; + return this; + } + + public MessageBuilder setRequestReadReceipt(boolean requestReadReceipt) { + this.requestReadReceipt = requestReadReceipt; + return this; + } + + public MessageBuilder setIdentity(Identity identity) { + this.identity = identity; + return this; + } + + public MessageBuilder setMessageFormat(SimpleMessageFormat messageFormat) { + this.messageFormat = messageFormat; + return this; + } + + public MessageBuilder setText(String text) { + this.text = text; + return this; + } + + public MessageBuilder setPgpData(PgpData pgpData) { + this.pgpData = pgpData; + return this; + } + + public MessageBuilder setAttachments(List attachments) { + this.attachments = attachments; + return this; + } + + public MessageBuilder setSignature(String signature) { + this.signature = signature; + return this; + } + + public MessageBuilder setQuoteStyle(QuoteStyle quoteStyle) { + this.quoteStyle = quoteStyle; + return this; + } + + public MessageBuilder setQuotedTextMode(QuotedTextMode quotedTextMode) { + this.quotedTextMode = quotedTextMode; + return this; + } + + public MessageBuilder setQuotedText(String quotedText) { + this.quotedText = quotedText; + return this; + } + + public MessageBuilder setQuotedHtmlContent(InsertableHtmlContent quotedHtmlContent) { + this.quotedHtmlContent = quotedHtmlContent; + return this; + } + + public MessageBuilder setReplyAfterQuote(boolean isReplyAfterQuote) { + this.isReplyAfterQuote = isReplyAfterQuote; + return this; + } + + public MessageBuilder setSignatureBeforeQuotedText(boolean isSignatureBeforeQuotedText) { + this.isSignatureBeforeQuotedText = isSignatureBeforeQuotedText; + return this; + } + + public MessageBuilder setIdentityChanged(boolean identityChanged) { + this.identityChanged = identityChanged; + return this; + } + + public MessageBuilder setSignatureChanged(boolean signatureChanged) { + this.signatureChanged = signatureChanged; + return this; + } + + public MessageBuilder setCursorPosition(int cursorPosition) { + this.cursorPosition = cursorPosition; + return this; + } + + public MessageBuilder setMessageReference(MessageReference messageReference) { + this.messageReference = messageReference; + return this; + } + + public MessageBuilder setDraft(boolean isDraft) { + this.isDraft = isDraft; + return this; + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/message/QuotedTextMode.java b/k9mail/src/main/java/com/fsck/k9/message/QuotedTextMode.java new file mode 100644 index 000000000..2e31ef057 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/message/QuotedTextMode.java @@ -0,0 +1,8 @@ +package com.fsck.k9.message; + + +public enum QuotedTextMode { + NONE, + SHOW, + HIDE +} diff --git a/k9mail/src/main/java/com/fsck/k9/message/SimpleMessageFormat.java b/k9mail/src/main/java/com/fsck/k9/message/SimpleMessageFormat.java new file mode 100644 index 000000000..5d82d0df2 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/message/SimpleMessageFormat.java @@ -0,0 +1,7 @@ +package com.fsck.k9.message; + + +public enum SimpleMessageFormat { + TEXT, + HTML +} diff --git a/k9mail/src/main/java/com/fsck/k9/activity/TextBodyBuilder.java b/k9mail/src/main/java/com/fsck/k9/message/TextBodyBuilder.java similarity index 99% rename from k9mail/src/main/java/com/fsck/k9/activity/TextBodyBuilder.java rename to k9mail/src/main/java/com/fsck/k9/message/TextBodyBuilder.java index 4dc0a0666..0aec41258 100644 --- a/k9mail/src/main/java/com/fsck/k9/activity/TextBodyBuilder.java +++ b/k9mail/src/main/java/com/fsck/k9/message/TextBodyBuilder.java @@ -1,4 +1,4 @@ -package com.fsck.k9.activity; +package com.fsck.k9.message; import android.text.TextUtils; import android.util.Log; diff --git a/k9mail/src/main/java/com/fsck/k9/provider/AttachmentProvider.java b/k9mail/src/main/java/com/fsck/k9/provider/AttachmentProvider.java index 89d1fec69..7bb9c41b1 100644 --- a/k9mail/src/main/java/com/fsck/k9/provider/AttachmentProvider.java +++ b/k9mail/src/main/java/com/fsck/k9/provider/AttachmentProvider.java @@ -1,15 +1,19 @@ package com.fsck.k9.provider; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + import android.content.ContentProvider; import android.content.ContentValues; -import android.content.Context; import android.database.Cursor; import android.database.MatrixCursor; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.util.Log; + import com.fsck.k9.Account; import com.fsck.k9.BuildConfig; import com.fsck.k9.K9; @@ -18,30 +22,19 @@ import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mailstore.LocalStore; import com.fsck.k9.mailstore.LocalStore.AttachmentInfo; -import com.fsck.k9.mailstore.StorageManager; +import org.openintents.openpgp.util.ParcelFileDescriptorUtil; -import java.io.*; -import java.util.List; /** * A simple ContentProvider that allows file access to attachments. - * - *

- * Warning! We make heavy assumptions about the Uris used by the {@link LocalStore} for an - * {@link Account} here. - *

*/ public class AttachmentProvider extends ContentProvider { private static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".attachmentprovider"; public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY); - private static final String FORMAT_RAW = "RAW"; - private static final String FORMAT_VIEW = "VIEW"; - private static final String FORMAT_THUMBNAIL = "THUMBNAIL"; - private static final String[] DEFAULT_PROJECTION = new String[] { - AttachmentProviderColumns._ID, - AttachmentProviderColumns.DATA, + AttachmentProviderColumns._ID, + AttachmentProviderColumns.DATA, }; public static class AttachmentProviderColumns { @@ -52,171 +45,49 @@ public class AttachmentProvider extends ContentProvider { } - public static Uri getAttachmentUri(Account account, long id) { + public static Uri getAttachmentUri(String accountUuid, long id) { return CONTENT_URI.buildUpon() - .appendPath(account.getUuid()) + .appendPath(accountUuid) .appendPath(Long.toString(id)) - .appendPath(FORMAT_RAW) .build(); } - public static Uri getAttachmentUriForViewing(Account account, long id, String mimeType, String filename) { - return CONTENT_URI.buildUpon() - .appendPath(account.getUuid()) - .appendPath(Long.toString(id)) - .appendPath(FORMAT_VIEW) - .appendPath(mimeType) - .appendPath(filename) - .build(); - } - - public static Uri getAttachmentThumbnailUri(Account account, long id, int width, int height) { - return CONTENT_URI.buildUpon() - .appendPath(account.getUuid()) - .appendPath(Long.toString(id)) - .appendPath(FORMAT_THUMBNAIL) - .appendPath(Integer.toString(width)) - .appendPath(Integer.toString(height)) - .build(); - } - - public static void clear(Context context) { - /* - * We use the cache dir as a temporary directory (since Android doesn't give us one) so - * on startup we'll clean up any .tmp files from the last run. - */ - File[] files = context.getCacheDir().listFiles(); - for (File file : files) { - try { - if (K9.DEBUG) { - Log.d(K9.LOG_TAG, "Deleting file " + file.getCanonicalPath()); - } - } catch (IOException ioe) { /* No need to log failure to log */ } - file.delete(); - } - } - - /** - * Delete the thumbnail of an attachment. - * - * @param context - * The application context. - * @param accountUuid - * The UUID of the account the attachment belongs to. - * @param attachmentId - * The ID of the attachment the thumbnail was created for. - */ - public static void deleteThumbnail(Context context, String accountUuid, String attachmentId) { - File file = getThumbnailFile(context, accountUuid, attachmentId); - if (file.exists()) { - file.delete(); - } - } - - private static File getThumbnailFile(Context context, String accountUuid, - String attachmentId) { - String filename = "thmb_" + accountUuid + "_" + attachmentId + ".tmp"; - File dir = context.getCacheDir(); - return new File(dir, filename); - } - - @Override public boolean onCreate() { - /* - * We use the cache dir as a temporary directory (since Android doesn't give us one) so - * on startup we'll clean up any .tmp files from the last run. - */ - final File cacheDir = getContext().getCacheDir(); - if (cacheDir == null) { - return true; - } - File[] files = cacheDir.listFiles(); - if (files == null) { - return true; - } - for (File file : files) { - if (file.getName().endsWith(".tmp")) { - file.delete(); - } - } - return true; } @Override public String getType(Uri uri) { List segments = uri.getPathSegments(); - String dbName = segments.get(0); + String accountUuid = segments.get(0); String id = segments.get(1); - String format = segments.get(2); - String mimeType = (segments.size() < 4) ? null : segments.get(3); + String mimeType = (segments.size() < 3) ? null : segments.get(2); - return getType(dbName, id, format, mimeType); + return getType(accountUuid, id, mimeType); } @Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { - File file; - List segments = uri.getPathSegments(); String accountUuid = segments.get(0); String attachmentId = segments.get(1); - String format = segments.get(2); - if (FORMAT_THUMBNAIL.equals(format)) { - int width = Integer.parseInt(segments.get(3)); - int height = Integer.parseInt(segments.get(4)); - - file = getThumbnailFile(getContext(), accountUuid, attachmentId); - if (!file.exists()) { - String type = getType(accountUuid, attachmentId, FORMAT_VIEW, null); - try { - FileInputStream in = new FileInputStream(getFile(accountUuid, attachmentId)); - try { - Bitmap thumbnail = createThumbnail(type, in); - if (thumbnail != null) { - thumbnail = Bitmap.createScaledBitmap(thumbnail, width, height, true); - FileOutputStream out = new FileOutputStream(file); - try { - thumbnail.compress(Bitmap.CompressFormat.PNG, 100, out); - } finally { - out.close(); - } - } - } finally { - try { in.close(); } catch (Throwable ignore) { /* ignore */ } - } - } catch (IOException ioe) { - return null; - } - } - } else { - file = getFile(accountUuid, attachmentId); - } - - return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + return openAttachment(accountUuid, attachmentId); } @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, - String sortOrder) { + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { String[] columnNames = (projection == null) ? DEFAULT_PROJECTION : projection; List segments = uri.getPathSegments(); - String dbName = segments.get(0); + String accountUuid = segments.get(0); String id = segments.get(1); - // Versions of K-9 before 3.400 had a database name here, not an - // account UID, so implement a bit of backcompat - if (dbName.endsWith(".db")) { - dbName = dbName.substring(0, dbName.length() - 3); - } - final AttachmentInfo attachmentInfo; try { - final Account account = Preferences.getPreferences(getContext()).getAccount(dbName); + final Account account = Preferences.getPreferences(getContext()).getAccount(accountUuid); attachmentInfo = LocalStore.getInstance(account, getContext()).getAttachmentInfo(id); } catch (MessagingException e) { Log.e(K9.LOG_TAG, "Unable to retrieve attachment info from local store for ID: " + id, e); @@ -263,66 +134,43 @@ public class AttachmentProvider extends ContentProvider { return null; } - private String getType(String dbName, String id, String format, String mimeType) { + private String getType(String accountUuid, String id, String mimeType) { String type; - if (FORMAT_THUMBNAIL.equals(format)) { - type = "image/png"; - } else { - final Account account = Preferences.getPreferences(getContext()).getAccount(dbName); + final Account account = Preferences.getPreferences(getContext()).getAccount(accountUuid); - try { - final LocalStore localStore = LocalStore.getInstance(account, getContext()); + try { + final LocalStore localStore = LocalStore.getInstance(account, getContext()); - AttachmentInfo attachmentInfo = localStore.getAttachmentInfo(id); - if (FORMAT_VIEW.equals(format) && mimeType != null) { - type = mimeType; - } else { - type = attachmentInfo.type; - } - } catch (MessagingException e) { - Log.e(K9.LOG_TAG, "Unable to retrieve LocalStore for " + account, e); - type = null; + AttachmentInfo attachmentInfo = localStore.getAttachmentInfo(id); + if (mimeType != null) { + type = mimeType; + } else { + type = attachmentInfo.type; } + } catch (MessagingException e) { + Log.e(K9.LOG_TAG, "Unable to retrieve LocalStore for " + account, e); + type = MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE; } return type; } - private File getFile(String dbName, String id) throws FileNotFoundException { - Account account = Preferences.getPreferences(getContext()).getAccount(dbName); - - File attachmentsDir = StorageManager.getInstance(getContext()).getAttachmentDirectory(dbName, - account.getLocalStorageProviderId()); - - File file = new File(attachmentsDir, id); - if (!file.exists()) { - throw new FileNotFoundException(file.getAbsolutePath()); - } - - return file; - } - - private Bitmap createThumbnail(String type, InputStream data) { - if (MimeUtility.mimeTypeMatches(type, "image/*")) { - return createImageThumbnail(data); - } - return null; - } - - private Bitmap createImageThumbnail(InputStream data) { + private ParcelFileDescriptor openAttachment(String accountUuid, String attachmentId) { try { - Bitmap bitmap = BitmapFactory.decodeStream(data); - return bitmap; - } catch (OutOfMemoryError oome) { - /* - * Improperly downloaded images, corrupt bitmaps and the like can commonly - * cause OOME due to invalid allocation sizes. We're happy with a null bitmap in - * that case. If the system is really out of memory we'll know about it soon - * enough. - */ + InputStream inputStream = getAttachmentInputStream(accountUuid, attachmentId); + return ParcelFileDescriptorUtil.pipeFrom(inputStream, null); + } catch (MessagingException e) { + Log.e(K9.LOG_TAG, "Error getting InputStream for attachment", e); return null; - } catch (Exception e) { + } catch (IOException e) { + Log.e(K9.LOG_TAG, "Error creating ParcelFileDescriptor", e); return null; } } + + private InputStream getAttachmentInputStream(String accountUuid, String attachmentId) throws MessagingException { + final Account account = Preferences.getPreferences(getContext()).getAccount(accountUuid); + LocalStore localStore = LocalStore.getInstance(account, getContext()); + return localStore.getAttachmentInputStream(attachmentId); + } } diff --git a/k9mail/src/main/java/com/fsck/k9/provider/EmailProvider.java b/k9mail/src/main/java/com/fsck/k9/provider/EmailProvider.java index 87c9b69fa..9b63a5865 100644 --- a/k9mail/src/main/java/com/fsck/k9/provider/EmailProvider.java +++ b/k9mail/src/main/java/com/fsck/k9/provider/EmailProvider.java @@ -153,8 +153,6 @@ public class EmailProvider extends ContentProvider { private interface InternalMessageColumns extends MessageColumns { public static final String DELETED = "deleted"; public static final String EMPTY = "empty"; - public static final String TEXT_CONTENT = "text_content"; - public static final String HTML_CONTENT = "html_content"; public static final String MIME_TYPE = "mime_type"; } diff --git a/k9mail/src/main/java/com/fsck/k9/provider/K9FileProvider.java b/k9mail/src/main/java/com/fsck/k9/provider/K9FileProvider.java new file mode 100644 index 000000000..3d032b19d --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/provider/K9FileProvider.java @@ -0,0 +1,25 @@ +package com.fsck.k9.provider; + + +import java.io.File; + +import android.content.Context; +import android.net.Uri; +import android.support.v4.content.FileProvider; + +import com.fsck.k9.BuildConfig; + + +public class K9FileProvider extends FileProvider { + private static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".fileprovider"; + + public static Uri getUriForFile(Context context, File file, String mimeType) { + Uri uri = FileProvider.getUriForFile(context, AUTHORITY, file); + return uri.buildUpon().appendQueryParameter("mime_type", mimeType).build(); + } + + @Override + public String getType(Uri uri) { + return uri.getQueryParameter("mime_type"); + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java b/k9mail/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java index d9cbccb87..5ebbe7caa 100644 --- a/k9mail/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java +++ b/k9mail/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java @@ -140,8 +140,7 @@ public class SqlQueryBuilder { break; } case MESSAGE_CONTENTS: { - columnName = "text_content"; - break; + throw new RuntimeException("Searching in message bodies is currently not supported"); } case REPLY_TO: { columnName = "reply_to_list"; diff --git a/k9mail/src/main/java/com/fsck/k9/ui/EolConvertingEditText.java b/k9mail/src/main/java/com/fsck/k9/ui/EolConvertingEditText.java new file mode 100644 index 000000000..b7fd8e09a --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/EolConvertingEditText.java @@ -0,0 +1,37 @@ +package com.fsck.k9.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.EditText; + +/** + * An {@link android.widget.EditText} extension with methods that convert line endings from + * {@code \r\n} to {@code \n} and back again when setting and getting text. + * + */ +public class EolConvertingEditText extends EditText { + + public EolConvertingEditText(Context context, AttributeSet attrs) { + super(context, attrs); + } + + /** + * Return the text the EolConvertingEditText is displaying. + * + * @return A string with any line endings converted to {@code \r\n}. + */ + public String getCharacters() { + return getText().toString().replace("\n", "\r\n"); + } + + /** + * Sets the string value of the EolConvertingEditText. Any line endings + * in the string will be converted to {@code \n}. + * + * @param text + */ + public void setCharacters(CharSequence text) { + setText(text.toString().replace("\r\n", "\n")); + } + +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoAnnotations.java b/k9mail/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoAnnotations.java new file mode 100644 index 000000000..81a90e14a --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoAnnotations.java @@ -0,0 +1,28 @@ +package com.fsck.k9.ui.crypto; + + +import java.util.HashMap; + +import com.fsck.k9.mail.Part; +import com.fsck.k9.mailstore.OpenPgpResultAnnotation; + + +public class MessageCryptoAnnotations { + private HashMap annotations = new HashMap(); + + MessageCryptoAnnotations() { + // Package-private constructor + } + + void put(Part part, OpenPgpResultAnnotation annotation) { + annotations.put(part, annotation); + } + + public OpenPgpResultAnnotation get(Part part) { + return annotations.get(part); + } + + public boolean has(Part part) { + return annotations.containsKey(part); + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoCallback.java b/k9mail/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoCallback.java new file mode 100644 index 000000000..b787f9211 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoCallback.java @@ -0,0 +1,6 @@ +package com.fsck.k9.ui.crypto; + + +public interface MessageCryptoCallback { + void onCryptoOperationsFinished(MessageCryptoAnnotations annotations); +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoHelper.java b/k9mail/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoHelper.java new file mode 100644 index 000000000..695785fce --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoHelper.java @@ -0,0 +1,478 @@ +package com.fsck.k9.ui.crypto; + + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.IntentSender.SendIntentException; +import android.os.AsyncTask; +import android.util.Log; + +import com.fsck.k9.Account; +import com.fsck.k9.Identity; +import com.fsck.k9.K9; +import com.fsck.k9.crypto.MessageDecryptVerifier; +import com.fsck.k9.crypto.OpenPgpApiHelper; +import com.fsck.k9.helper.IdentityHelper; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MessageExtractor; +import com.fsck.k9.mail.internet.MimeBodyPart; +import com.fsck.k9.mail.internet.TextBody; +import com.fsck.k9.mailstore.DecryptStreamParser; +import com.fsck.k9.mailstore.LocalMessage; +import com.fsck.k9.mailstore.MessageHelper; +import com.fsck.k9.mailstore.OpenPgpResultAnnotation; +import com.fsck.k9.mailstore.OpenPgpResultAnnotation.CryptoError; +import org.openintents.openpgp.IOpenPgpService; +import org.openintents.openpgp.OpenPgpError; +import org.openintents.openpgp.OpenPgpSignatureResult; +import org.openintents.openpgp.util.OpenPgpApi; +import org.openintents.openpgp.util.OpenPgpApi.IOpenPgpCallback; +import org.openintents.openpgp.util.OpenPgpServiceConnection; +import org.openintents.openpgp.util.OpenPgpServiceConnection.OnBound; + + +public class MessageCryptoHelper { + private static final int REQUEST_CODE_CRYPTO = 1000; + private static final int INVALID_OPENPGP_RESULT_CODE = -1; + private static final MimeBodyPart NO_REPLACEMENT_PART = null; + + + private final Context context; + private final Activity activity; + private final MessageCryptoCallback callback; + private final Account account; + private LocalMessage message; + + private Deque partsToDecryptOrVerify = new ArrayDeque(); + private OpenPgpApi openPgpApi; + private CryptoPart currentCryptoPart; + private Intent currentCryptoResult; + + private MessageCryptoAnnotations messageAnnotations; + + + public MessageCryptoHelper(Activity activity, Account account, MessageCryptoCallback callback) { + this.context = activity.getApplicationContext(); + this.activity = activity; + this.callback = callback; + this.account = account; + + this.messageAnnotations = new MessageCryptoAnnotations(); + } + + public void decryptOrVerifyMessagePartsIfNecessary(LocalMessage message) { + this.message = message; + + if (!account.isOpenPgpProviderConfigured()) { + returnResultToFragment(); + return; + } + + List encryptedParts = MessageDecryptVerifier.findEncryptedParts(message); + processFoundParts(encryptedParts, CryptoPartType.ENCRYPTED, CryptoError.ENCRYPTED_BUT_INCOMPLETE, + MessageHelper.createEmptyPart()); + + List signedParts = MessageDecryptVerifier.findSignedParts(message); + processFoundParts(signedParts, CryptoPartType.SIGNED, CryptoError.SIGNED_BUT_INCOMPLETE, NO_REPLACEMENT_PART); + + List inlineParts = MessageDecryptVerifier.findPgpInlineParts(message); + addFoundInlinePgpParts(inlineParts); + + decryptOrVerifyNextPart(); + } + + private void processFoundParts(List foundParts, CryptoPartType cryptoPartType, CryptoError errorIfIncomplete, + MimeBodyPart replacementPart) { + for (Part part : foundParts) { + if (MessageHelper.isCompletePartAvailable(part)) { + CryptoPart cryptoPart = new CryptoPart(cryptoPartType, part); + partsToDecryptOrVerify.add(cryptoPart); + } else { + addErrorAnnotation(part, errorIfIncomplete, replacementPart); + } + } + } + + private void addErrorAnnotation(Part part, CryptoError error, MimeBodyPart outputData) { + OpenPgpResultAnnotation annotation = new OpenPgpResultAnnotation(); + annotation.setErrorType(error); + annotation.setOutputData(outputData); + messageAnnotations.put(part, annotation); + } + + private void addFoundInlinePgpParts(List foundParts) { + for (Part part : foundParts) { + CryptoPart cryptoPart = new CryptoPart(CryptoPartType.INLINE_PGP, part); + partsToDecryptOrVerify.add(cryptoPart); + } + } + + private void decryptOrVerifyNextPart() { + if (partsToDecryptOrVerify.isEmpty()) { + returnResultToFragment(); + return; + } + + CryptoPart cryptoPart = partsToDecryptOrVerify.peekFirst(); + startDecryptingOrVerifyingPart(cryptoPart); + } + + private void startDecryptingOrVerifyingPart(CryptoPart cryptoPart) { + if (!isBoundToCryptoProviderService()) { + connectToCryptoProviderService(); + } else { + decryptOrVerifyPart(cryptoPart); + } + } + + private boolean isBoundToCryptoProviderService() { + return openPgpApi != null; + } + + private void connectToCryptoProviderService() { + String openPgpProvider = account.getOpenPgpProvider(); + new OpenPgpServiceConnection(context, openPgpProvider, + new OnBound() { + @Override + public void onBound(IOpenPgpService service) { + openPgpApi = new OpenPgpApi(context, service); + + decryptOrVerifyNextPart(); + } + + @Override + public void onError(Exception e) { + Log.e(K9.LOG_TAG, "Couldn't connect to OpenPgpService", e); + } + }).bindToService(); + } + + private void decryptOrVerifyPart(CryptoPart cryptoPart) { + currentCryptoPart = cryptoPart; + decryptVerify(new Intent()); + } + + private void decryptVerify(Intent intent) { + intent.setAction(OpenPgpApi.ACTION_DECRYPT_VERIFY); + + Identity identity = IdentityHelper.getRecipientIdentityFromMessage(account, message); + String accountName = OpenPgpApiHelper.buildAccountName(identity); + intent.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, accountName); + + try { + CryptoPartType cryptoPartType = currentCryptoPart.type; + switch (cryptoPartType) { + case SIGNED: { + callAsyncDetachedVerify(intent); + return; + } + case ENCRYPTED: { + callAsyncDecrypt(intent); + return; + } + case INLINE_PGP: { + callAsyncInlineOperation(intent); + return; + } + } + + throw new IllegalStateException("Unknown crypto part type: " + cryptoPartType); + } catch (IOException e) { + Log.e(K9.LOG_TAG, "IOException", e); + } catch (MessagingException e) { + Log.e(K9.LOG_TAG, "MessagingException", e); + } + } + + private void callAsyncInlineOperation(Intent intent) throws IOException { + PipedInputStream pipedInputStream = getPipedInputStreamForEncryptedOrInlineData(); + final ByteArrayOutputStream decryptedOutputStream = new ByteArrayOutputStream(); + + openPgpApi.executeApiAsync(intent, pipedInputStream, decryptedOutputStream, new IOpenPgpCallback() { + @Override + public void onReturn(Intent result) { + currentCryptoResult = result; + + MimeBodyPart decryptedPart = null; + try { + TextBody body = new TextBody(new String(decryptedOutputStream.toByteArray())); + decryptedPart = new MimeBodyPart(body, "text/plain"); + } catch (MessagingException e) { + Log.e(K9.LOG_TAG, "MessagingException", e); + } + + onCryptoOperationReturned(decryptedPart); + } + }); + } + + private void callAsyncDecrypt(Intent intent) throws IOException { + final CountDownLatch latch = new CountDownLatch(1); + PipedInputStream pipedInputStream = getPipedInputStreamForEncryptedOrInlineData(); + PipedOutputStream decryptedOutputStream = getPipedOutputStreamForDecryptedData(latch); + + openPgpApi.executeApiAsync(intent, pipedInputStream, decryptedOutputStream, new IOpenPgpCallback() { + @Override + public void onReturn(Intent result) { + currentCryptoResult = result; + latch.countDown(); + } + }); + } + + private void callAsyncDetachedVerify(Intent intent) throws IOException, MessagingException { + PipedInputStream pipedInputStream = getPipedInputStreamForSignedData(); + + byte[] signatureData = MessageDecryptVerifier.getSignatureData(currentCryptoPart.part); + intent.putExtra(OpenPgpApi.EXTRA_DETACHED_SIGNATURE, signatureData); + + openPgpApi.executeApiAsync(intent, pipedInputStream, null, new IOpenPgpCallback() { + @Override + public void onReturn(Intent result) { + currentCryptoResult = result; + onCryptoOperationReturned(null); + } + }); + } + + private PipedInputStream getPipedInputStreamForSignedData() throws IOException { + PipedInputStream pipedInputStream = new PipedInputStream(); + + final PipedOutputStream out = new PipedOutputStream(pipedInputStream); + new Thread(new Runnable() { + @Override + public void run() { + try { + Multipart multipartSignedMultipart = (Multipart) currentCryptoPart.part.getBody(); + BodyPart signatureBodyPart = multipartSignedMultipart.getBodyPart(0); + Log.d(K9.LOG_TAG, "signed data type: " + signatureBodyPart.getMimeType()); + signatureBodyPart.writeTo(out); + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Exception while writing message to crypto provider", e); + } finally { + try { + out.close(); + } catch (IOException e) { + // don't care + } + } + } + }).start(); + + return pipedInputStream; + } + + private PipedInputStream getPipedInputStreamForEncryptedOrInlineData() throws IOException { + PipedInputStream pipedInputStream = new PipedInputStream(); + + final PipedOutputStream out = new PipedOutputStream(pipedInputStream); + new Thread(new Runnable() { + @Override + public void run() { + try { + Part part = currentCryptoPart.part; + CryptoPartType cryptoPartType = currentCryptoPart.type; + if (cryptoPartType == CryptoPartType.ENCRYPTED) { + Multipart multipartEncryptedMultipart = (Multipart) part.getBody(); + BodyPart encryptionPayloadPart = multipartEncryptedMultipart.getBodyPart(1); + Body encryptionPayloadBody = encryptionPayloadPart.getBody(); + encryptionPayloadBody.writeTo(out); + } else if (cryptoPartType == CryptoPartType.INLINE_PGP) { + String text = MessageExtractor.getTextFromPart(part); + out.write(text.getBytes()); + } else { + Log.wtf(K9.LOG_TAG, "No suitable data to stream found!"); + } + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Exception while writing message to crypto provider", e); + } finally { + try { + out.close(); + } catch (IOException e) { + // don't care + } + } + } + }).start(); + + return pipedInputStream; + } + + private PipedOutputStream getPipedOutputStreamForDecryptedData(final CountDownLatch latch) throws IOException { + PipedOutputStream decryptedOutputStream = new PipedOutputStream(); + final PipedInputStream decryptedInputStream = new PipedInputStream(decryptedOutputStream); + new AsyncTask() { + @Override + protected MimeBodyPart doInBackground(Void... params) { + MimeBodyPart decryptedPart = null; + try { + decryptedPart = DecryptStreamParser.parse(context, decryptedInputStream); + + latch.await(); + } catch (InterruptedException e) { + Log.w(K9.LOG_TAG, "we were interrupted while waiting for onReturn!", e); + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Something went wrong while parsing the decrypted MIME part", e); + //TODO: pass error to main thread and display error message to user + } + return decryptedPart; + } + + @Override + protected void onPostExecute(MimeBodyPart decryptedPart) { + onCryptoOperationReturned(decryptedPart); + } + }.execute(); + return decryptedOutputStream; + } + + private void onCryptoOperationReturned(MimeBodyPart outputPart) { + if (currentCryptoResult == null) { + Log.e(K9.LOG_TAG, "Internal error: we should have a result here!"); + return; + } + + try { + handleCryptoOperationResult(outputPart); + } finally { + currentCryptoResult = null; + } + } + + private void handleCryptoOperationResult(MimeBodyPart outputPart) { + int resultCode = currentCryptoResult.getIntExtra(OpenPgpApi.RESULT_CODE, INVALID_OPENPGP_RESULT_CODE); + if (K9.DEBUG) { + Log.d(K9.LOG_TAG, "OpenPGP API decryptVerify result code: " + resultCode); + } + + switch (resultCode) { + case INVALID_OPENPGP_RESULT_CODE: { + Log.e(K9.LOG_TAG, "Internal error: no result code!"); + break; + } + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: { + handleUserInteractionRequest(); + break; + } + case OpenPgpApi.RESULT_CODE_ERROR: { + handleCryptoOperationError(); + break; + } + case OpenPgpApi.RESULT_CODE_SUCCESS: { + handleCryptoOperationSuccess(outputPart); + break; + } + } + } + + private void handleUserInteractionRequest() { + PendingIntent pendingIntent = currentCryptoResult.getParcelableExtra(OpenPgpApi.RESULT_INTENT); + if (pendingIntent == null) { + throw new AssertionError("Expecting PendingIntent on USER_INTERACTION_REQUIRED!"); + } + + try { + activity.startIntentSenderForResult(pendingIntent.getIntentSender(), REQUEST_CODE_CRYPTO, null, 0, 0, 0); + } catch (SendIntentException e) { + Log.e(K9.LOG_TAG, "Internal error on starting pendingintent!", e); + } + } + + private void handleCryptoOperationError() { + OpenPgpError error = currentCryptoResult.getParcelableExtra(OpenPgpApi.RESULT_ERROR); + if (K9.DEBUG) { + Log.w(K9.LOG_TAG, "OpenPGP API error: " + error.getMessage()); + } + + onCryptoFailed(error); + } + + private void handleCryptoOperationSuccess(MimeBodyPart outputPart) { + OpenPgpResultAnnotation resultAnnotation = new OpenPgpResultAnnotation(); + + resultAnnotation.setOutputData(outputPart); + + // TODO if the data /was/ encrypted, we should set it here! + // this is not easy to determine for inline data though + resultAnnotation.setWasEncrypted(false); + + OpenPgpSignatureResult signatureResult = currentCryptoResult.getParcelableExtra(OpenPgpApi.RESULT_SIGNATURE); + resultAnnotation.setSignatureResult(signatureResult); + + PendingIntent pendingIntent = currentCryptoResult.getParcelableExtra(OpenPgpApi.RESULT_INTENT); + resultAnnotation.setPendingIntent(pendingIntent); + + onCryptoSuccess(resultAnnotation); + } + + public void handleCryptoResult(int requestCode, int resultCode, Intent data) { + if (requestCode != REQUEST_CODE_CRYPTO) { + return; + } + + if (resultCode == Activity.RESULT_OK) { + decryptOrVerifyNextPart(); + } else { + // FIXME: don't pass null + onCryptoFailed(null); + } + } + + private void onCryptoSuccess(OpenPgpResultAnnotation resultAnnotation) { + addOpenPgpResultPartToMessage(resultAnnotation); + onCryptoFinished(); + } + + private void addOpenPgpResultPartToMessage(OpenPgpResultAnnotation resultAnnotation) { + Part part = currentCryptoPart.part; + messageAnnotations.put(part, resultAnnotation); + } + + private void onCryptoFailed(OpenPgpError error) { + OpenPgpResultAnnotation errorPart = new OpenPgpResultAnnotation(); + errorPart.setError(error); + addOpenPgpResultPartToMessage(errorPart); + onCryptoFinished(); + } + + private void onCryptoFinished() { + partsToDecryptOrVerify.removeFirst(); + decryptOrVerifyNextPart(); + } + + private void returnResultToFragment() { + callback.onCryptoOperationsFinished(messageAnnotations); + } + + + private static class CryptoPart { + public final CryptoPartType type; + public final Part part; + + CryptoPart(CryptoPartType type, Part part) { + this.type = type; + this.part = part; + } + } + + private enum CryptoPartType { + INLINE_PGP, + ENCRYPTED, + SIGNED + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/message/DecodeMessageLoader.java b/k9mail/src/main/java/com/fsck/k9/ui/message/DecodeMessageLoader.java new file mode 100644 index 000000000..9d7fe0d1a --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/message/DecodeMessageLoader.java @@ -0,0 +1,52 @@ +package com.fsck.k9.ui.message; + + +import android.content.AsyncTaskLoader; +import android.content.Context; +import android.util.Log; + +import com.fsck.k9.K9; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mailstore.LocalMessageExtractor; +import com.fsck.k9.mailstore.MessageViewInfo; +import com.fsck.k9.ui.crypto.MessageCryptoAnnotations; + + +public class DecodeMessageLoader extends AsyncTaskLoader { + private final Message message; + private MessageViewInfo messageViewInfo; + private MessageCryptoAnnotations annotations; + + public DecodeMessageLoader(Context context, Message message, MessageCryptoAnnotations annotations) { + super(context); + this.message = message; + this.annotations = annotations; + } + + @Override + protected void onStartLoading() { + if (messageViewInfo != null) { + super.deliverResult(messageViewInfo); + } + + if (takeContentChanged() || messageViewInfo == null) { + forceLoad(); + } + } + + @Override + public void deliverResult(MessageViewInfo messageViewInfo) { + this.messageViewInfo = messageViewInfo; + super.deliverResult(messageViewInfo); + } + + @Override + public MessageViewInfo loadInBackground() { + try { + return LocalMessageExtractor.decodeMessageForView(getContext(), message, annotations); + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Error while decoding message", e); + return null; + } + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/message/LocalMessageLoader.java b/k9mail/src/main/java/com/fsck/k9/ui/message/LocalMessageLoader.java new file mode 100644 index 000000000..de897f402 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/message/LocalMessageLoader.java @@ -0,0 +1,60 @@ +package com.fsck.k9.ui.message; + + +import android.content.AsyncTaskLoader; +import android.content.Context; +import android.util.Log; + +import com.fsck.k9.Account; +import com.fsck.k9.K9; +import com.fsck.k9.activity.MessageReference; +import com.fsck.k9.controller.MessagingController; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mailstore.LocalMessage; + + +public class LocalMessageLoader extends AsyncTaskLoader { + private final MessagingController controller; + private final Account account; + private final MessageReference messageReference; + private LocalMessage message; + + public LocalMessageLoader(Context context, MessagingController controller, Account account, + MessageReference messageReference) { + super(context); + this.controller = controller; + this.account = account; + this.messageReference = messageReference; + } + + @Override + protected void onStartLoading() { + if (message != null) { + super.deliverResult(message); + } + + if (takeContentChanged() || message == null) { + forceLoad(); + } + } + + @Override + public void deliverResult(LocalMessage message) { + this.message = message; + super.deliverResult(message); + } + + @Override + public LocalMessage loadInBackground() { + try { + return loadMessageFromDatabase(); + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Error while loading message from database", e); + return null; + } + } + + private LocalMessage loadMessageFromDatabase() throws MessagingException { + return controller.loadMessage(account, messageReference.folderName, messageReference.uid); + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java new file mode 100644 index 000000000..5ee4dbcee --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java @@ -0,0 +1,369 @@ +package com.fsck.k9.ui.messageview; + + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Environment; +import android.util.Log; +import android.widget.Toast; + +import com.fsck.k9.Account; +import com.fsck.k9.K9; +import com.fsck.k9.Preferences; +import com.fsck.k9.R; +import com.fsck.k9.cache.TemporaryAttachmentStore; +import com.fsck.k9.controller.MessagingController; +import com.fsck.k9.controller.MessagingListener; +import com.fsck.k9.helper.FileHelper; +import com.fsck.k9.helper.MediaScannerNotifier; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.internet.MimeUtility; +import com.fsck.k9.mailstore.AttachmentViewInfo; +import com.fsck.k9.mailstore.LocalMessage; +import com.fsck.k9.mailstore.LocalPart; +import org.apache.commons.io.IOUtils; + + +public class AttachmentController { + private final Context context; + private final MessagingController controller; + private final MessageViewFragment messageViewFragment; + private final AttachmentViewInfo attachment; + + AttachmentController(MessagingController controller, MessageViewFragment messageViewFragment, + AttachmentViewInfo attachment) { + this.context = messageViewFragment.getContext(); + this.controller = controller; + this.messageViewFragment = messageViewFragment; + this.attachment = attachment; + } + + public void viewAttachment() { + if (needsDownloading()) { + downloadAndViewAttachment((LocalPart) attachment.part); + } else { + viewLocalAttachment(); + } + } + + public void saveAttachment() { + saveAttachmentTo(K9.getAttachmentDefaultPath()); + } + + public void saveAttachmentTo(String directory) { + saveAttachmentTo(new File(directory)); + } + + private boolean needsDownloading() { + return isPartMissing() && isLocalPart(); + } + + private boolean isPartMissing() { + return attachment.part.getBody() == null; + } + + private boolean isLocalPart() { + return attachment.part instanceof LocalPart; + } + + private void downloadAndViewAttachment(LocalPart localPart) { + downloadAttachment(localPart, new Runnable() { + @Override + public void run() { + viewLocalAttachment(); + } + }); + } + + private void downloadAndSaveAttachmentTo(LocalPart localPart, final File directory) { + downloadAttachment(localPart, new Runnable() { + @Override + public void run() { + messageViewFragment.refreshAttachmentThumbnail(attachment); + saveAttachmentTo(directory); + } + }); + } + + private void downloadAttachment(LocalPart localPart, final Runnable attachmentDownloadedCallback) { + String accountUuid = localPart.getAccountUuid(); + Account account = Preferences.getPreferences(context).getAccount(accountUuid); + LocalMessage message = localPart.getMessage(); + + messageViewFragment.showAttachmentLoadingDialog(); + controller.loadAttachment(account, message, attachment.part, new MessagingListener() { + @Override + public void loadAttachmentFinished(Account account, Message message, Part part) { + messageViewFragment.hideAttachmentLoadingDialogOnMainThread(); + messageViewFragment.runOnMainThread(attachmentDownloadedCallback); + } + + @Override + public void loadAttachmentFailed(Account account, Message message, Part part, String reason) { + messageViewFragment.hideAttachmentLoadingDialogOnMainThread(); + } + }); + } + + private void viewLocalAttachment() { + new ViewAttachmentAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void saveAttachmentTo(File directory) { + boolean isExternalStorageMounted = Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED); + if (!isExternalStorageMounted) { + String message = context.getString(R.string.message_view_status_attachment_not_saved); + displayMessageToUser(message); + return; + } + + if (needsDownloading()) { + downloadAndSaveAttachmentTo((LocalPart) attachment.part, directory); + } else { + saveLocalAttachmentTo(directory); + } + } + + private void saveLocalAttachmentTo(File directory) { + new SaveAttachmentAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, directory); + } + + private File saveAttachmentWithUniqueFileName(File directory) throws IOException { + String filename = FileHelper.sanitizeFilename(attachment.displayName); + File file = FileHelper.createUniqueFile(directory, filename); + + writeAttachmentToStorage(file); + + return file; + } + + private void writeAttachmentToStorage(File file) throws IOException { + InputStream in = context.getContentResolver().openInputStream(attachment.uri); + try { + OutputStream out = new FileOutputStream(file); + try { + IOUtils.copy(in, out); + out.flush(); + } finally { + out.close(); + } + } finally { + in.close(); + } + } + + private Intent getBestViewIntentAndSaveFileIfNecessary() { + String displayName = attachment.displayName; + String inferredMimeType = MimeUtility.getMimeTypeByExtension(displayName); + + IntentAndResolvedActivitiesCount resolvedIntentInfo; + String mimeType = attachment.mimeType; + if (MimeUtility.isDefaultMimeType(mimeType)) { + resolvedIntentInfo = getBestViewIntentForMimeType(inferredMimeType); + } else { + resolvedIntentInfo = getBestViewIntentForMimeType(mimeType); + if (!resolvedIntentInfo.hasResolvedActivities() && !inferredMimeType.equals(mimeType)) { + resolvedIntentInfo = getBestViewIntentForMimeType(inferredMimeType); + } + } + + if (!resolvedIntentInfo.hasResolvedActivities()) { + resolvedIntentInfo = getBestViewIntentForMimeType(MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE); + } + + Intent viewIntent; + if (resolvedIntentInfo.hasResolvedActivities() && resolvedIntentInfo.containsFileUri()) { + try { + File tempFile = TemporaryAttachmentStore.getFileForWriting(context, displayName); + writeAttachmentToStorage(tempFile); + viewIntent = createViewIntentForFileUri(resolvedIntentInfo.getMimeType(), Uri.fromFile(tempFile)); + } catch (IOException e) { + if (K9.DEBUG) { + Log.e(K9.LOG_TAG, "Error while saving attachment to use file:// URI with ACTION_VIEW Intent", e); + } + viewIntent = createViewIntentForAttachmentProviderUri(MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE); + } + } else { + viewIntent = resolvedIntentInfo.getIntent(); + } + + return viewIntent; + } + + private IntentAndResolvedActivitiesCount getBestViewIntentForMimeType(String mimeType) { + Intent contentUriIntent = createViewIntentForAttachmentProviderUri(mimeType); + int contentUriActivitiesCount = getResolvedIntentActivitiesCount(contentUriIntent); + + if (contentUriActivitiesCount > 0) { + return new IntentAndResolvedActivitiesCount(contentUriIntent, contentUriActivitiesCount); + } + + File tempFile = TemporaryAttachmentStore.getFile(context, attachment.displayName); + Uri tempFileUri = Uri.fromFile(tempFile); + Intent fileUriIntent = createViewIntentForFileUri(mimeType, tempFileUri); + int fileUriActivitiesCount = getResolvedIntentActivitiesCount(fileUriIntent); + + if (fileUriActivitiesCount > 0) { + return new IntentAndResolvedActivitiesCount(fileUriIntent, fileUriActivitiesCount); + } + + return new IntentAndResolvedActivitiesCount(contentUriIntent, contentUriActivitiesCount); + } + + private Intent createViewIntentForAttachmentProviderUri(String mimeType) { + Uri uri = getAttachmentUriForMimeType(attachment, mimeType); + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(uri, mimeType); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + addUiIntentFlags(intent); + + return intent; + } + + private Uri getAttachmentUriForMimeType(AttachmentViewInfo attachment, String mimeType) { + if (attachment.mimeType.equals(mimeType)) { + return attachment.uri; + } + + return attachment.uri.buildUpon() + .appendPath(mimeType) + .build(); + } + + private Intent createViewIntentForFileUri(String mimeType, Uri uri) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(uri, mimeType); + addUiIntentFlags(intent); + + return intent; + } + + private void addUiIntentFlags(Intent intent) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); + } + + private int getResolvedIntentActivitiesCount(Intent intent) { + PackageManager packageManager = context.getPackageManager(); + + List resolveInfos = + packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + + return resolveInfos.size(); + } + + private void displayAttachmentSavedMessage(final String filename) { + String message = context.getString(R.string.message_view_status_attachment_saved, filename); + displayMessageToUser(message); + } + + private void displayAttachmentNotSavedMessage() { + String message = context.getString(R.string.message_view_status_attachment_not_saved); + displayMessageToUser(message); + } + + private void displayMessageToUser(String message) { + Toast.makeText(context, message, Toast.LENGTH_LONG).show(); + } + + private static class IntentAndResolvedActivitiesCount { + private Intent intent; + private int activitiesCount; + + IntentAndResolvedActivitiesCount(Intent intent, int activitiesCount) { + this.intent = intent; + this.activitiesCount = activitiesCount; + } + + public Intent getIntent() { + return intent; + } + + public boolean hasResolvedActivities() { + return activitiesCount > 0; + } + + public String getMimeType() { + return intent.getType(); + } + + public boolean containsFileUri() { + return "file".equals(intent.getData().getScheme()); + } + } + + private class ViewAttachmentAsyncTask extends AsyncTask { + + @Override + protected void onPreExecute() { + messageViewFragment.disableAttachmentButtons(attachment); + } + + @Override + protected Intent doInBackground(Void... params) { + return getBestViewIntentAndSaveFileIfNecessary(); + } + + @Override + protected void onPostExecute(Intent intent) { + viewAttachment(intent); + messageViewFragment.enableAttachmentButtons(attachment); + } + + private void viewAttachment(Intent intent) { + try { + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.e(K9.LOG_TAG, "Could not display attachment of type " + attachment.mimeType, e); + + String message = context.getString(R.string.message_view_no_viewer, attachment.mimeType); + displayMessageToUser(message); + } + } + } + + private class SaveAttachmentAsyncTask extends AsyncTask { + + @Override + protected void onPreExecute() { + messageViewFragment.disableAttachmentButtons(attachment); + } + + @Override + protected File doInBackground(File... params) { + try { + File directory = params[0]; + return saveAttachmentWithUniqueFileName(directory); + } catch (IOException e) { + if (K9.DEBUG) { + Log.e(K9.LOG_TAG, "Error saving attachment", e); + } + return null; + } + } + + @Override + protected void onPostExecute(File file) { + messageViewFragment.enableAttachmentButtons(attachment); + if (file != null) { + displayAttachmentSavedMessage(file.toString()); + MediaScannerNotifier.notify(context, file); + } else { + displayAttachmentNotSavedMessage(); + } + } + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/messageview/AttachmentView.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/AttachmentView.java new file mode 100644 index 000000000..0540c941e --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/AttachmentView.java @@ -0,0 +1,141 @@ +package com.fsck.k9.ui.messageview; + + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnLongClickListener; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import com.bumptech.glide.Glide; +import com.fsck.k9.K9; +import com.fsck.k9.R; +import com.fsck.k9.helper.SizeFormatter; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mailstore.AttachmentViewInfo; + + +public class AttachmentView extends FrameLayout implements OnClickListener, OnLongClickListener { + private AttachmentViewInfo attachment; + private AttachmentViewCallback callback; + + private Button viewButton; + private Button downloadButton; + + + public AttachmentView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public AttachmentView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public AttachmentView(Context context) { + super(context); + } + + public AttachmentViewInfo getAttachment() { + return attachment; + } + + public void enableButtons() { + viewButton.setEnabled(true); + downloadButton.setEnabled(true); + } + + public void disableButtons() { + viewButton.setEnabled(false); + downloadButton.setEnabled(false); + } + + public void setAttachment(AttachmentViewInfo attachment) throws MessagingException { + this.attachment = attachment; + + displayAttachmentInformation(); + } + + private void displayAttachmentInformation() { + viewButton = (Button) findViewById(R.id.view); + downloadButton = (Button) findViewById(R.id.download); + + if (attachment.size > K9.MAX_ATTACHMENT_DOWNLOAD_SIZE) { + viewButton.setVisibility(View.GONE); + downloadButton.setVisibility(View.GONE); + } + + viewButton.setOnClickListener(this); + downloadButton.setOnClickListener(this); + downloadButton.setOnLongClickListener(this); + + TextView attachmentName = (TextView) findViewById(R.id.attachment_name); + attachmentName.setText(attachment.displayName); + + setAttachmentSize(attachment.size); + + refreshThumbnail(); + } + + private void setAttachmentSize(long size) { + TextView attachmentSize = (TextView) findViewById(R.id.attachment_info); + if (size == AttachmentViewInfo.UNKNOWN_SIZE) { + attachmentSize.setText(""); + } else { + String text = SizeFormatter.formatSize(getContext(), size); + attachmentSize.setText(text); + } + } + + @Override + public void onClick(View view) { + switch (view.getId()) { + case R.id.view: { + onViewButtonClick(); + break; + } + case R.id.download: { + onSaveButtonClick(); + break; + } + } + } + + @Override + public boolean onLongClick(View view) { + if (view.getId() == R.id.download) { + onSaveButtonLongClick(); + return true; + } + + return false; + } + + private void onViewButtonClick() { + callback.onViewAttachment(attachment); + } + + private void onSaveButtonClick() { + callback.onSaveAttachment(attachment); + } + + private void onSaveButtonLongClick() { + callback.onSaveAttachmentToUserProvidedDirectory(attachment); + } + + public void setCallback(AttachmentViewCallback callback) { + this.callback = callback; + } + + public void refreshThumbnail() { + ImageView thumbnailView = (ImageView) findViewById(R.id.attachment_icon); + Glide.with(getContext()) + .load(attachment.uri) + .placeholder(R.drawable.attached_image_placeholder) + .centerCrop() + .into(thumbnailView); + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/messageview/AttachmentViewCallback.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/AttachmentViewCallback.java new file mode 100644 index 000000000..3575bee69 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/AttachmentViewCallback.java @@ -0,0 +1,11 @@ +package com.fsck.k9.ui.messageview; + + +import com.fsck.k9.mailstore.AttachmentViewInfo; + + +interface AttachmentViewCallback { + void onViewAttachment(AttachmentViewInfo attachment); + void onSaveAttachment(AttachmentViewInfo attachment); + void onSaveAttachmentToUserProvidedDirectory(AttachmentViewInfo attachment); +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/messageview/DownloadImageTask.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/DownloadImageTask.java new file mode 100644 index 000000000..827bf33d3 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/DownloadImageTask.java @@ -0,0 +1,193 @@ +package com.fsck.k9.ui.messageview; + + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.util.Log; +import android.widget.Toast; + +import com.fsck.k9.K9; +import com.fsck.k9.R; +import com.fsck.k9.helper.FileHelper; +import com.fsck.k9.helper.UrlEncodingHelper; +import com.fsck.k9.mail.internet.MimeUtility; +import com.fsck.k9.provider.AttachmentProvider.AttachmentProviderColumns; +import org.apache.commons.io.IOUtils; + + +@Deprecated +class DownloadImageTask extends AsyncTask { + private static final String[] ATTACHMENT_PROJECTION = new String[] { + AttachmentProviderColumns._ID, + AttachmentProviderColumns.DISPLAY_NAME + }; + private static final int DISPLAY_NAME_INDEX = 1; + + private static final String DEFAULT_FILE_NAME = "saved_image"; + + + private final Context context; + + public DownloadImageTask(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + protected String doInBackground(String... params) { + String url = params[0]; + try { + boolean isExternalImage = url.startsWith("http"); + + String fileName; + if (isExternalImage) { + fileName = downloadAndStoreImage(url); + } else { + fileName = fetchAndStoreImage(url); + } + + return fileName; + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Error while downloading image", e); + return null; + } + } + + @Override + protected void onPostExecute(String fileName) { + boolean errorSavingFile = (fileName == null); + + String text; + if (errorSavingFile) { + text = context.getString(R.string.image_saving_failed); + } else { + text = context.getString(R.string.image_saved_as, fileName); + } + + Toast.makeText(context, text, Toast.LENGTH_LONG).show(); + } + + private String downloadAndStoreImage(String urlString) throws IOException { + URL url = new URL(urlString); + URLConnection conn = url.openConnection(); + + InputStream in = conn.getInputStream(); + try { + String fileName = getFileNameFromUrl(url); + String mimeType = getMimeType(conn, fileName); + + String fileNameWithExtension = getFileNameWithExtension(fileName, mimeType); + return writeFileToStorage(fileNameWithExtension, in); + } finally { + in.close(); + } + } + + private String getFileNameFromUrl(URL url) { + String fileName; + + String path = url.getPath(); + int start = path.lastIndexOf("/"); + if (start != -1 && start + 1 < path.length()) { + fileName = UrlEncodingHelper.decodeUtf8(path.substring(start + 1)); + } else { + fileName = DEFAULT_FILE_NAME; + } + + return fileName; + } + + private String getMimeType(URLConnection conn, String fileName) { + String mimeType = null; + if (fileName.indexOf('.') == -1) { + mimeType = conn.getContentType(); + } + + return mimeType; + } + + private String fetchAndStoreImage(String urlString) throws IOException { + ContentResolver contentResolver = context.getContentResolver(); + Uri uri = Uri.parse(urlString); + + String fileName = getFileNameFromContentProvider(contentResolver, uri); + String mimeType = getMimeType(contentResolver, uri, fileName); + + InputStream in = contentResolver.openInputStream(uri); + try { + String fileNameWithExtension = getFileNameWithExtension(fileName, mimeType); + return writeFileToStorage(fileNameWithExtension, in); + } finally { + in.close(); + } + } + + private String getMimeType(ContentResolver contentResolver, Uri uri, String fileName) { + String mimeType = null; + if (fileName.indexOf('.') == -1) { + mimeType = contentResolver.getType(uri); + } + + return mimeType; + } + + private String getFileNameFromContentProvider(ContentResolver contentResolver, Uri uri) { + String displayName = DEFAULT_FILE_NAME; + + Cursor cursor = contentResolver.query(uri, ATTACHMENT_PROJECTION, null, null, null); + if (cursor != null) { + try { + if (cursor.moveToNext() && !cursor.isNull(DISPLAY_NAME_INDEX)) { + displayName = cursor.getString(DISPLAY_NAME_INDEX); + } + } finally { + cursor.close(); + } + } + + return displayName; + } + + private String getFileNameWithExtension(String fileName, String mimeType) { + if (fileName.indexOf('.') != -1) { + return fileName; + } + + // Use JPEG as fallback + String extension = "jpeg"; + if (mimeType != null) { + String extensionFromMimeType = MimeUtility.getExtensionByMimeType(mimeType); + if (extensionFromMimeType != null) { + extension = extensionFromMimeType; + } + } + + return fileName + "." + extension; + } + + private String writeFileToStorage(String fileName, InputStream in) throws IOException { + String sanitized = FileHelper.sanitizeFilename(fileName); + + File directory = new File(K9.getAttachmentDefaultPath()); + File file = FileHelper.createUniqueFile(directory, sanitized); + + FileOutputStream out = new FileOutputStream(file); + try { + IOUtils.copy(in, out); + out.flush(); + } finally { + out.close(); + } + + return file.getName(); + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/view/SingleMessageView.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/MessageContainerView.java similarity index 52% rename from k9mail/src/main/java/com/fsck/k9/view/SingleMessageView.java rename to k9mail/src/main/java/com/fsck/k9/ui/messageview/MessageContainerView.java index 25852ee17..de70a2e63 100644 --- a/k9mail/src/main/java/com/fsck/k9/view/SingleMessageView.java +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/MessageContainerView.java @@ -1,25 +1,15 @@ -package com.fsck.k9.view; +package com.fsck.k9.ui.messageview; -import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.net.URL; -import java.net.URLConnection; +import java.util.HashMap; +import java.util.Map; -import android.app.Activity; -import android.app.Fragment; import android.content.ActivityNotFoundException; -import android.content.ContentResolver; import android.content.Context; import android.content.Intent; -import android.database.Cursor; import android.net.Uri; -import android.os.AsyncTask; import android.os.Parcel; import android.os.Parcelable; import android.util.AttributeSet; -import android.util.Log; -import android.util.TypedValue; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.KeyEvent; @@ -30,41 +20,32 @@ import android.view.MenuItem.OnMenuItemClickListener; import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnCreateContextMenuListener; +import android.view.ViewStub; import android.webkit.WebView; import android.webkit.WebView.HitTestResult; +import android.webkit.WebViewClient; import android.widget.Button; import android.widget.LinearLayout; import android.widget.Toast; -import com.fsck.k9.Account; -import com.fsck.k9.K9; import com.fsck.k9.R; -import com.fsck.k9.controller.MessagingController; -import com.fsck.k9.controller.MessagingListener; -import com.fsck.k9.crypto.PgpData; -import com.fsck.k9.fragment.MessageViewFragment; import com.fsck.k9.helper.ClipboardManager; import com.fsck.k9.helper.Contacts; -import com.fsck.k9.helper.FileHelper; -import com.fsck.k9.helper.HtmlConverter; -import com.fsck.k9.helper.UrlEncodingHelper; import com.fsck.k9.helper.Utility; import com.fsck.k9.mail.Address; -import com.fsck.k9.mail.Flag; -import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.Multipart; -import com.fsck.k9.mail.Part; -import com.fsck.k9.mail.internet.MimeUtility; -import com.fsck.k9.mailstore.LocalAttachmentBodyPart; -import com.fsck.k9.mailstore.LocalMessage; -import com.fsck.k9.provider.AttachmentProvider.AttachmentProviderColumns; +import com.fsck.k9.mailstore.AttachmentViewInfo; +import com.fsck.k9.mailstore.MessageViewInfo.MessageViewContainer; -import org.apache.commons.io.IOUtils; +import com.fsck.k9.mailstore.OpenPgpResultAnnotation; +import com.fsck.k9.mailstore.OpenPgpResultAnnotation.CryptoError; +import com.fsck.k9.view.K9WebViewClient; +import com.fsck.k9.view.MessageHeader.OnLayoutChangedListener; +import com.fsck.k9.view.MessageWebView; -public class SingleMessageView extends LinearLayout implements OnClickListener, - MessageHeader.OnLayoutChangedListener, OnCreateContextMenuListener { +public class MessageContainerView extends LinearLayout implements OnClickListener, + OnLayoutChangedListener, OnCreateContextMenuListener { private static final int MENU_ITEM_LINK_VIEW = Menu.FIRST; private static final int MENU_ITEM_LINK_SHARE = Menu.FIRST + 1; private static final int MENU_ITEM_LINK_COPY = Menu.FIRST + 2; @@ -81,43 +62,29 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, private static final int MENU_ITEM_EMAIL_SAVE = Menu.FIRST + 1; private static final int MENU_ITEM_EMAIL_COPY = Menu.FIRST + 2; - private static final String[] ATTACHMENT_PROJECTION = new String[] { - AttachmentProviderColumns._ID, - AttachmentProviderColumns.DISPLAY_NAME - }; - private static final int DISPLAY_NAME_INDEX = 1; - - - private MessageOpenPgpView mOpenPgpView; + private View mSidebar; private MessageWebView mMessageContentView; - private MessageHeader mHeaderContainer; private LinearLayout mAttachments; private Button mShowHiddenAttachments; private LinearLayout mHiddenAttachments; - private View mShowPicturesAction; - private View mShowMessageAction; - private View mShowAttachmentsAction; - private boolean mShowPictures; - private boolean mHasAttachments; - private Button mDownloadRemainder; + private boolean showingPictures; private LayoutInflater mInflater; - private Contacts mContacts; - private AttachmentView.AttachmentFileDownloadCallback attachmentCallback; + private AttachmentViewCallback attachmentCallback; private View mAttachmentsContainer; private SavedState mSavedState; private ClipboardManager mClipboardManager; private String mText; + private Map attachments = new HashMap(); - public void initialize(Fragment fragment) { - Activity activity = fragment.getActivity(); + @Override + public void onFinishInflate() { + mSidebar = findViewById(R.id.message_sidebar); + mMessageContentView = (MessageWebView) findViewById(R.id.message_content); mMessageContentView.configure(); - activity.registerForContextMenu(mMessageContentView); mMessageContentView.setOnCreateContextMenuListener(this); - - mHeaderContainer = (MessageHeader) findViewById(R.id.header_container); - mHeaderContainer.setOnLayoutChangedListener(this); + mMessageContentView.setVisibility(View.VISIBLE); mAttachmentsContainer = findViewById(R.id.attachments_container); mAttachments = (LinearLayout) findViewById(R.id.attachments); @@ -125,38 +92,13 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, mHiddenAttachments.setVisibility(View.GONE); mShowHiddenAttachments = (Button) findViewById(R.id.show_hidden_attachments); mShowHiddenAttachments.setVisibility(View.GONE); - mOpenPgpView = (MessageOpenPgpView) findViewById(R.id.layout_decrypt_openpgp); - mOpenPgpView.setFragment(fragment); - mOpenPgpView.setupChildViews(); - mShowPicturesAction = findViewById(R.id.show_pictures); - mShowMessageAction = findViewById(R.id.show_message); - - mShowAttachmentsAction = findViewById(R.id.show_attachments); - - mShowPictures = false; - - mContacts = Contacts.getInstance(activity); - - mInflater = ((MessageViewFragment) fragment).getFragmentLayoutInflater(); - mDownloadRemainder = (Button) findViewById(R.id.download_remainder); - mDownloadRemainder.setVisibility(View.GONE); - mAttachmentsContainer.setVisibility(View.GONE); - mMessageContentView.setVisibility(View.VISIBLE); - - // the HTC version of WebView tries to force the background of the - // titlebar, which is really unfair. - TypedValue outValue = new TypedValue(); - getContext().getTheme().resolveAttribute(R.attr.messageViewHeaderBackgroundColor, outValue, true); - mHeaderContainer.setBackgroundColor(outValue.data); - // also set background of the whole view (including the attachments view) - setBackgroundColor(outValue.data); - mShowHiddenAttachments.setOnClickListener(this); - mShowMessageAction.setOnClickListener(this); - mShowAttachmentsAction.setOnClickListener(this); - mShowPicturesAction.setOnClickListener(this); - mClipboardManager = ClipboardManager.getInstance(activity); + showingPictures = false; + + Context context = getContext(); + mInflater = LayoutInflater.from(context); + mClipboardManager = ClipboardManager.getInstance(context); } @Override @@ -238,7 +180,8 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, break; } case MENU_ITEM_IMAGE_SAVE: { - new DownloadImageTask().execute(url); + //TODO: Use download manager + new DownloadImageTask(getContext()).execute(url); break; } case MENU_ITEM_IMAGE_COPY: { @@ -381,21 +324,6 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, onShowHiddenAttachments(); break; } - case R.id.show_message: { - onShowMessage(); - break; - } - case R.id.show_attachments: { - onShowAttachments(); - break; - } - case R.id.show_pictures: { - // Allow network access first... - setLoadPictures(true); - // ...then re-populate the WebView with the message text - loadBodyFromText(mText); - break; - } } } @@ -404,230 +332,147 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, mHiddenAttachments.setVisibility(View.VISIBLE); } - public void onShowMessage() { - showShowMessageAction(false); - showAttachments(false); - showShowAttachmentsAction(mHasAttachments); - showMessageWebView(true); - } - - public void onShowAttachments() { - showMessageWebView(false); - showShowAttachmentsAction(false); - showShowMessageAction(true); - showAttachments(true); - } - - public SingleMessageView(Context context, AttributeSet attrs) { + public MessageContainerView(Context context, AttributeSet attrs) { super(context, attrs); } - public boolean showPictures() { - return mShowPictures; + private boolean isShowingPictures() { + return showingPictures; } - public void setShowPictures(Boolean show) { - mShowPictures = show; - } - - /** - * Enable/disable image loading of the WebView. But always hide the - * "Show pictures" button! - * - * @param enable true, if (network) images should be loaded. - * false, otherwise. - */ - public void setLoadPictures(boolean enable) { + private void setLoadPictures(boolean enable) { mMessageContentView.blockNetworkData(!enable); - setShowPictures(enable); - showShowPicturesAction(false); + showingPictures = enable; } - public Button downloadRemainderButton() { - return mDownloadRemainder; + public void showPictures() { + setLoadPictures(true); + loadBodyFromText(mText); } - public void showShowPicturesAction(boolean show) { - mShowPicturesAction.setVisibility(show ? View.VISIBLE : View.GONE); - } - public void showShowMessageAction(boolean show) { - mShowMessageAction.setVisibility(show ? View.VISIBLE : View.GONE); - } - public void showShowAttachmentsAction(boolean show) { - mShowAttachmentsAction.setVisibility(show ? View.VISIBLE : View.GONE); - } - - /** - * Fetch the message header view. This is not the same as the message headers; this is the View shown at the top - * of messages. - * @return MessageHeader View. - */ - public MessageHeader getMessageHeaderView() { - return mHeaderContainer; - } - - public void setHeaders(final Message message, Account account) { - try { - mHeaderContainer.populate(message, account); - mHeaderContainer.setVisibility(View.VISIBLE); - - - } catch (Exception me) { - Log.e(K9.LOG_TAG, "setHeaders - error", me); + public void enableAttachmentButtons() { + for (AttachmentView attachmentView : attachments.values()) { + attachmentView.enableButtons(); } } - public void setShowDownloadButton(Message message) { - if (message.isSet(Flag.X_DOWNLOADED_FULL)) { - mDownloadRemainder.setVisibility(View.GONE); - } else { - mDownloadRemainder.setEnabled(true); - mDownloadRemainder.setVisibility(View.VISIBLE); + public void disableAttachmentButtons() { + for (AttachmentView attachmentView : attachments.values()) { + attachmentView.disableButtons(); } } - public void setOnFlagListener(OnClickListener listener) { - mHeaderContainer.setOnFlagListener(listener); - } + public void displayMessageViewContainer(MessageViewContainer messageViewContainer, + boolean automaticallyLoadPictures, ShowPicturesController showPicturesController, + AttachmentViewCallback attachmentCallback, OpenPgpHeaderViewCallback openPgpHeaderViewCallback, + boolean displayPgpHeader) throws MessagingException { - public void showAllHeaders() { - mHeaderContainer.onShowAdditionalHeaders(); - } + this.attachmentCallback = attachmentCallback; - public boolean additionalHeadersVisible() { - return mHeaderContainer.additionalHeadersVisible(); - } - - public void setMessage(Account account, LocalMessage message, PgpData pgpData, - MessagingController controller, MessagingListener listener) throws MessagingException { resetView(); - String text = null; - if (pgpData != null) { - text = pgpData.getDecryptedData(); - if (text != null) { - text = HtmlConverter.textToHtml(text); - } - } + WebViewClient webViewClient = K9WebViewClient.newInstance(messageViewContainer.rootPart); + mMessageContentView.setWebViewClient(webViewClient); - if (text == null) { - text = message.getTextForDisplay(); - } - - // Save the text so we can reset the WebView when the user clicks the "Show pictures" button - mText = text; - - mHasAttachments = message.hasAttachments(); - - if (mHasAttachments) { - renderAttachments(message, 0, message, account, controller, listener); + boolean hasAttachments = !messageViewContainer.attachments.isEmpty(); + if (hasAttachments) { + renderAttachments(messageViewContainer); } mHiddenAttachments.setVisibility(View.GONE); boolean lookForImages = true; if (mSavedState != null) { - if (mSavedState.showPictures) { + if (mSavedState.showingPictures) { setLoadPictures(true); lookForImages = false; } - if (mSavedState.attachmentViewVisible) { - onShowAttachments(); - } else { - onShowMessage(); - } - if (mSavedState.hiddenAttachmentsVisible) { onShowHiddenAttachments(); } mSavedState = null; - } else { - onShowMessage(); } - if (text != null && lookForImages) { - // If the message contains external pictures and the "Show pictures" - // button wasn't already pressed, see if the user's preferences has us - // showing them anyway. - if (Utility.hasExternalImages(text) && !showPictures()) { - Address[] from = message.getFrom(); - if ((account.getShowPictures() == Account.ShowPictures.ALWAYS) || - ((account.getShowPictures() == Account.ShowPictures.ONLY_FROM_CONTACTS) && - // Make sure we have at least one from address - (from != null && from.length > 0) && - mContacts.isInContacts(from[0].getAddress()))) { + mText = getTextToDisplay(messageViewContainer); + if (mText != null && lookForImages) { + if (Utility.hasExternalImages(mText) && !isShowingPictures()) { + if (automaticallyLoadPictures) { setLoadPictures(true); } else { - showShowPicturesAction(true); + showPicturesController.notifyMessageContainerContainsPictures(this); } } } - if (text != null) { - loadBodyFromText(text); - mOpenPgpView.updateLayout(account, pgpData.getDecryptedData(), - pgpData.getSignatureResult(), message); + if (displayPgpHeader) { + ViewStub openPgpHeaderStub = (ViewStub) findViewById(R.id.openpgp_header_stub); + OpenPgpHeaderView openPgpHeaderView = (OpenPgpHeaderView) openPgpHeaderStub.inflate(); + + OpenPgpResultAnnotation cryptoAnnotation = messageViewContainer.cryptoAnnotation; + openPgpHeaderView.setOpenPgpData(cryptoAnnotation); + openPgpHeaderView.setCallback(openPgpHeaderViewCallback); + mSidebar.setVisibility(View.VISIBLE); } else { - showStatusMessage(getContext().getString(R.string.webview_empty_message)); + mSidebar.setVisibility(View.GONE); } + + String text; + if (mText != null) { + text = mText; + } else { + text = wrapStatusMessage(getContext().getString(R.string.webview_empty_message)); + } + + loadBodyFromText(text); } - public void showStatusMessage(String status) { - String text = "
" + status + "
"; - loadBodyFromText(text); + private String getTextToDisplay(MessageViewContainer messageViewContainer) { + OpenPgpResultAnnotation cryptoAnnotation = messageViewContainer.cryptoAnnotation; + if (cryptoAnnotation == null) { + return messageViewContainer.text; + } + + CryptoError errorType = cryptoAnnotation.getErrorType(); + switch (errorType) { + case CRYPTO_API_RETURNED_ERROR: { + // TODO make a nice view for this + return wrapStatusMessage(cryptoAnnotation.getError().getMessage()); + } + case ENCRYPTED_BUT_INCOMPLETE: { + return wrapStatusMessage(getContext().getString(R.string.crypto_download_complete_message_to_decrypt)); + } + case NONE: + case SIGNED_BUT_INCOMPLETE: { + return messageViewContainer.text; + } + } + + throw new IllegalStateException("Unknown error type: " + errorType); + } + + public String wrapStatusMessage(String status) { + return "
" + status + "
"; } private void loadBodyFromText(String emailText) { mMessageContentView.setText(emailText); } - public void showAttachments(boolean show) { - mAttachmentsContainer.setVisibility(show ? View.VISIBLE : View.GONE); - boolean showHidden = (show && mHiddenAttachments.getVisibility() == View.GONE && - mHiddenAttachments.getChildCount() > 0); - mShowHiddenAttachments.setVisibility(showHidden ? View.VISIBLE : View.GONE); - } - - public void showMessageWebView(boolean show) { - mMessageContentView.setVisibility(show ? View.VISIBLE : View.GONE); - } - - public void setAttachmentsEnabled(boolean enabled) { - for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { - AttachmentView attachment = (AttachmentView) mAttachments.getChildAt(i); - attachment.setButtonsEnabled(enabled); - } - } - - public void removeAllAttachments() { - for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { - mAttachments.removeView(mAttachments.getChildAt(i)); - } - } - - public void renderAttachments(Part part, int depth, Message message, Account account, - MessagingController controller, MessagingListener listener) throws MessagingException { - - if (part.getBody() instanceof Multipart) { - Multipart mp = (Multipart) part.getBody(); - for (int i = 0; i < mp.getCount(); i++) { - renderAttachments(mp.getBodyPart(i), depth + 1, message, account, controller, listener); - } - } else if (part instanceof LocalAttachmentBodyPart) { - AttachmentView view = (AttachmentView)mInflater.inflate(R.layout.message_view_attachment, null); + public void renderAttachments(MessageViewContainer messageContainer) throws MessagingException { + for (AttachmentViewInfo attachment : messageContainer.attachments) { + AttachmentView view = (AttachmentView) mInflater.inflate(R.layout.message_view_attachment, null); view.setCallback(attachmentCallback); + view.setAttachment(attachment); - try { - if (view.populateFromPart(part, message, account, controller, listener)) { - addAttachment(view); - } else { - addHiddenAttachment(view); - } - } catch (Exception e) { - Log.e(K9.LOG_TAG, "Error adding attachment view", e); + attachments.put(attachment, view); + + if (attachment.firstClassAttachment) { + addAttachment(view); + } else { + addHiddenAttachment(view); } } } @@ -653,11 +498,7 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, } public void resetView() { - mDownloadRemainder.setVisibility(View.GONE); setLoadPictures(false); - showShowAttachmentsAction(false); - showShowMessageAction(false); - showShowPicturesAction(false); mAttachments.removeAllViews(); mHiddenAttachments.removeAllViews(); @@ -671,19 +512,6 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, loadBodyFromText(""); } - public void resetHeaderView() { - mHeaderContainer.setVisibility(View.GONE); - } - - public AttachmentView.AttachmentFileDownloadCallback getAttachmentCallback() { - return attachmentCallback; - } - - public void setAttachmentCallback( - AttachmentView.AttachmentFileDownloadCallback attachmentCallback) { - this.attachmentCallback = attachmentCallback; - } - @Override public Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); @@ -694,7 +522,7 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, mAttachmentsContainer.getVisibility() == View.VISIBLE); savedState.hiddenAttachmentsVisible = (mHiddenAttachments != null && mHiddenAttachments.getVisibility() == View.VISIBLE); - savedState.showPictures = mShowPictures; + savedState.showingPictures = showingPictures; return savedState; } @@ -719,10 +547,26 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, } } + public void enableAttachmentButtons(AttachmentViewInfo attachment) { + getAttachmentView(attachment).enableButtons(); + } + + public void disableAttachmentButtons(AttachmentViewInfo attachment) { + getAttachmentView(attachment).disableButtons(); + } + + public void refreshAttachmentThumbnail(AttachmentViewInfo attachment) { + getAttachmentView(attachment).refreshThumbnail(); + } + + private AttachmentView getAttachmentView(AttachmentViewInfo attachment) { + return attachments.get(attachment); + } + static class SavedState extends BaseSavedState { boolean attachmentViewVisible; boolean hiddenAttachmentsVisible; - boolean showPictures; + boolean showingPictures; public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { @@ -746,7 +590,7 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, super(in); this.attachmentViewVisible = (in.readInt() != 0); this.hiddenAttachmentsVisible = (in.readInt() != 0); - this.showPictures = (in.readInt() != 0); + this.showingPictures = (in.readInt() != 0); } @Override @@ -754,120 +598,7 @@ public class SingleMessageView extends LinearLayout implements OnClickListener, super.writeToParcel(out, flags); out.writeInt((this.attachmentViewVisible) ? 1 : 0); out.writeInt((this.hiddenAttachmentsVisible) ? 1 : 0); - out.writeInt((this.showPictures) ? 1 : 0); - } - } - - class DownloadImageTask extends AsyncTask { - @Override - protected String doInBackground(String... params) { - String urlString = params[0]; - try { - boolean externalImage = urlString.startsWith("http"); - - String filename = null; - String mimeType = null; - InputStream in = null; - - try { - if (externalImage) { - URL url = new URL(urlString); - URLConnection conn = url.openConnection(); - in = conn.getInputStream(); - - String path = url.getPath(); - - // Try to get the filename from the URL - int start = path.lastIndexOf("/"); - if (start != -1 && start + 1 < path.length()) { - filename = UrlEncodingHelper.decodeUtf8(path.substring(start + 1)); - } else { - // Use a dummy filename if necessary - filename = "saved_image"; - } - - // Get the MIME type if we couldn't find a file extension - if (filename.indexOf('.') == -1) { - mimeType = conn.getContentType(); - } - } else { - ContentResolver contentResolver = getContext().getContentResolver(); - Uri uri = Uri.parse(urlString); - - // Get the filename from AttachmentProvider - Cursor cursor = contentResolver.query(uri, ATTACHMENT_PROJECTION, null, null, null); - if (cursor != null) { - try { - if (cursor.moveToNext()) { - filename = cursor.getString(DISPLAY_NAME_INDEX); - } - } finally { - cursor.close(); - } - } - - // Use a dummy filename if necessary - if (filename == null) { - filename = "saved_image"; - } - - // Get the MIME type if we couldn't find a file extension - if (filename.indexOf('.') == -1) { - mimeType = contentResolver.getType(uri); - } - - in = contentResolver.openInputStream(uri); - } - - // Do we still need an extension? - if (filename.indexOf('.') == -1) { - // Use JPEG as fallback - String extension = "jpeg"; - if (mimeType != null) { - // Try to find an extension for the given MIME type - String ext = MimeUtility.getExtensionByMimeType(mimeType); - if (ext != null) { - extension = ext; - } - } - filename += "." + extension; - } - - String sanitized = FileHelper.sanitizeFilename(filename); - - File directory = new File(K9.getAttachmentDefaultPath()); - File file = FileHelper.createUniqueFile(directory, sanitized); - FileOutputStream out = new FileOutputStream(file); - try { - IOUtils.copy(in, out); - out.flush(); - } finally { - out.close(); - } - - return file.getName(); - - } finally { - if (in != null) { - in.close(); - } - } - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } - - @Override - protected void onPostExecute(String filename) { - String text; - if (filename == null) { - text = getContext().getString(R.string.image_saving_failed); - } else { - text = getContext().getString(R.string.image_saved_as, filename); - } - - Toast.makeText(getContext(), text, Toast.LENGTH_LONG).show(); + out.writeInt((this.showingPictures) ? 1 : 0); } } } diff --git a/k9mail/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java new file mode 100644 index 000000000..f0ca38065 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java @@ -0,0 +1,206 @@ +package com.fsck.k9.ui.messageview; + +import java.util.ArrayList; +import java.util.List; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; + +import com.fsck.k9.Account; +import com.fsck.k9.Account.ShowPictures; +import com.fsck.k9.K9; +import com.fsck.k9.R; +import com.fsck.k9.helper.Contacts; +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Flag; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mailstore.MessageViewInfo; +import com.fsck.k9.mailstore.MessageViewInfo.MessageViewContainer; +import com.fsck.k9.view.MessageHeader; + + +public class MessageTopView extends LinearLayout implements ShowPicturesController { + + private MessageHeader mHeaderContainer; + private LayoutInflater mInflater; + private LinearLayout containerViews; + private Button mDownloadRemainder; + private AttachmentViewCallback attachmentCallback; + private OpenPgpHeaderViewCallback openPgpHeaderViewCallback; + private Button showPicturesButton; + private List messageContainerViewsWithPictures = new ArrayList(); + + + public MessageTopView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onFinishInflate() { + mHeaderContainer = (MessageHeader) findViewById(R.id.header_container); + // mHeaderContainer.setOnLayoutChangedListener(this); + mInflater = LayoutInflater.from(getContext()); + + mDownloadRemainder = (Button) findViewById(R.id.download_remainder); + mDownloadRemainder.setVisibility(View.GONE); + + showPicturesButton = (Button) findViewById(R.id.show_pictures); + setShowPicturesButtonListener(); + + containerViews = (LinearLayout) findViewById(R.id.message_containers); + } + + private void setShowPicturesButtonListener() { + showPicturesButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + showPicturesInAllContainerViews(); + } + }); + } + + private void showPicturesInAllContainerViews() { + for (MessageContainerView containerView : messageContainerViewsWithPictures) { + containerView.showPictures(); + } + + hideShowPicturesButton(); + } + + public void resetView() { + mDownloadRemainder.setVisibility(View.GONE); + containerViews.removeAllViews(); + } + + public void setMessage(Account account, MessageViewInfo messageViewInfo) + throws MessagingException { + resetView(); + + ShowPictures showPicturesSetting = account.getShowPictures(); + boolean automaticallyLoadPictures = + shouldAutomaticallyLoadPictures(showPicturesSetting, messageViewInfo.message); + + for (MessageViewContainer container : messageViewInfo.containers) { + MessageContainerView view = (MessageContainerView) mInflater.inflate(R.layout.message_container, null); + boolean displayPgpHeader = account.isOpenPgpProviderConfigured(); + view.displayMessageViewContainer(container, automaticallyLoadPictures, this, attachmentCallback, + openPgpHeaderViewCallback, displayPgpHeader); + + containerViews.addView(view); + } + + } + + /** + * Fetch the message header view. This is not the same as the message headers; this is the View shown at the top + * of messages. + * @return MessageHeader View. + */ + public MessageHeader getMessageHeaderView() { + return mHeaderContainer; + } + + public void setHeaders(final Message message, Account account) { + try { + mHeaderContainer.populate(message, account); + mHeaderContainer.setVisibility(View.VISIBLE); + + + } catch (Exception me) { + Log.e(K9.LOG_TAG, "setHeaders - error", me); + } + } + + public void setOnToggleFlagClickListener(OnClickListener listener) { + mHeaderContainer.setOnFlagListener(listener); + } + + public void showAllHeaders() { + mHeaderContainer.onShowAdditionalHeaders(); + } + + public boolean additionalHeadersVisible() { + return mHeaderContainer.additionalHeadersVisible(); + } + + public void resetHeaderView() { + mHeaderContainer.setVisibility(View.GONE); + } + + public void setOnDownloadButtonClickListener(OnClickListener listener) { + mDownloadRemainder.setOnClickListener(listener); + } + + public void setAttachmentCallback(AttachmentViewCallback callback) { + attachmentCallback = callback; + } + + public void setOpenPgpHeaderViewCallback(OpenPgpHeaderViewCallback callback) { + openPgpHeaderViewCallback = callback; + } + + public void enableDownloadButton() { + mDownloadRemainder.setEnabled(true); + } + + public void disableDownloadButton() { + mDownloadRemainder.setEnabled(false); + } + + public void setShowDownloadButton(Message message) { + if (message.isSet(Flag.X_DOWNLOADED_FULL)) { + mDownloadRemainder.setVisibility(View.GONE); + } else { + mDownloadRemainder.setEnabled(true); + mDownloadRemainder.setVisibility(View.VISIBLE); + } + } + + private void showShowPicturesButton() { + showPicturesButton.setVisibility(View.VISIBLE); + } + + private void hideShowPicturesButton() { + showPicturesButton.setVisibility(View.GONE); + } + + @Override + public void notifyMessageContainerContainsPictures(MessageContainerView messageContainerView) { + messageContainerViewsWithPictures.add(messageContainerView); + + showShowPicturesButton(); + } + + private boolean shouldAutomaticallyLoadPictures(ShowPictures showPicturesSetting, Message message) { + return showPicturesSetting == ShowPictures.ALWAYS || shouldShowPicturesFromSender(showPicturesSetting, message); + } + + private boolean shouldShowPicturesFromSender(ShowPictures showPicturesSetting, Message message) { + if (showPicturesSetting != ShowPictures.ONLY_FROM_CONTACTS) { + return false; + } + + String senderEmailAddress = getSenderEmailAddress(message); + if (senderEmailAddress == null) { + return false; + } + + Contacts contacts = Contacts.getInstance(getContext()); + return contacts.isInContacts(senderEmailAddress); + } + + private String getSenderEmailAddress(Message message) { + Address[] from = message.getFrom(); + if (from == null || from.length == 0) { + return null; + } + + return from[0].getAddress(); + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/fragment/MessageViewFragment.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.java similarity index 60% rename from k9mail/src/main/java/com/fsck/k9/fragment/MessageViewFragment.java rename to k9mail/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.java index 961a38555..de9aa281d 100644 --- a/k9mail/src/main/java/com/fsck/k9/fragment/MessageViewFragment.java +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.java @@ -1,18 +1,23 @@ -package com.fsck.k9.fragment; +package com.fsck.k9.ui.messageview; -import java.io.File; import java.util.Collections; import java.util.Locale; import android.app.Activity; +import android.app.DialogFragment; import android.app.Fragment; +import android.app.FragmentManager; +import android.app.LoaderManager; +import android.app.LoaderManager.LoaderCallbacks; +import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.content.IntentSender; +import android.content.Loader; import android.net.Uri; import android.os.Bundle; import android.os.Handler; -import android.app.DialogFragment; -import android.app.FragmentManager; +import android.text.TextUtils; import android.util.Log; import android.view.ContextThemeWrapper; import android.view.KeyEvent; @@ -31,24 +36,25 @@ import com.fsck.k9.activity.MessageReference; import com.fsck.k9.controller.MessagingController; import com.fsck.k9.controller.MessagingListener; import com.fsck.k9.crypto.PgpData; +import com.fsck.k9.fragment.ConfirmationDialogFragment; import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener; +import com.fsck.k9.fragment.ProgressDialogFragment; import com.fsck.k9.helper.FileBrowserHelper; import com.fsck.k9.helper.FileBrowserHelper.FileBrowserFailOverCallback; import com.fsck.k9.mail.Flag; -import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.Part; +import com.fsck.k9.mailstore.AttachmentViewInfo; import com.fsck.k9.mailstore.LocalMessage; -import com.fsck.k9.view.AttachmentView; -import com.fsck.k9.view.AttachmentView.AttachmentFileDownloadCallback; +import com.fsck.k9.mailstore.MessageViewInfo; +import com.fsck.k9.ui.crypto.MessageCryptoCallback; +import com.fsck.k9.ui.crypto.MessageCryptoHelper; +import com.fsck.k9.ui.message.DecodeMessageLoader; +import com.fsck.k9.ui.message.LocalMessageLoader; +import com.fsck.k9.ui.crypto.MessageCryptoAnnotations; import com.fsck.k9.view.MessageHeader; -import com.fsck.k9.view.SingleMessageView; -import org.openintents.openpgp.OpenPgpSignatureResult; - - -public class MessageViewFragment extends Fragment implements OnClickListener, - ConfirmationDialogFragmentListener { +public class MessageViewFragment extends Fragment implements ConfirmationDialogFragmentListener, + AttachmentViewCallback, OpenPgpHeaderViewCallback, MessageCryptoCallback { private static final String ARG_REFERENCE = "reference"; @@ -59,6 +65,8 @@ public class MessageViewFragment extends Fragment implements OnClickListener, private static final int ACTIVITY_CHOOSE_FOLDER_COPY = 2; private static final int ACTIVITY_CHOOSE_DIRECTORY = 3; + private static final int LOCAL_MESSAGE_LOADER_ID = 1; + private static final int DECODE_MESSAGE_LOADER_ID = 2; public static MessageViewFragment newInstance(MessageReference reference) { MessageViewFragment fragment = new MessageViewFragment(); @@ -70,22 +78,16 @@ public class MessageViewFragment extends Fragment implements OnClickListener, return fragment; } - - private SingleMessageView mMessageView; + private MessageTopView mMessageView; private PgpData mPgpData; private Account mAccount; private MessageReference mMessageReference; private LocalMessage mMessage; + private MessageCryptoAnnotations messageAnnotations; private MessagingController mController; - private Listener mListener = new Listener(); - private MessageViewHandler mHandler = new MessageViewHandler(); - private LayoutInflater mLayoutInflater; - - /** this variable is used to save the calling AttachmentView - * until the onActivityResult is called. - * => with this reference we can identity the caller - */ - private AttachmentView attachmentTmpStore; + private Handler handler = new Handler(); + private DownloadMessageListener downloadMessageListener = new DownloadMessageListener(); + private MessageCryptoHelper messageCryptoHelper; /** * Used to temporarily store the destination folder for refile operations if a confirmation @@ -104,66 +106,10 @@ public class MessageViewFragment extends Fragment implements OnClickListener, private Context mContext; - - class MessageViewHandler extends Handler { - - public void progress(final boolean progress) { - post(new Runnable() { - @Override - public void run() { - setProgress(progress); - } - }); - } - - public void addAttachment(final View attachmentView) { - post(new Runnable() { - @Override - public void run() { - mMessageView.addAttachment(attachmentView); - } - }); - } - - /* A helper for a set of "show a toast" methods */ - private void showToast(final String message, final int toastLength) { - post(new Runnable() { - @Override - public void run() { - Toast.makeText(getActivity(), message, toastLength).show(); - } - }); - } - - public void networkError() { - // FIXME: This is a hack. Fix the Handler madness! - Context context = getActivity(); - if (context == null) { - return; - } - - showToast(context.getString(R.string.status_network_error), Toast.LENGTH_LONG); - } - - public void invalidIdError() { - Context context = getActivity(); - if (context == null) { - return; - } - - showToast(context.getString(R.string.status_invalid_id_error), Toast.LENGTH_LONG); - } - - - public void fetchingAttachment() { - Context context = getActivity(); - if (context == null) { - return; - } - - showToast(context.getString(R.string.message_view_fetching_attachment_toast), Toast.LENGTH_SHORT); - } - } + private LoaderCallbacks localMessageLoaderCallback = new LocalMessageLoaderCallback(); + private LoaderCallbacks decodeMessageLoaderCallback = new DecodeMessageLoaderCallback(); + private MessageViewInfo messageViewInfo; + private AttachmentViewInfo currentAttachmentViewInfo; @Override public void onAttach(Activity activity) { @@ -195,42 +141,26 @@ public class MessageViewFragment extends Fragment implements OnClickListener, Bundle savedInstanceState) { Context context = new ContextThemeWrapper(inflater.getContext(), K9.getK9ThemeResourceId(K9.getK9MessageViewTheme())); - mLayoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - View view = mLayoutInflater.inflate(R.layout.message, container, false); + LayoutInflater layoutInflater = LayoutInflater.from(context); + View view = layoutInflater.inflate(R.layout.message, container, false); + mMessageView = (MessageTopView) view.findViewById(R.id.message_view); + mMessageView.setAttachmentCallback(this); + mMessageView.setOpenPgpHeaderViewCallback(this); - mMessageView = (SingleMessageView) view.findViewById(R.id.message_view); - - //set a callback for the attachment view. With this callback the attachmentview - //request the start of a filebrowser activity. - mMessageView.setAttachmentCallback(new AttachmentFileDownloadCallback() { - + mMessageView.setOnToggleFlagClickListener(new OnClickListener() { @Override - public void pickDirectoryToSaveAttachmentTo(final AttachmentView caller) { - FileBrowserHelper.getInstance() - .showFileBrowserActivity(MessageViewFragment.this, - null, - ACTIVITY_CHOOSE_DIRECTORY, - callback); - attachmentTmpStore = caller; + public void onClick(View v) { + onToggleFlagged(); } - - FileBrowserFailOverCallback callback = new FileBrowserFailOverCallback() { - - @Override - public void onPathEntered(String path) { - attachmentTmpStore.writeFile(new File(path)); - } - - @Override - public void onCancel() { - // canceled, do nothing - } - }; }); - mMessageView.initialize(this); - mMessageView.downloadRemainderButton().setOnClickListener(this); + mMessageView.setOnDownloadButtonClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + onDownloadRemainder(); + } + }); mFragmentListener.messageHeaderViewAvailable(mMessageView.getMessageHeaderView()); @@ -247,7 +177,7 @@ public class MessageViewFragment extends Fragment implements OnClickListener, messageReference = (MessageReference) savedInstanceState.get(STATE_MESSAGE_REFERENCE); } else { Bundle args = getArguments(); - messageReference = (MessageReference) args.getParcelable(ARG_REFERENCE); + messageReference = args.getParcelable(ARG_REFERENCE); } displayMessage(messageReference, (mPgpData == null)); @@ -260,10 +190,6 @@ public class MessageViewFragment extends Fragment implements OnClickListener, outState.putSerializable(STATE_PGP_DATA, mPgpData); } - public void displayMessage(MessageReference ref) { - displayMessage(ref, true); - } - private void displayMessage(MessageReference ref, boolean resetPgpData) { mMessageReference = ref; if (K9.DEBUG) { @@ -272,6 +198,7 @@ public class MessageViewFragment extends Fragment implements OnClickListener, Context appContext = getActivity().getApplicationContext(); mAccount = Preferences.getPreferences(appContext).getAccount(mMessageReference.accountUuid); + messageCryptoHelper = new MessageCryptoHelper(getActivity(), mAccount, this); if (resetPgpData) { // start with fresh, empty PGP data @@ -282,11 +209,90 @@ public class MessageViewFragment extends Fragment implements OnClickListener, mMessageView.resetView(); mMessageView.resetHeaderView(); - mController.loadMessageForView(mAccount, mMessageReference.folderName, mMessageReference.uid, mListener); + startLoadingMessageFromDatabase(); mFragmentListener.updateMenu(); } + public void handleCryptoResult(int requestCode, int resultCode, Intent data) { + if (messageCryptoHelper != null) { + messageCryptoHelper.handleCryptoResult(requestCode, resultCode, data); + } + } + + private void startLoadingMessageFromDatabase() { + getLoaderManager().initLoader(LOCAL_MESSAGE_LOADER_ID, null, localMessageLoaderCallback); + } + + private void onLoadMessageFromDatabaseFinished(LocalMessage message) { + displayMessageHeader(message); + + if (message.isBodyMissing()) { + startDownloadingMessageBody(message); + } else { + messageCryptoHelper.decryptOrVerifyMessagePartsIfNecessary(message); + } + } + + private void onLoadMessageFromDatabaseFailed() { + // mMessageView.showStatusMessage(mContext.getString(R.string.status_invalid_id_error)); + } + + private void startDownloadingMessageBody(LocalMessage message) { + throw new RuntimeException("Not implemented yet"); + } + + private void onMessageDownloadFinished(LocalMessage message) { + mMessage = message; + + LoaderManager loaderManager = getLoaderManager(); + loaderManager.destroyLoader(LOCAL_MESSAGE_LOADER_ID); + loaderManager.destroyLoader(DECODE_MESSAGE_LOADER_ID); + + onLoadMessageFromDatabaseFinished(mMessage); + } + + private void onDownloadMessageFailed(Throwable t) { + mMessageView.enableDownloadButton(); + String errorMessage; + if (t instanceof IllegalArgumentException) { + errorMessage = mContext.getString(R.string.status_invalid_id_error); + } else { + errorMessage = mContext.getString(R.string.status_network_error); + } + Toast.makeText(mContext, errorMessage, Toast.LENGTH_LONG).show(); + } + + @Override + public void onCryptoOperationsFinished(MessageCryptoAnnotations annotations) { + startExtractingTextAndAttachments(annotations); + } + + private void startExtractingTextAndAttachments(MessageCryptoAnnotations annotations) { + this.messageAnnotations = annotations; + getLoaderManager().initLoader(DECODE_MESSAGE_LOADER_ID, null, decodeMessageLoaderCallback); + } + + private void onDecodeMessageFinished(MessageViewInfo messageContainer) { + this.messageViewInfo = messageContainer; + showMessage(messageContainer); + } + + private void showMessage(MessageViewInfo messageContainer) { + try { + mMessageView.setMessage(mAccount, messageContainer); + mMessageView.setShowDownloadButton(mMessage); + } catch (MessagingException e) { + Log.e(K9.LOG_TAG, "Error while trying to display message", e); + } + } + + private void displayMessageHeader(LocalMessage message) { + mMessageView.setHeaders(message, mAccount); + displayMessageSubject(getSubjectForMessage(message)); + mFragmentListener.updateMenu(); + } + /** * Called from UI thread when user select Delete */ @@ -411,7 +417,8 @@ public class MessageViewFragment extends Fragment implements OnClickListener, } public void onSelectText() { - mMessageView.beginSelectingText(); + // FIXME + // mMessageView.beginSelectingText(); } private void startRefileActivity(int activity) { @@ -423,7 +430,6 @@ public class MessageViewFragment extends Fragment implements OnClickListener, startActivityForResult(intent, activity); } - @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { if (resultCode != Activity.RESULT_OK) { @@ -432,13 +438,13 @@ public class MessageViewFragment extends Fragment implements OnClickListener, switch (requestCode) { case ACTIVITY_CHOOSE_DIRECTORY: { - if (resultCode == Activity.RESULT_OK && data != null) { + if (data != null) { // obtain the filename Uri fileUri = data.getData(); if (fileUri != null) { String filePath = fileUri.getPath(); if (filePath != null) { - attachmentTmpStore.writeFile(new File(filePath)); + getAttachmentController(currentAttachmentViewInfo).saveAttachmentTo(filePath); } } } @@ -492,15 +498,10 @@ public class MessageViewFragment extends Fragment implements OnClickListener, if (mMessage.isSet(Flag.X_DOWNLOADED_FULL)) { return; } - mMessageView.downloadRemainderButton().setEnabled(false); - mController.loadMessageForViewRemote(mAccount, mMessageReference.folderName, mMessageReference.uid, mListener); - } + mMessageView.disableDownloadButton(); - @Override - public void onClick(View view) { - if (view.getId() == R.id.download_remainder) { - onDownloadRemainder(); - } + mController.loadMessageForViewRemote(mAccount, mMessageReference.folderName, mMessageReference.uid, + downloadMessageListener); } private void setProgress(boolean enable) { @@ -515,6 +516,15 @@ public class MessageViewFragment extends Fragment implements OnClickListener, } } + private String getSubjectForMessage(LocalMessage message) { + String subject = message.getSubject(); + if (TextUtils.isEmpty(subject)) { + return mContext.getString(R.string.general_no_subject); + } + + return subject; + } + public void moveMessage(MessageReference reference, String destFolderName) { mController.moveMessage(mAccount, mMessageReference.folderName, mMessage, destFolderName, null); @@ -525,202 +535,6 @@ public class MessageViewFragment extends Fragment implements OnClickListener, destFolderName, null); } - class Listener extends MessagingListener { - @Override - public void loadMessageForViewHeadersAvailable(final Account account, String folder, String uid, - final Message message) { - if (!mMessageReference.uid.equals(uid) || !mMessageReference.folderName.equals(folder) - || !mMessageReference.accountUuid.equals(account.getUuid())) { - return; - } - - /* - * Clone the message object because the original could be modified by - * MessagingController later. This could lead to a ConcurrentModificationException - * when that same object is accessed by the UI thread (below). - * - * See issue 3953 - * - * This is just an ugly hack to get rid of the most pressing problem. A proper way to - * fix this is to make Message thread-safe. Or, even better, rewriting the UI code to - * access messages via a ContentProvider. - * - */ - final Message clonedMessage = message.clone(); - - mHandler.post(new Runnable() { - @Override - public void run() { - if (!clonedMessage.isSet(Flag.X_DOWNLOADED_FULL) && - !clonedMessage.isSet(Flag.X_DOWNLOADED_PARTIAL)) { - String text = mContext.getString(R.string.message_view_downloading); - mMessageView.showStatusMessage(text); - } - mMessageView.setHeaders(clonedMessage, account); - final String subject = clonedMessage.getSubject(); - if (subject == null || subject.equals("")) { - displayMessageSubject(mContext.getString(R.string.general_no_subject)); - } else { - displayMessageSubject(clonedMessage.getSubject()); - } - mMessageView.setOnFlagListener(new OnClickListener() { - @Override - public void onClick(View v) { - onToggleFlagged(); - } - }); - } - }); - } - - @Override - public void loadMessageForViewBodyAvailable(final Account account, String folder, - String uid, final Message message) { - if (!(message instanceof LocalMessage) || - !mMessageReference.uid.equals(uid) || - !mMessageReference.folderName.equals(folder) || - !mMessageReference.accountUuid.equals(account.getUuid())) { - return; - } - - mHandler.post(new Runnable() { - @Override - public void run() { - try { - mMessage = (LocalMessage) message; - mMessageView.setMessage(account, (LocalMessage) message, mPgpData, - mController, mListener); - mFragmentListener.updateMenu(); - - } catch (MessagingException e) { - Log.v(K9.LOG_TAG, "loadMessageForViewBodyAvailable", e); - } - } - }); - } - - @Override - public void loadMessageForViewFailed(Account account, String folder, String uid, final Throwable t) { - if (!mMessageReference.uid.equals(uid) || !mMessageReference.folderName.equals(folder) - || !mMessageReference.accountUuid.equals(account.getUuid())) { - return; - } - mHandler.post(new Runnable() { - @Override - public void run() { - setProgress(false); - if (t instanceof IllegalArgumentException) { - mHandler.invalidIdError(); - } else { - mHandler.networkError(); - } - if (mMessage == null || mMessage.isSet(Flag.X_DOWNLOADED_PARTIAL)) { - mMessageView.showStatusMessage( - mContext.getString(R.string.webview_empty_message)); - } - } - }); - } - - @Override - public void loadMessageForViewFinished(Account account, String folder, String uid, final Message message) { - if (!mMessageReference.uid.equals(uid) || !mMessageReference.folderName.equals(folder) - || !mMessageReference.accountUuid.equals(account.getUuid())) { - return; - } - mHandler.post(new Runnable() { - @Override - public void run() { - setProgress(false); - mMessageView.setShowDownloadButton(message); - } - }); - } - - @Override - public void loadMessageForViewStarted(Account account, String folder, String uid) { - if (!mMessageReference.uid.equals(uid) || !mMessageReference.folderName.equals(folder) - || !mMessageReference.accountUuid.equals(account.getUuid())) { - return; - } - mHandler.post(new Runnable() { - @Override - public void run() { - setProgress(true); - } - }); - } - - @Override - public void loadAttachmentStarted(Account account, Message message, Part part, Object tag, final boolean requiresDownload) { - if (mMessage != message) { - return; - } - mHandler.post(new Runnable() { - @Override - public void run() { - mMessageView.setAttachmentsEnabled(false); - showDialog(R.id.dialog_attachment_progress); - if (requiresDownload) { - mHandler.fetchingAttachment(); - } - } - }); - } - - @Override - public void loadAttachmentFinished(Account account, Message message, Part part, final Object tag) { - if (mMessage != message) { - return; - } - mHandler.post(new Runnable() { - @Override - public void run() { - mMessageView.setAttachmentsEnabled(true); - removeDialog(R.id.dialog_attachment_progress); - Object[] params = (Object[]) tag; - boolean download = (Boolean) params[0]; - AttachmentView attachment = (AttachmentView) params[1]; - if (download) { - attachment.writeFile(); - } else { - attachment.showFile(); - } - } - }); - } - - @Override - public void loadAttachmentFailed(Account account, Message message, Part part, Object tag, String reason) { - if (mMessage != message) { - return; - } - mHandler.post(new Runnable() { - @Override - public void run() { - mMessageView.setAttachmentsEnabled(true); - removeDialog(R.id.dialog_attachment_progress); - mHandler.networkError(); - } - }); - } - } - - /** - * Used by MessageOpenPgpView - */ - public void setMessageWithOpenPgp(String decryptedData, OpenPgpSignatureResult signatureResult) { - try { - // TODO: get rid of PgpData? - PgpData data = new PgpData(); - data.setDecryptedData(decryptedData); - data.setSignatureResult(signatureResult); - mMessageView.setMessage(mAccount, (LocalMessage) mMessage, data, mController, mListener); - } catch (MessagingException e) { - Log.e(K9.LOG_TAG, "displayMessageBody failed", e); - } - } - private void showDialog(int dialogId) { DialogFragment fragment; switch (dialogId) { @@ -782,7 +596,7 @@ public class MessageViewFragment extends Fragment implements OnClickListener, } public void zoom(KeyEvent event) { - mMessageView.zoom(event); + // mMessageView.zoom(event); } @Override @@ -845,6 +659,52 @@ public class MessageViewFragment extends Fragment implements OnClickListener, } } + public Context getContext() { + return mContext; + } + + public void disableAttachmentButtons(AttachmentViewInfo attachment) { + // mMessageView.disableAttachmentButtons(attachment); + } + + public void enableAttachmentButtons(AttachmentViewInfo attachment) { + // mMessageView.enableAttachmentButtons(attachment); + } + + public void runOnMainThread(Runnable runnable) { + handler.post(runnable); + } + + public void showAttachmentLoadingDialog() { + // mMessageView.disableAttachmentButtons(); + showDialog(R.id.dialog_attachment_progress); + } + + public void hideAttachmentLoadingDialogOnMainThread() { + handler.post(new Runnable() { + @Override + public void run() { + removeDialog(R.id.dialog_attachment_progress); + // mMessageView.enableAttachmentButtons(); + } + }); + } + + public void refreshAttachmentThumbnail(AttachmentViewInfo attachment) { + // mMessageView.refreshAttachmentThumbnail(attachment); + } + + @Override + public void onPgpSignatureButtonClick(PendingIntent pendingIntent) { + try { + getActivity().startIntentSenderForResult( + pendingIntent.getIntentSender(), + 42, null, 0, 0, 0); + } catch (IntentSender.SendIntentException e) { + Log.e(K9.LOG_TAG, "SendIntentException", e); + } + } + public interface MessageViewFragmentListener { public void onForward(LocalMessage mMessage, PgpData mPgpData); public void disableDeleteAction(); @@ -861,7 +721,105 @@ public class MessageViewFragment extends Fragment implements OnClickListener, return mInitialized ; } - public LayoutInflater getFragmentLayoutInflater() { - return mLayoutInflater; + class LocalMessageLoaderCallback implements LoaderCallbacks { + @Override + public Loader onCreateLoader(int id, Bundle args) { + setProgress(true); + return new LocalMessageLoader(mContext, mController, mAccount, mMessageReference); + } + + @Override + public void onLoadFinished(Loader loader, LocalMessage message) { + setProgress(false); + mMessage = message; + if (message == null) { + onLoadMessageFromDatabaseFailed(); + } else { + onLoadMessageFromDatabaseFinished(message); + } + } + + @Override + public void onLoaderReset(Loader loader) { + // Do nothing + } + } + + class DecodeMessageLoaderCallback implements LoaderCallbacks { + @Override + public Loader onCreateLoader(int id, Bundle args) { + setProgress(true); + return new DecodeMessageLoader(mContext, mMessage, messageAnnotations); + } + + @Override + public void onLoadFinished(Loader loader, MessageViewInfo messageContainer) { + setProgress(false); + onDecodeMessageFinished(messageContainer); + } + + @Override + public void onLoaderReset(Loader loader) { + // Do nothing + } + } + + @Override + public void onViewAttachment(AttachmentViewInfo attachment) { + //TODO: check if we have to download the attachment first + + getAttachmentController(attachment).viewAttachment(); + } + + @Override + public void onSaveAttachment(AttachmentViewInfo attachment) { + //TODO: check if we have to download the attachment first + + getAttachmentController(attachment).saveAttachment(); + } + + @Override + public void onSaveAttachmentToUserProvidedDirectory(final AttachmentViewInfo attachment) { + //TODO: check if we have to download the attachment first + + currentAttachmentViewInfo = attachment; + FileBrowserHelper.getInstance().showFileBrowserActivity(MessageViewFragment.this, null, + ACTIVITY_CHOOSE_DIRECTORY, new FileBrowserFailOverCallback() { + @Override + public void onPathEntered(String path) { + getAttachmentController(attachment).saveAttachmentTo(path); + } + + @Override + public void onCancel() { + // Do nothing + } + }); + } + + private AttachmentController getAttachmentController(AttachmentViewInfo attachment) { + return new AttachmentController(mController, this, attachment); + } + + private class DownloadMessageListener extends MessagingListener { + @Override + public void loadMessageForViewFinished(Account account, String folder, String uid, final LocalMessage message) { + handler.post(new Runnable() { + @Override + public void run() { + onMessageDownloadFinished(message); + } + }); + } + + @Override + public void loadMessageForViewFailed(Account account, String folder, String uid, final Throwable t) { + handler.post(new Runnable() { + @Override + public void run() { + onDownloadMessageFailed(t); + } + }); + } } } diff --git a/k9mail/src/main/java/com/fsck/k9/ui/messageview/OpenPgpHeaderView.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/OpenPgpHeaderView.java new file mode 100644 index 000000000..d65c75d15 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/OpenPgpHeaderView.java @@ -0,0 +1,363 @@ + +package com.fsck.k9.ui.messageview; + + +import android.app.PendingIntent; +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.support.annotation.ColorRes; +import android.support.annotation.DrawableRes; +import android.support.annotation.StringRes; +import android.util.AttributeSet; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.fsck.k9.R; + +import com.fsck.k9.mailstore.OpenPgpResultAnnotation; +import org.openintents.openpgp.OpenPgpError; +import org.openintents.openpgp.OpenPgpSignatureResult; +import org.openintents.openpgp.util.OpenPgpUtils; + + +public class OpenPgpHeaderView extends LinearLayout { + private Context context; + private OpenPgpHeaderViewCallback callback; + + private OpenPgpResultAnnotation cryptoAnnotation; + + private ImageView resultEncryptionIcon; + private TextView resultEncryptionText; + private ImageView resultSignatureIcon; + private TextView resultSignatureText; + private LinearLayout resultSignatureLayout; + private TextView resultSignatureName; + private TextView resultSignatureEmail; + private Button resultSignatureButton; + + + public OpenPgpHeaderView(Context context, AttributeSet attrs) { + super(context, attrs); + this.context = context; + } + + @Override + public void onFinishInflate() { + resultEncryptionIcon = (ImageView) findViewById(R.id.result_encryption_icon); + resultEncryptionText = (TextView) findViewById(R.id.result_encryption_text); + resultSignatureIcon = (ImageView) findViewById(R.id.result_signature_icon); + resultSignatureText = (TextView) findViewById(R.id.result_signature_text); + resultSignatureLayout = (LinearLayout) findViewById(R.id.result_signature_layout); + resultSignatureName = (TextView) findViewById(R.id.result_signature_name); + resultSignatureEmail = (TextView) findViewById(R.id.result_signature_email); + resultSignatureButton = (Button) findViewById(R.id.result_signature_button); + } + + public void setCallback(OpenPgpHeaderViewCallback callback) { + this.callback = callback; + } + + public void setOpenPgpData(OpenPgpResultAnnotation cryptoAnnotation) { + this.cryptoAnnotation = cryptoAnnotation; + + initializeEncryptionHeader(); + initializeSignatureHeader(); + } + + private void initializeEncryptionHeader() { + if (noCryptoAnnotationFound()) { + displayNotEncrypted(); + return; + } + + switch (cryptoAnnotation.getErrorType()) { + case NONE: { + if (cryptoAnnotation.wasEncrypted()) { + displayEncrypted(); + } else { + displayNotEncrypted(); + } + break; + } + case CRYPTO_API_RETURNED_ERROR: { + displayEncryptionError(); + break; + } + case ENCRYPTED_BUT_INCOMPLETE: { + displayIncompleteEncryptedPart(); + break; + } + case SIGNED_BUT_INCOMPLETE: { + displayNotEncrypted(); + break; + } + } + } + + private boolean noCryptoAnnotationFound() { + return cryptoAnnotation == null; + } + + private void displayEncrypted() { + setEncryptionImageAndTextColor(CryptoState.ENCRYPTED); + resultEncryptionText.setText(R.string.openpgp_result_encrypted); + } + + private void displayNotEncrypted() { + setEncryptionImageAndTextColor(CryptoState.NOT_ENCRYPTED); + resultEncryptionText.setText(R.string.openpgp_result_not_encrypted); + } + + private void displayEncryptionError() { + setEncryptionImageAndTextColor(CryptoState.INVALID); + + OpenPgpError error = cryptoAnnotation.getError(); + String text; + if (error == null) { + text = context.getString(R.string.openpgp_unknown_error); + } else { + text = context.getString(R.string.openpgp_error, error.getMessage()); + } + resultEncryptionText.setText(text); + } + + private void displayIncompleteEncryptedPart() { + setEncryptionImageAndTextColor(CryptoState.UNAVAILABLE); + resultEncryptionText.setText(R.string.crypto_incomplete_message); + } + + private void initializeSignatureHeader() { + initializeSignatureButton(); + + if (noCryptoAnnotationFound()) { + displayNotSigned(); + return; + } + + switch (cryptoAnnotation.getErrorType()) { + case CRYPTO_API_RETURNED_ERROR: + case NONE: { + displayVerificationResult(); + break; + } + case ENCRYPTED_BUT_INCOMPLETE: + case SIGNED_BUT_INCOMPLETE: { + displayIncompleteSignedPart(); + break; + } + } + } + + private void displayIncompleteSignedPart() { + setSignatureImageAndTextColor(CryptoState.UNAVAILABLE); + resultSignatureText.setText(R.string.crypto_incomplete_message); + hideSignatureLayout(); + } + + private void displayVerificationResult() { + OpenPgpSignatureResult signatureResult = cryptoAnnotation.getSignatureResult(); + if (signatureResult == null) { + displayNotSigned(); + return; + } + + switch (signatureResult.getStatus()) { + case OpenPgpSignatureResult.SIGNATURE_ERROR: { + displaySignatureError(); + break; + } + case OpenPgpSignatureResult.SIGNATURE_SUCCESS_CERTIFIED: { + displaySignatureSuccessCertified(); + break; + } + case OpenPgpSignatureResult.SIGNATURE_KEY_MISSING: { + displaySignatureKeyMissing(); + break; + } + case OpenPgpSignatureResult.SIGNATURE_SUCCESS_UNCERTIFIED: { + displaySignatureSuccessUncertified(); + break; + } + case OpenPgpSignatureResult.SIGNATURE_KEY_EXPIRED: { + displaySignatureKeyExpired(); + break; + } + case OpenPgpSignatureResult.SIGNATURE_KEY_REVOKED: { + displaySignatureKeyRevoked(); + break; + } + } + } + + private void initializeSignatureButton() { + if (noCryptoAnnotationFound()) { + hideSignatureButton(); + } else if (isSignatureButtonUsed()) { + setSignatureButtonClickListener(); + } else { + hideSignatureButton(); + } + } + + private boolean isSignatureButtonUsed() { + return cryptoAnnotation.getPendingIntent() != null; + } + + private void setSignatureButtonClickListener() { + final PendingIntent pendingIntent = cryptoAnnotation.getPendingIntent(); + resultSignatureButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + callback.onPgpSignatureButtonClick(pendingIntent); + } + }); + } + + private void hideSignatureButton() { + resultSignatureButton.setVisibility(View.GONE); + resultSignatureButton.setOnClickListener(null); + } + + private void showSignatureButtonWithTextIfNecessary(@StringRes int stringId) { + if (isSignatureButtonUsed()) { + resultSignatureButton.setVisibility(View.VISIBLE); + resultSignatureButton.setText(stringId); + } + } + + private void displayNotSigned() { + setSignatureImageAndTextColor(CryptoState.NOT_SIGNED); + resultSignatureText.setText(R.string.openpgp_result_no_signature); + hideSignatureLayout(); + } + + private void displaySignatureError() { + setSignatureImageAndTextColor(CryptoState.INVALID); + resultSignatureText.setText(R.string.openpgp_result_invalid_signature); + hideSignatureLayout(); + } + + private void displaySignatureSuccessCertified() { + setSignatureImageAndTextColor(CryptoState.VERIFIED); + resultSignatureText.setText(R.string.openpgp_result_signature_certified); + + displayUserIdAndSignatureButton(); + } + + private void displaySignatureKeyMissing() { + setSignatureImageAndTextColor(CryptoState.UNKNOWN_KEY); + resultSignatureText.setText(R.string.openpgp_result_signature_missing_key); + + setUserId(cryptoAnnotation.getSignatureResult()); + showSignatureButtonWithTextIfNecessary(R.string.openpgp_result_action_lookup); + showSignatureLayout(); + } + + private void displaySignatureSuccessUncertified() { + setSignatureImageAndTextColor(CryptoState.UNVERIFIED); + resultSignatureText.setText(R.string.openpgp_result_signature_uncertified); + + displayUserIdAndSignatureButton(); + } + + private void displaySignatureKeyExpired() { + setSignatureImageAndTextColor(CryptoState.EXPIRED); + resultSignatureText.setText(R.string.openpgp_result_signature_expired_key); + + displayUserIdAndSignatureButton(); + } + + private void displaySignatureKeyRevoked() { + setSignatureImageAndTextColor(CryptoState.REVOKED); + resultSignatureText.setText(R.string.openpgp_result_signature_revoked_key); + + displayUserIdAndSignatureButton(); + } + + private void displayUserIdAndSignatureButton() { + setUserId(cryptoAnnotation.getSignatureResult()); + showSignatureButtonWithTextIfNecessary(R.string.openpgp_result_action_show); + showSignatureLayout(); + } + + private void setUserId(OpenPgpSignatureResult signatureResult) { + final OpenPgpUtils.UserId userInfo = OpenPgpUtils.splitUserId(signatureResult.getPrimaryUserId()); + if (userInfo.name != null) { + resultSignatureName.setText(userInfo.name); + } else { + resultSignatureName.setText(R.string.openpgp_result_no_name); + } + + if (userInfo.email != null) { + resultSignatureEmail.setText(userInfo.email); + } else { + resultSignatureEmail.setText(R.string.openpgp_result_no_email); + } + } + + private void hideSignatureLayout() { + resultSignatureLayout.setVisibility(View.GONE); + } + + private void showSignatureLayout() { + resultSignatureLayout.setVisibility(View.VISIBLE); + } + + private void setEncryptionImageAndTextColor(CryptoState state) { + setStatusImageAndTextColor(resultEncryptionIcon, resultEncryptionText, state); + } + + private void setSignatureImageAndTextColor(CryptoState state) { + setStatusImageAndTextColor(resultSignatureIcon, resultSignatureText, state); + } + + private void setStatusImageAndTextColor(ImageView statusIcon, TextView statusText, CryptoState state) { + Drawable statusImageDrawable = context.getResources().getDrawable(state.getDrawableId()); + statusIcon.setImageDrawable(statusImageDrawable); + + int color = context.getResources().getColor(state.getColorId()); + statusIcon.setColorFilter(color, PorterDuff.Mode.SRC_IN); + if (statusText != null) { + statusText.setTextColor(color); + } + } + + + private enum CryptoState { + VERIFIED(R.drawable.status_signature_verified_cutout, R.color.openpgp_green), + ENCRYPTED(R.drawable.status_lock_closed, R.color.openpgp_green), + + UNAVAILABLE(R.drawable.status_signature_unverified_cutout, R.color.openpgp_orange), + UNVERIFIED(R.drawable.status_signature_unverified_cutout, R.color.openpgp_orange), + UNKNOWN_KEY(R.drawable.status_signature_unknown_cutout, R.color.openpgp_orange), + + REVOKED(R.drawable.status_signature_revoked_cutout, R.color.openpgp_red), + EXPIRED(R.drawable.status_signature_expired_cutout, R.color.openpgp_red), + NOT_ENCRYPTED(R.drawable.status_lock_open, R.color.openpgp_red), + NOT_SIGNED(R.drawable.status_signature_unknown_cutout, R.color.openpgp_red), + INVALID(R.drawable.status_signature_invalid_cutout, R.color.openpgp_red); + + + private final int drawableId; + private final int colorId; + + CryptoState(@DrawableRes int drawableId, @ColorRes int colorId) { + this.drawableId = drawableId; + this.colorId = colorId; + } + + @DrawableRes + public int getDrawableId() { + return drawableId; + } + + @ColorRes + public int getColorId() { + return colorId; + } + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/messageview/OpenPgpHeaderViewCallback.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/OpenPgpHeaderViewCallback.java new file mode 100644 index 000000000..351e6a567 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/OpenPgpHeaderViewCallback.java @@ -0,0 +1,9 @@ +package com.fsck.k9.ui.messageview; + + +import android.app.PendingIntent; + + +interface OpenPgpHeaderViewCallback { + void onPgpSignatureButtonClick(PendingIntent pendingIntent); +} diff --git a/k9mail/src/main/java/com/fsck/k9/ui/messageview/ShowPicturesController.java b/k9mail/src/main/java/com/fsck/k9/ui/messageview/ShowPicturesController.java new file mode 100644 index 000000000..78ee0c9e9 --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/ui/messageview/ShowPicturesController.java @@ -0,0 +1,6 @@ +package com.fsck.k9.ui.messageview; + + +interface ShowPicturesController { + void notifyMessageContainerContainsPictures(MessageContainerView messageContainerView); +} diff --git a/k9mail/src/main/java/com/fsck/k9/view/AttachmentView.java b/k9mail/src/main/java/com/fsck/k9/view/AttachmentView.java deleted file mode 100644 index 7552c23b5..000000000 --- a/k9mail/src/main/java/com/fsck/k9/view/AttachmentView.java +++ /dev/null @@ -1,479 +0,0 @@ -package com.fsck.k9.view; - - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.List; - -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Environment; -import android.util.AttributeSet; -import android.util.Log; -import android.view.View; -import android.view.View.OnClickListener; -import android.view.View.OnLongClickListener; -import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.R; -import com.fsck.k9.cache.TemporaryAttachmentStore; -import com.fsck.k9.controller.MessagingController; -import com.fsck.k9.controller.MessagingListener; -import com.fsck.k9.helper.FileHelper; -import com.fsck.k9.helper.MediaScannerNotifier; -import com.fsck.k9.helper.SizeFormatter; -import com.fsck.k9.mail.Message; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.Part; -import com.fsck.k9.mail.internet.MimeHeader; -import com.fsck.k9.mail.internet.MimeUtility; -import com.fsck.k9.mailstore.LocalAttachmentBodyPart; -import com.fsck.k9.provider.AttachmentProvider; -import org.apache.commons.io.IOUtils; - - -public class AttachmentView extends FrameLayout implements OnClickListener, OnLongClickListener { - private Context context; - private Message message; - private LocalAttachmentBodyPart part; - private Account account; - private MessagingController controller; - private MessagingListener listener; - private AttachmentFileDownloadCallback callback; - - private Button viewButton; - private Button downloadButton; - - private String name; - private String contentType; - private long size; - - - public AttachmentView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - this.context = context; - } - - public AttachmentView(Context context, AttributeSet attrs) { - super(context, attrs); - this.context = context; - } - - public AttachmentView(Context context) { - super(context); - this.context = context; - } - - public void setButtonsEnabled(boolean enabled) { - viewButton.setEnabled(enabled); - downloadButton.setEnabled(enabled); - } - - /** - * Populates this view with information about the attachment. - *

- * This method also decides which attachments are displayed when the "show attachments" button - * is pressed, and which attachments are only displayed after the "show more attachments" - * button was pressed.
- * Inline attachments with content ID and unnamed attachments fall into the second category. - *

- * - * @return {@code true} for a regular attachment. {@code false} for attachments that should be initially hidden. - */ - public boolean populateFromPart(Part inputPart, Message message, Account account, - MessagingController controller, MessagingListener listener) throws MessagingException { - - part = (LocalAttachmentBodyPart) inputPart; - this.message = message; - this.account = account; - this.controller = controller; - this.listener = listener; - - boolean firstClassAttachment = extractAttachmentInformation(part); - - displayAttachmentInformation(); - - return firstClassAttachment; - } - - //TODO: extract this code to a helper class - private boolean extractAttachmentInformation(Part part) throws MessagingException { - boolean firstClassAttachment = true; - - contentType = part.getMimeType(); - String contentTypeHeader = MimeUtility.unfoldAndDecode(part.getContentType()); - String contentDisposition = MimeUtility.unfoldAndDecode(part.getDisposition()); - - name = MimeUtility.getHeaderParameter(contentTypeHeader, "name"); - if (name == null) { - name = MimeUtility.getHeaderParameter(contentDisposition, "filename"); - } - - if (name == null) { - firstClassAttachment = false; - String extension = MimeUtility.getExtensionByMimeType(contentType); - name = "noname" + ((extension != null) ? "." + extension : ""); - } - - // Inline parts with a content-id are almost certainly components of an HTML message - // not attachments. Only show them if the user pressed the button to show more - // attachments. - if (contentDisposition != null && - MimeUtility.getHeaderParameter(contentDisposition, null).matches("^(?i:inline)") - && part.getHeader(MimeHeader.HEADER_CONTENT_ID) != null) { - firstClassAttachment = false; - } - - String sizeParam = MimeUtility.getHeaderParameter(contentDisposition, "size"); - if (sizeParam != null) { - try { - size = Integer.parseInt(sizeParam); - } catch (NumberFormatException e) { /* ignore */ } - } - - return firstClassAttachment; - } - - private void displayAttachmentInformation() { - TextView attachmentName = (TextView) findViewById(R.id.attachment_name); - TextView attachmentInfo = (TextView) findViewById(R.id.attachment_info); - viewButton = (Button) findViewById(R.id.view); - downloadButton = (Button) findViewById(R.id.download); - - if (size > K9.MAX_ATTACHMENT_DOWNLOAD_SIZE) { - viewButton.setVisibility(View.GONE); - downloadButton.setVisibility(View.GONE); - } - - viewButton.setOnClickListener(this); - downloadButton.setOnClickListener(this); - downloadButton.setOnLongClickListener(this); - - attachmentName.setText(name); - attachmentInfo.setText(SizeFormatter.formatSize(context, size)); - - ImageView thumbnail = (ImageView) findViewById(R.id.attachment_icon); - new LoadAndDisplayThumbnailAsyncTask(thumbnail).execute(); - } - - @Override - public void onClick(View view) { - switch (view.getId()) { - case R.id.view: { - onViewButtonClicked(); - break; - } - case R.id.download: { - onSaveButtonClicked(); - break; - } - } - } - - @Override - public boolean onLongClick(View view) { - if (view.getId() == R.id.download) { - callback.pickDirectoryToSaveAttachmentTo(this); - return true; - } - - return false; - } - - private void onViewButtonClicked() { - if (message != null) { - controller.loadAttachment(account, message, part, new Object[] {false, this}, listener); - } - } - - private void onSaveButtonClicked() { - boolean isExternalStorageMounted = Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED); - if (!isExternalStorageMounted) { - String message = context.getString(R.string.message_view_status_attachment_not_saved); - displayMessageToUser(message); - return; - } - - if (message != null) { - controller.loadAttachment(account, message, part, new Object[] {true, this}, listener); - } - } - - public void writeFile() { - writeFile(new File(K9.getAttachmentDefaultPath())); - } - - /** - * Saves the attachment as file in the given directory - */ - public void writeFile(File directory) { - try { - File file = saveAttachmentWithUniqueFileName(directory); - - displayAttachmentSavedMessage(file.toString()); - - MediaScannerNotifier.notify(context, file); - } catch (IOException ioe) { - if (K9.DEBUG) { - Log.e(K9.LOG_TAG, "Error saving attachment", ioe); - } - displayAttachmentNotSavedMessage(); - } - } - - private File saveAttachmentWithUniqueFileName(File directory) throws IOException { - String filename = FileHelper.sanitizeFilename(name); - File file = FileHelper.createUniqueFile(directory, filename); - - writeAttachmentToStorage(file); - - return file; - } - - private void writeAttachmentToStorage(File file) throws IOException { - Uri uri = AttachmentProvider.getAttachmentUri(account, part.getAttachmentId()); - InputStream in = context.getContentResolver().openInputStream(uri); - try { - OutputStream out = new FileOutputStream(file); - try { - IOUtils.copy(in, out); - out.flush(); - } finally { - out.close(); - } - } finally { - in.close(); - } - } - - public void showFile() { - new ViewAttachmentAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - private Intent getBestViewIntentAndSaveFileIfNecessary() { - String inferredMimeType = MimeUtility.getMimeTypeByExtension(name); - - IntentAndResolvedActivitiesCount resolvedIntentInfo; - if (MimeUtility.isDefaultMimeType(contentType)) { - resolvedIntentInfo = getBestViewIntentForMimeType(inferredMimeType); - } else { - resolvedIntentInfo = getBestViewIntentForMimeType(contentType); - if (!resolvedIntentInfo.hasResolvedActivities() && !inferredMimeType.equals(contentType)) { - resolvedIntentInfo = getBestViewIntentForMimeType(inferredMimeType); - } - } - - if (!resolvedIntentInfo.hasResolvedActivities()) { - resolvedIntentInfo = getBestViewIntentForMimeType(MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE); - } - - Intent viewIntent; - if (resolvedIntentInfo.hasResolvedActivities() && resolvedIntentInfo.containsFileUri()) { - try { - File tempFile = TemporaryAttachmentStore.getFileForWriting(context, name); - writeAttachmentToStorage(tempFile); - viewIntent = createViewIntentForFileUri(resolvedIntentInfo.getMimeType(), Uri.fromFile(tempFile)); - } catch (IOException e) { - if (K9.DEBUG) { - Log.e(K9.LOG_TAG, "Error while saving attachment to use file:// URI with ACTION_VIEW Intent", e); - } - viewIntent = createViewIntentForAttachmentProviderUri(MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE); - } - } else { - viewIntent = resolvedIntentInfo.getIntent(); - } - - return viewIntent; - } - - private IntentAndResolvedActivitiesCount getBestViewIntentForMimeType(String mimeType) { - Intent contentUriIntent = createViewIntentForAttachmentProviderUri(mimeType); - int contentUriActivitiesCount = getResolvedIntentActivitiesCount(contentUriIntent); - - if (contentUriActivitiesCount > 0) { - return new IntentAndResolvedActivitiesCount(contentUriIntent, contentUriActivitiesCount); - } - - File tempFile = TemporaryAttachmentStore.getFile(context, name); - Uri tempFileUri = Uri.fromFile(tempFile); - Intent fileUriIntent = createViewIntentForFileUri(mimeType, tempFileUri); - int fileUriActivitiesCount = getResolvedIntentActivitiesCount(fileUriIntent); - - if (fileUriActivitiesCount > 0) { - return new IntentAndResolvedActivitiesCount(fileUriIntent, fileUriActivitiesCount); - } - - return new IntentAndResolvedActivitiesCount(contentUriIntent, contentUriActivitiesCount); - } - - private Intent createViewIntentForAttachmentProviderUri(String mimeType) { - Uri uri = AttachmentProvider.getAttachmentUriForViewing(account, part.getAttachmentId(), mimeType, name); - - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(uri, mimeType); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - addUiIntentFlags(intent); - - return intent; - } - - private Intent createViewIntentForFileUri(String mimeType, Uri uri) { - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(uri, mimeType); - addUiIntentFlags(intent); - - return intent; - } - - private void addUiIntentFlags(Intent intent) { - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); - } - - private int getResolvedIntentActivitiesCount(Intent intent) { - PackageManager packageManager = context.getPackageManager(); - - List resolveInfos = - packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - - return resolveInfos.size(); - } - - private void displayAttachmentSavedMessage(final String filename) { - String message = context.getString(R.string.message_view_status_attachment_saved, filename); - displayMessageToUser(message); - } - - private void displayAttachmentNotSavedMessage() { - String message = context.getString(R.string.message_view_status_attachment_not_saved); - displayMessageToUser(message); - } - - private void displayMessageToUser(String message) { - Toast.makeText(context, message, Toast.LENGTH_LONG).show(); - } - - public void setCallback(AttachmentFileDownloadCallback callback) { - this.callback = callback; - } - - - public interface AttachmentFileDownloadCallback { - /** - * This method is called to ask the user to pick a directory to save the attachment to. - *

- * After the user has selected a directory, the implementation of this interface has to call - * {@link #writeFile(File)} on the object supplied as argument in order for the attachment to be saved. - */ - public void pickDirectoryToSaveAttachmentTo(AttachmentView caller); - } - - - private static class IntentAndResolvedActivitiesCount { - private Intent intent; - private int activitiesCount; - - IntentAndResolvedActivitiesCount(Intent intent, int activitiesCount) { - this.intent = intent; - this.activitiesCount = activitiesCount; - } - - public Intent getIntent() { - return intent; - } - - public boolean hasResolvedActivities() { - return activitiesCount > 0; - } - - public String getMimeType() { - return intent.getType(); - } - - public boolean containsFileUri() { - return "file".equals(intent.getData().getScheme()); - } - } - - private class LoadAndDisplayThumbnailAsyncTask extends AsyncTask { - private final ImageView thumbnail; - - public LoadAndDisplayThumbnailAsyncTask(ImageView thumbnail) { - this.thumbnail = thumbnail; - } - - protected Bitmap doInBackground(Void... asyncTaskArgs) { - return getPreviewIcon(); - } - - private Bitmap getPreviewIcon() { - Bitmap icon = null; - try { - InputStream input = context.getContentResolver().openInputStream( - AttachmentProvider.getAttachmentThumbnailUri(account, - part.getAttachmentId(), - 62, - 62)); - icon = BitmapFactory.decodeStream(input); - input.close(); - } catch (Exception e) { - // We don't care what happened, we just return null for the preview icon. - } - - return icon; - } - - protected void onPostExecute(Bitmap previewIcon) { - if (previewIcon != null) { - thumbnail.setImageBitmap(previewIcon); - } else { - thumbnail.setImageResource(R.drawable.attached_image_placeholder); - } - } - } - - private class ViewAttachmentAsyncTask extends AsyncTask { - - @Override - protected void onPreExecute() { - viewButton.setEnabled(false); - } - - @Override - protected Intent doInBackground(Void... params) { - return getBestViewIntentAndSaveFileIfNecessary(); - } - - @Override - protected void onPostExecute(Intent intent) { - viewAttachment(intent); - viewButton.setEnabled(true); - } - - private void viewAttachment(Intent intent) { - try { - context.startActivity(intent); - } catch (ActivityNotFoundException e) { - Log.e(K9.LOG_TAG, "Could not display attachment of type " + contentType, e); - - String message = context.getString(R.string.message_view_no_viewer, contentType); - displayMessageToUser(message); - } - } - } -} diff --git a/k9mail/src/main/java/com/fsck/k9/view/K9WebViewClient.java b/k9mail/src/main/java/com/fsck/k9/view/K9WebViewClient.java new file mode 100644 index 000000000..a52c64f5d --- /dev/null +++ b/k9mail/src/main/java/com/fsck/k9/view/K9WebViewClient.java @@ -0,0 +1,125 @@ +package com.fsck.k9.view; + + +import java.io.InputStream; +import java.util.Stack; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import com.fsck.k9.K9; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.Multipart; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mailstore.AttachmentViewInfo; +import com.fsck.k9.mailstore.LocalMessageExtractor; + + +/** + * {@link WebViewClient} that intercepts requests for {@code cid:} URIs to load the respective body part. + */ +public abstract class K9WebViewClient extends WebViewClient { + private static final String CID_SCHEME = "cid"; + private static final WebResourceResponse RESULT_DO_NOT_INTERCEPT = null; + private static final WebResourceResponse RESULT_DUMMY_RESPONSE = new WebResourceResponse(null, null, null); + + public static WebViewClient newInstance(Part part) { + if (Build.VERSION.SDK_INT < 21) { + return new PreLollipopWebViewClient(part); + } + + return new LollipopWebViewClient(part); + } + + + private final Part part; + + private K9WebViewClient(Part part) { + this.part = part; + } + + protected WebResourceResponse shouldInterceptRequest(WebView webView, Uri uri) { + if (!CID_SCHEME.equals(uri.getScheme())) { + return RESULT_DO_NOT_INTERCEPT; + } + + String cid = uri.getSchemeSpecificPart(); + if (TextUtils.isEmpty(cid)) { + return RESULT_DUMMY_RESPONSE; + } + + Part part = getPartForContentId(cid); + if (part == null) { + return RESULT_DUMMY_RESPONSE; + } + + Context context = webView.getContext(); + ContentResolver contentResolver = context.getContentResolver(); + try { + AttachmentViewInfo attachmentInfo = LocalMessageExtractor.extractAttachmentInfo(context, part); + String mimeType = attachmentInfo.mimeType; + InputStream inputStream = contentResolver.openInputStream(attachmentInfo.uri); + + return new WebResourceResponse(mimeType, null, inputStream); + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Error while intercepting URI: " + uri, e); + return RESULT_DUMMY_RESPONSE; + } + } + + private Part getPartForContentId(String cid) { + Stack partsToCheck = new Stack(); + partsToCheck.push(part); + + while (!partsToCheck.isEmpty()) { + Part part = partsToCheck.pop(); + + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + for (Part bodyPart : multipart.getBodyParts()) { + partsToCheck.push(bodyPart); + } + } else if (cid.equals(part.getContentId())) { + return part; + } + } + + return null; + } + + + private static class PreLollipopWebViewClient extends K9WebViewClient { + protected PreLollipopWebViewClient(Part part) { + super(part); + } + + @SuppressWarnings("deprecation") + @Override + public WebResourceResponse shouldInterceptRequest(WebView webView, String url) { + return shouldInterceptRequest(webView, Uri.parse(url)); + } + } + + @TargetApi(VERSION_CODES.LOLLIPOP) + private static class LollipopWebViewClient extends K9WebViewClient { + protected LollipopWebViewClient(Part part) { + super(part); + } + + @Override + public WebResourceResponse shouldInterceptRequest(WebView webView, WebResourceRequest request) { + return shouldInterceptRequest(webView, request.getUrl()); + } + } +} diff --git a/k9mail/src/main/java/com/fsck/k9/view/MessageHeader.java b/k9mail/src/main/java/com/fsck/k9/view/MessageHeader.java index 67c5e130d..a536676d7 100644 --- a/k9mail/src/main/java/com/fsck/k9/view/MessageHeader.java +++ b/k9mail/src/main/java/com/fsck/k9/view/MessageHeader.java @@ -153,8 +153,6 @@ public class MessageHeader extends LinearLayout implements OnClickListener { } public void setOnFlagListener(OnClickListener listener) { - if (mFlagged == null) - return; mFlagged.setOnClickListener(listener); } diff --git a/k9mail/src/main/java/com/fsck/k9/view/MessageOpenPgpView.java b/k9mail/src/main/java/com/fsck/k9/view/MessageOpenPgpView.java deleted file mode 100644 index 9e09e5394..000000000 --- a/k9mail/src/main/java/com/fsck/k9/view/MessageOpenPgpView.java +++ /dev/null @@ -1,424 +0,0 @@ - -package com.fsck.k9.view; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.io.UnsupportedEncodingException; -import java.nio.charset.Charset; - -import android.app.Activity; -import android.app.Fragment; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.IntentSender.SendIntentException; -import android.util.AttributeSet; -import android.util.Log; -import android.view.View; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.ProgressBar; -import android.widget.RelativeLayout; -import android.widget.TextView; -import android.widget.Toast; - -import com.fsck.k9.Account; -import com.fsck.k9.Identity; -import com.fsck.k9.K9; -import com.fsck.k9.R; -import com.fsck.k9.crypto.CryptoHelper; -import com.fsck.k9.crypto.OpenPgpApiHelper; -import com.fsck.k9.fragment.MessageViewFragment; -import com.fsck.k9.helper.IdentityHelper; -import com.fsck.k9.mail.Message; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.Part; - -import com.fsck.k9.mail.internet.MessageExtractor; -import com.fsck.k9.mail.internet.MimeUtility; -import org.openintents.openpgp.OpenPgpError; -import org.openintents.openpgp.OpenPgpSignatureResult; -import org.openintents.openpgp.util.OpenPgpApi; -import org.openintents.openpgp.util.OpenPgpServiceConnection; - -public class MessageOpenPgpView extends LinearLayout { - - private Context mContext; - private MessageViewFragment mFragment; - private RelativeLayout mSignatureLayout = null; - private ImageView mSignatureStatusImage = null; - private TextView mSignatureUserId = null; - private TextView mText = null; - private ProgressBar mProgress; - private Button mGetKeyButton; - - private OpenPgpServiceConnection mOpenPgpServiceConnection; - private OpenPgpApi mOpenPgpApi; - - private String mOpenPgpProvider; - private Message mMessage; - - private PendingIntent mMissingKeyPI; - - private static final int REQUEST_CODE_DECRYPT_VERIFY = 12; - - String mData; - Account mAccount; - - public MessageOpenPgpView(Context context, AttributeSet attrs) { - super(context, attrs); - mContext = context; - } - - public void setupChildViews() { - mSignatureLayout = (RelativeLayout) findViewById(R.id.openpgp_signature_layout); - mSignatureStatusImage = (ImageView) findViewById(R.id.openpgp_signature_status); - mSignatureUserId = (TextView) findViewById(R.id.openpgp_user_id); - mText = (TextView) findViewById(R.id.openpgp_text); - mProgress = (ProgressBar) findViewById(R.id.openpgp_progress); - mGetKeyButton = (Button) findViewById(R.id.openpgp_get_key); - - mGetKeyButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - getMissingKey(); - } - }); - } - - public void setFragment(Fragment fragment) { - mFragment = (MessageViewFragment) fragment; - } - - /** - * Fill the decrypt layout with signature data, if known, make controls - * visible, if they should be visible. - */ - public void updateLayout(Account account, String decryptedData, - final OpenPgpSignatureResult signatureResult, - final Message message) { - - // set class variables - mAccount = account; - mOpenPgpProvider = mAccount.getOpenPgpProvider(); - mMessage = message; - - // only use this view if a OpenPGP Provider is set - if (mOpenPgpProvider == null) { - return; - } - - Activity activity = mFragment.getActivity(); - if (activity == null) { - return; - } - // bind to service - mOpenPgpServiceConnection = new OpenPgpServiceConnection(activity, - mOpenPgpProvider); - mOpenPgpServiceConnection.bindToService(); - - if ((message == null) && (decryptedData == null)) { - this.setVisibility(View.GONE); - - // don't process further - return; - } - if (decryptedData != null && signatureResult == null) { - // encrypted-only - - MessageOpenPgpView.this.setBackgroundColor(mFragment.getResources().getColor( - R.color.openpgp_blue)); - mText.setText(R.string.openpgp_successful_decryption); - - // don't process further - return; - } else if (decryptedData != null && signatureResult != null) { - // signed-only and signed-and-encrypted - - switch (signatureResult.getStatus()) { - case OpenPgpSignatureResult.SIGNATURE_ERROR: - // TODO: signature error but decryption works? - mText.setText(R.string.openpgp_signature_invalid); - MessageOpenPgpView.this.setBackgroundColor(mFragment.getResources().getColor( - R.color.openpgp_red)); - - mGetKeyButton.setVisibility(View.GONE); - mSignatureStatusImage.setImageResource(R.drawable.overlay_error); - mSignatureLayout.setVisibility(View.GONE); - break; - - case OpenPgpSignatureResult.SIGNATURE_SUCCESS_CERTIFIED: - if (signatureResult.isSignatureOnly()) { - mText.setText(R.string.openpgp_signature_valid_certified); - } - else { - mText.setText(R.string.openpgp_successful_decryption_valid_signature_certified); - } - MessageOpenPgpView.this.setBackgroundColor(mFragment.getResources().getColor( - R.color.openpgp_green)); - - mGetKeyButton.setVisibility(View.GONE); - mSignatureUserId.setText(signatureResult.getPrimaryUserId()); - mSignatureStatusImage.setImageResource(R.drawable.overlay_ok); - mSignatureLayout.setVisibility(View.VISIBLE); - - break; - - case OpenPgpSignatureResult.SIGNATURE_KEY_MISSING: - if (signatureResult.isSignatureOnly()) { - mText.setText(R.string.openpgp_signature_unknown_text); - } - else { - mText.setText(R.string.openpgp_successful_decryption_unknown_signature); - } - MessageOpenPgpView.this.setBackgroundColor(mFragment.getResources().getColor( - R.color.openpgp_orange)); - - mGetKeyButton.setVisibility(View.VISIBLE); - mSignatureUserId.setText(R.string.openpgp_signature_unknown); - mSignatureStatusImage.setImageResource(R.drawable.overlay_error); - mSignatureLayout.setVisibility(View.VISIBLE); - - break; - - case OpenPgpSignatureResult.SIGNATURE_SUCCESS_UNCERTIFIED: - if (signatureResult.isSignatureOnly()) { - mText.setText(R.string.openpgp_signature_valid_uncertified); - } - else { - mText.setText(R.string.openpgp_successful_decryption_valid_signature_uncertified); - } - MessageOpenPgpView.this.setBackgroundColor(mFragment.getResources().getColor( - R.color.openpgp_orange)); - - mGetKeyButton.setVisibility(View.GONE); - mSignatureUserId.setText(signatureResult.getPrimaryUserId()); - mSignatureStatusImage.setImageResource(R.drawable.overlay_ok); - mSignatureLayout.setVisibility(View.VISIBLE); - - break; - - default: - break; - } - - // don't process further - return; - } - - // Start new decryption/verification - CryptoHelper helper = new CryptoHelper(); - if (helper.isEncrypted(message) || helper.isSigned(message)) { - // start automatic decrypt - decryptAndVerify(message); - } else { - try { - // check for PGP/MIME encryption - Part pgp = MimeUtility.findFirstPartByMimeType(message, "application/pgp-encrypted"); - if (pgp != null) { - Toast.makeText(mContext, R.string.pgp_mime_unsupported, Toast.LENGTH_LONG) - .show(); - } - } catch (MessagingException e) { - // nothing to do... - } - } - } - - private void decryptAndVerify(final Message message) { - this.setVisibility(View.VISIBLE); - mProgress.setVisibility(View.VISIBLE); - MessageOpenPgpView.this.setBackgroundColor(mFragment.getResources().getColor( - R.color.openpgp_orange)); - mText.setText(R.string.openpgp_decrypting_verifying); - - // waiting in a new thread - Runnable r = new Runnable() { - - @Override - public void run() { - try { - // get data String - Part part = MimeUtility.findFirstPartByMimeType(message, "text/plain"); - if (part == null) { - part = MimeUtility.findFirstPartByMimeType(message, "text/html"); - } - if (part != null) { - mData = MessageExtractor.getTextFromPart(part); - } - - // wait for service to be bound - while (!mOpenPgpServiceConnection.isBound()) { - try { - Thread.sleep(100); - } catch (InterruptedException e) { - } - } - - mOpenPgpApi = new OpenPgpApi(getContext(), - mOpenPgpServiceConnection.getService()); - - decryptVerify(new Intent()); - - } catch (MessagingException me) { - Log.e(K9.LOG_TAG, "Unable to decrypt email.", me); - } - - } - }; - - new Thread(r).start(); - } - - private void decryptVerify(Intent intent) { - intent.setAction(OpenPgpApi.ACTION_DECRYPT_VERIFY); - intent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true); - - Identity identity = IdentityHelper.getRecipientIdentityFromMessage(mAccount, mMessage); - String accName = OpenPgpApiHelper.buildAccountName(identity); - intent.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, accName); - - InputStream is = new ByteArrayInputStream(mData.getBytes(Charset.forName("UTF-8"))); - final ByteArrayOutputStream os = new ByteArrayOutputStream(); - - DecryptVerifyCallback callback = new DecryptVerifyCallback(os, REQUEST_CODE_DECRYPT_VERIFY); - - mOpenPgpApi.executeApiAsync(intent, is, os, callback); - } - - private void getMissingKey() { - try { - mFragment.getActivity().startIntentSenderForResult( - mMissingKeyPI.getIntentSender(), - REQUEST_CODE_DECRYPT_VERIFY, null, 0, 0, 0); - } catch (SendIntentException e) { - Log.e(K9.LOG_TAG, "SendIntentException", e); - } - } - - /** - * Called on successful decrypt/verification - */ - private class DecryptVerifyCallback implements OpenPgpApi.IOpenPgpCallback { - ByteArrayOutputStream os; - int requestCode; - - private DecryptVerifyCallback(ByteArrayOutputStream os, int requestCode) { - this.os = os; - this.requestCode = requestCode; - } - - @Override - public void onReturn(Intent result) { - switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - case OpenPgpApi.RESULT_CODE_SUCCESS: { - try { - final String output = os.toString("UTF-8"); - - OpenPgpSignatureResult sigResult = null; - if (result.hasExtra(OpenPgpApi.RESULT_SIGNATURE)) { - sigResult = result.getParcelableExtra(OpenPgpApi.RESULT_SIGNATURE); - } - - if (K9.DEBUG) - Log.d(K9.LOG_TAG, "result: " + os.toByteArray().length - + " str=" + output); - - // missing key -> PendingIntent to get keys - mMissingKeyPI = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT); - - mProgress.setVisibility(View.GONE); - mFragment.setMessageWithOpenPgp(output, sigResult); - } catch (UnsupportedEncodingException e) { - Log.e(K9.LOG_TAG, "UnsupportedEncodingException", e); - } - break; - } - case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: { - PendingIntent pi = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT); - try { - mFragment.getActivity().startIntentSenderForResult( - pi.getIntentSender(), - requestCode, null, 0, 0, 0); - } catch (SendIntentException e) { - Log.e(K9.LOG_TAG, "SendIntentException", e); - } - break; - } - case OpenPgpApi.RESULT_CODE_ERROR: { - OpenPgpError error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR); - handleError(error); - break; - } - } - } - } - - public boolean handleOnActivityResult(int requestCode, int resultCode, Intent data) { - if (K9.DEBUG) - Log.d(K9.LOG_TAG, "onActivityResult resultCode: " + resultCode); - - // try again after user interaction - if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_CODE_DECRYPT_VERIFY) { - /* - * The data originally given to the decryptVerify() method, is again - * returned here to be used when calling decryptVerify() after user - * interaction. The Intent now also contains results from the user - * interaction, for example selected key ids. - */ - decryptVerify(data); - - return true; - } - - return false; - } - - private void handleError(final OpenPgpError error) { - Activity activity = mFragment.getActivity(); - if (activity == null) { - return; - } - activity.runOnUiThread(new Runnable() { - - @Override - public void run() { - mProgress.setVisibility(View.GONE); - - if (K9.DEBUG) { - Log.d(K9.LOG_TAG, "OpenPGP Error ID:" + error.getErrorId()); - Log.d(K9.LOG_TAG, "OpenPGP Error Message:" + error.getMessage()); - } - - mText.setText(mFragment.getString(R.string.openpgp_error) + " " - + error.getMessage()); - MessageOpenPgpView.this.setBackgroundColor(mFragment.getResources().getColor( - R.color.openpgp_red)); - } - }); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - - // bind to service if a OpenPGP Provider is available - if (mOpenPgpProvider != null) { - mOpenPgpServiceConnection = new OpenPgpServiceConnection(mFragment.getActivity(), - mOpenPgpProvider); - mOpenPgpServiceConnection.bindToService(); - } - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - - if (mOpenPgpServiceConnection != null) { - mOpenPgpServiceConnection.unbindFromService(); - } - } - -} diff --git a/k9mail/src/main/java/com/fsck/k9/view/MessageWebView.java b/k9mail/src/main/java/com/fsck/k9/view/MessageWebView.java index 0f64194e2..fdf6ff6d2 100644 --- a/k9mail/src/main/java/com/fsck/k9/view/MessageWebView.java +++ b/k9mail/src/main/java/com/fsck/k9/view/MessageWebView.java @@ -16,7 +16,6 @@ import com.fsck.k9.helper.HtmlSanitizer; public class MessageWebView extends RigidWebView { - public MessageWebView(Context context) { super(context); } diff --git a/k9mail/src/main/res/drawable-hdpi/status_lock_closed.png b/k9mail/src/main/res/drawable-hdpi/status_lock_closed.png new file mode 100644 index 000000000..a1b090630 Binary files /dev/null and b/k9mail/src/main/res/drawable-hdpi/status_lock_closed.png differ diff --git a/k9mail/src/main/res/drawable-hdpi/status_lock_error.png b/k9mail/src/main/res/drawable-hdpi/status_lock_error.png new file mode 100644 index 000000000..e567055aa Binary files /dev/null and b/k9mail/src/main/res/drawable-hdpi/status_lock_error.png differ diff --git a/k9mail/src/main/res/drawable-hdpi/status_lock_open.png b/k9mail/src/main/res/drawable-hdpi/status_lock_open.png new file mode 100644 index 000000000..98e32eadc Binary files /dev/null and b/k9mail/src/main/res/drawable-hdpi/status_lock_open.png differ diff --git a/k9mail/src/main/res/drawable-hdpi/status_signature_expired_cutout.png b/k9mail/src/main/res/drawable-hdpi/status_signature_expired_cutout.png new file mode 100644 index 000000000..84ac9bec2 Binary files /dev/null and b/k9mail/src/main/res/drawable-hdpi/status_signature_expired_cutout.png differ diff --git a/k9mail/src/main/res/drawable-hdpi/status_signature_invalid_cutout.png b/k9mail/src/main/res/drawable-hdpi/status_signature_invalid_cutout.png new file mode 100644 index 000000000..967e00e80 Binary files /dev/null and b/k9mail/src/main/res/drawable-hdpi/status_signature_invalid_cutout.png differ diff --git a/k9mail/src/main/res/drawable-hdpi/status_signature_revoked_cutout.png b/k9mail/src/main/res/drawable-hdpi/status_signature_revoked_cutout.png new file mode 100644 index 000000000..244dd0708 Binary files /dev/null and b/k9mail/src/main/res/drawable-hdpi/status_signature_revoked_cutout.png differ diff --git a/k9mail/src/main/res/drawable-hdpi/status_signature_unknown_cutout.png b/k9mail/src/main/res/drawable-hdpi/status_signature_unknown_cutout.png new file mode 100644 index 000000000..82cc25a4b Binary files /dev/null and b/k9mail/src/main/res/drawable-hdpi/status_signature_unknown_cutout.png differ diff --git a/k9mail/src/main/res/drawable-hdpi/status_signature_unverified_cutout.png b/k9mail/src/main/res/drawable-hdpi/status_signature_unverified_cutout.png new file mode 100644 index 000000000..e752eaeab Binary files /dev/null and b/k9mail/src/main/res/drawable-hdpi/status_signature_unverified_cutout.png differ diff --git a/k9mail/src/main/res/drawable-hdpi/status_signature_verified_cutout.png b/k9mail/src/main/res/drawable-hdpi/status_signature_verified_cutout.png new file mode 100644 index 000000000..08a9f464c Binary files /dev/null and b/k9mail/src/main/res/drawable-hdpi/status_signature_verified_cutout.png differ diff --git a/k9mail/src/main/res/drawable-mdpi/status_lock_closed.png b/k9mail/src/main/res/drawable-mdpi/status_lock_closed.png new file mode 100644 index 000000000..cfc39f0e7 Binary files /dev/null and b/k9mail/src/main/res/drawable-mdpi/status_lock_closed.png differ diff --git a/k9mail/src/main/res/drawable-mdpi/status_lock_error.png b/k9mail/src/main/res/drawable-mdpi/status_lock_error.png new file mode 100644 index 000000000..824dc2672 Binary files /dev/null and b/k9mail/src/main/res/drawable-mdpi/status_lock_error.png differ diff --git a/k9mail/src/main/res/drawable-mdpi/status_lock_open.png b/k9mail/src/main/res/drawable-mdpi/status_lock_open.png new file mode 100644 index 000000000..9bca59ae3 Binary files /dev/null and b/k9mail/src/main/res/drawable-mdpi/status_lock_open.png differ diff --git a/k9mail/src/main/res/drawable-mdpi/status_signature_expired_cutout.png b/k9mail/src/main/res/drawable-mdpi/status_signature_expired_cutout.png new file mode 100644 index 000000000..bc91094b5 Binary files /dev/null and b/k9mail/src/main/res/drawable-mdpi/status_signature_expired_cutout.png differ diff --git a/k9mail/src/main/res/drawable-mdpi/status_signature_invalid_cutout.png b/k9mail/src/main/res/drawable-mdpi/status_signature_invalid_cutout.png new file mode 100644 index 000000000..bc2f56e2a Binary files /dev/null and b/k9mail/src/main/res/drawable-mdpi/status_signature_invalid_cutout.png differ diff --git a/k9mail/src/main/res/drawable-mdpi/status_signature_revoked_cutout.png b/k9mail/src/main/res/drawable-mdpi/status_signature_revoked_cutout.png new file mode 100644 index 000000000..2d2593194 Binary files /dev/null and b/k9mail/src/main/res/drawable-mdpi/status_signature_revoked_cutout.png differ diff --git a/k9mail/src/main/res/drawable-mdpi/status_signature_unknown_cutout.png b/k9mail/src/main/res/drawable-mdpi/status_signature_unknown_cutout.png new file mode 100644 index 000000000..0fc74d07e Binary files /dev/null and b/k9mail/src/main/res/drawable-mdpi/status_signature_unknown_cutout.png differ diff --git a/k9mail/src/main/res/drawable-mdpi/status_signature_unverified_cutout.png b/k9mail/src/main/res/drawable-mdpi/status_signature_unverified_cutout.png new file mode 100644 index 000000000..96a2d1413 Binary files /dev/null and b/k9mail/src/main/res/drawable-mdpi/status_signature_unverified_cutout.png differ diff --git a/k9mail/src/main/res/drawable-mdpi/status_signature_verified_cutout.png b/k9mail/src/main/res/drawable-mdpi/status_signature_verified_cutout.png new file mode 100644 index 000000000..9f7cf837c Binary files /dev/null and b/k9mail/src/main/res/drawable-mdpi/status_signature_verified_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xhdpi/status_lock_closed.png b/k9mail/src/main/res/drawable-xhdpi/status_lock_closed.png new file mode 100644 index 000000000..7c6bb2d18 Binary files /dev/null and b/k9mail/src/main/res/drawable-xhdpi/status_lock_closed.png differ diff --git a/k9mail/src/main/res/drawable-xhdpi/status_lock_error.png b/k9mail/src/main/res/drawable-xhdpi/status_lock_error.png new file mode 100644 index 000000000..da4a5d89a Binary files /dev/null and b/k9mail/src/main/res/drawable-xhdpi/status_lock_error.png differ diff --git a/k9mail/src/main/res/drawable-xhdpi/status_lock_open.png b/k9mail/src/main/res/drawable-xhdpi/status_lock_open.png new file mode 100644 index 000000000..cd02fc1e4 Binary files /dev/null and b/k9mail/src/main/res/drawable-xhdpi/status_lock_open.png differ diff --git a/k9mail/src/main/res/drawable-xhdpi/status_signature_expired_cutout.png b/k9mail/src/main/res/drawable-xhdpi/status_signature_expired_cutout.png new file mode 100644 index 000000000..83f6fde35 Binary files /dev/null and b/k9mail/src/main/res/drawable-xhdpi/status_signature_expired_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xhdpi/status_signature_invalid_cutout.png b/k9mail/src/main/res/drawable-xhdpi/status_signature_invalid_cutout.png new file mode 100644 index 000000000..29830f5ba Binary files /dev/null and b/k9mail/src/main/res/drawable-xhdpi/status_signature_invalid_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xhdpi/status_signature_revoked_cutout.png b/k9mail/src/main/res/drawable-xhdpi/status_signature_revoked_cutout.png new file mode 100644 index 000000000..2f7695043 Binary files /dev/null and b/k9mail/src/main/res/drawable-xhdpi/status_signature_revoked_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xhdpi/status_signature_unknown_cutout.png b/k9mail/src/main/res/drawable-xhdpi/status_signature_unknown_cutout.png new file mode 100644 index 000000000..2ce28c7ca Binary files /dev/null and b/k9mail/src/main/res/drawable-xhdpi/status_signature_unknown_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xhdpi/status_signature_unverified_cutout.png b/k9mail/src/main/res/drawable-xhdpi/status_signature_unverified_cutout.png new file mode 100644 index 000000000..442c55eee Binary files /dev/null and b/k9mail/src/main/res/drawable-xhdpi/status_signature_unverified_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xhdpi/status_signature_verified_cutout.png b/k9mail/src/main/res/drawable-xhdpi/status_signature_verified_cutout.png new file mode 100644 index 000000000..160ec7cbe Binary files /dev/null and b/k9mail/src/main/res/drawable-xhdpi/status_signature_verified_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xxhdpi/status_lock_closed.png b/k9mail/src/main/res/drawable-xxhdpi/status_lock_closed.png new file mode 100644 index 000000000..5a9664d59 Binary files /dev/null and b/k9mail/src/main/res/drawable-xxhdpi/status_lock_closed.png differ diff --git a/k9mail/src/main/res/drawable-xxhdpi/status_lock_error.png b/k9mail/src/main/res/drawable-xxhdpi/status_lock_error.png new file mode 100644 index 000000000..608f065af Binary files /dev/null and b/k9mail/src/main/res/drawable-xxhdpi/status_lock_error.png differ diff --git a/k9mail/src/main/res/drawable-xxhdpi/status_lock_open.png b/k9mail/src/main/res/drawable-xxhdpi/status_lock_open.png new file mode 100644 index 000000000..ee34dd396 Binary files /dev/null and b/k9mail/src/main/res/drawable-xxhdpi/status_lock_open.png differ diff --git a/k9mail/src/main/res/drawable-xxhdpi/status_signature_expired_cutout.png b/k9mail/src/main/res/drawable-xxhdpi/status_signature_expired_cutout.png new file mode 100644 index 000000000..33a3efed1 Binary files /dev/null and b/k9mail/src/main/res/drawable-xxhdpi/status_signature_expired_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xxhdpi/status_signature_invalid_cutout.png b/k9mail/src/main/res/drawable-xxhdpi/status_signature_invalid_cutout.png new file mode 100644 index 000000000..bc39d3496 Binary files /dev/null and b/k9mail/src/main/res/drawable-xxhdpi/status_signature_invalid_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xxhdpi/status_signature_revoked_cutout.png b/k9mail/src/main/res/drawable-xxhdpi/status_signature_revoked_cutout.png new file mode 100644 index 000000000..58929661f Binary files /dev/null and b/k9mail/src/main/res/drawable-xxhdpi/status_signature_revoked_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xxhdpi/status_signature_unknown_cutout.png b/k9mail/src/main/res/drawable-xxhdpi/status_signature_unknown_cutout.png new file mode 100644 index 000000000..3020357a4 Binary files /dev/null and b/k9mail/src/main/res/drawable-xxhdpi/status_signature_unknown_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xxhdpi/status_signature_unverified_cutout.png b/k9mail/src/main/res/drawable-xxhdpi/status_signature_unverified_cutout.png new file mode 100644 index 000000000..3829bb3a0 Binary files /dev/null and b/k9mail/src/main/res/drawable-xxhdpi/status_signature_unverified_cutout.png differ diff --git a/k9mail/src/main/res/drawable-xxhdpi/status_signature_verified_cutout.png b/k9mail/src/main/res/drawable-xxhdpi/status_signature_verified_cutout.png new file mode 100644 index 000000000..3548ee2b6 Binary files /dev/null and b/k9mail/src/main/res/drawable-xxhdpi/status_signature_verified_cutout.png differ diff --git a/k9mail/src/main/res/layout/message.xml b/k9mail/src/main/res/layout/message.xml index a054bc2bd..13c3f0420 100644 --- a/k9mail/src/main/res/layout/message.xml +++ b/k9mail/src/main/res/layout/message.xml @@ -1,11 +1,13 @@ - + android:layout_weight="1" + android:background="?attr/messageViewBackgroundColor"> - - - - - - - - - - -