diff --git a/res/values-cs/strings.xml b/res/values-cs/strings.xml index ce0ce9cc6..f38314c38 100644 --- a/res/values-cs/strings.xml +++ b/res/values-cs/strings.xml @@ -84,6 +84,10 @@ "Zpráva byla smazána." "Zpráva byla zrušena." "Zpráva byla uložena jako koncept." + + + + "Nastavit e-mail" "Zadejte e-mailovou adresu účtu:" "E-mailová adresa" diff --git a/res/values-de/strings.xml b/res/values-de/strings.xml index 9fe47dbdc..28bb5d218 100644 --- a/res/values-de/strings.xml +++ b/res/values-de/strings.xml @@ -84,6 +84,10 @@ "Nachricht gelöscht" "Nachricht gelöscht" "Nachricht als Entwurf gespeichert" + + + + "E-Mail einrichten" "Geben Sie Ihre im Konto gespeicherte E-Mail-Adresse ein:" "E-Mail-Adresse" diff --git a/res/values-es/strings.xml b/res/values-es/strings.xml index 2c2c2faab..71a7fd0d7 100644 --- a/res/values-es/strings.xml +++ b/res/values-es/strings.xml @@ -84,6 +84,10 @@ "Mensaje suprimido" "Mensaje descartado" "Mensaje guardado como borrador" + + + + "Configurar correo electrónico" "Introduce la dirección de correo electrónico de tu cuenta:" "Dirección de correo electrónico" diff --git a/res/values-fr/strings.xml b/res/values-fr/strings.xml index 14d79dc8d..f72f185c0 100644 --- a/res/values-fr/strings.xml +++ b/res/values-fr/strings.xml @@ -84,6 +84,10 @@ "Message supprimé." "Message supprimé." "Message enregistré comme brouillon." + + + + "Configurer la messagerie électronique" "Saisissez l\'adresse e-mail de votre compte :" "Adresse e-mail" diff --git a/res/values-it/strings.xml b/res/values-it/strings.xml index f83274890..112fad49d 100644 --- a/res/values-it/strings.xml +++ b/res/values-it/strings.xml @@ -84,6 +84,10 @@ "Messaggio eliminato." "Messaggio eliminato." "Messaggio salvato come bozza." + + + + "Imposta email" "Digita l\'indirizzo email del tuo account:" "Indirizzo email" diff --git a/res/values-ja/strings.xml b/res/values-ja/strings.xml index c0ab1aa72..1c7c95840 100644 --- a/res/values-ja/strings.xml +++ b/res/values-ja/strings.xml @@ -84,6 +84,10 @@ "メッセージを削除しました。" "メッセージを破棄しました。" "メッセージを下書きとして保存しました。" + + + + "メールアカウントの登録" "メールのアカウント情報を入力:" "メールアドレス" diff --git a/res/values-ko/strings.xml b/res/values-ko/strings.xml index 9bad079a9..85511805b 100644 --- a/res/values-ko/strings.xml +++ b/res/values-ko/strings.xml @@ -84,6 +84,10 @@ "메일이 삭제되었습니다." "메일이 삭제되었습니다." "메일을 임시로 저장했습니다." + + + + "이메일 설정" "계정 이메일 주소 입력:" "이메일 주소" diff --git a/res/values-nb/strings.xml b/res/values-nb/strings.xml index ba3e7bd86..7e6b025c2 100644 --- a/res/values-nb/strings.xml +++ b/res/values-nb/strings.xml @@ -84,6 +84,10 @@ "Meldingen ble slettet." "Meldingen ble forkastet." "Meldingen ble lagret som utkast." + + + + "Sett opp e-post" "Skriv e-postadressen til kontoen din:" "E-postadresse" diff --git a/res/values-nl/strings.xml b/res/values-nl/strings.xml index e76d039fe..e71de77a3 100644 --- a/res/values-nl/strings.xml +++ b/res/values-nl/strings.xml @@ -84,6 +84,10 @@ "Bericht verwijderd." "Bericht wordt verwijderd" "Bericht opgeslagen als concept." + + + + "E-mail instellen" "Typ het e-mailadres van je account:" "E-mailadres" diff --git a/res/values-pl/strings.xml b/res/values-pl/strings.xml index dfac04fc0..ab4f036cc 100644 --- a/res/values-pl/strings.xml +++ b/res/values-pl/strings.xml @@ -84,6 +84,10 @@ "Wiadomość została usunięta." "Wiadomość została odrzucona." "Wiadomość została zapisana jako wersja robocza." + + + + "Skonfiguruj konto e-mail" "Podaj adres e-mail swojego konta:" "Adres e-mail" diff --git a/res/values-ru/strings.xml b/res/values-ru/strings.xml index f92669cbd..805e85d07 100644 --- a/res/values-ru/strings.xml +++ b/res/values-ru/strings.xml @@ -84,6 +84,10 @@ "Письмо удалено." "Письмо не сохранено." "Письмо сохранено как черновик." + + + + "Настройка электронной почты" "Укажите почтовый адрес своего аккаунта:" "Адрес электронной почты" diff --git a/res/values-zh-rCN/strings.xml b/res/values-zh-rCN/strings.xml index 8b413a0c0..2e0614814 100644 --- a/res/values-zh-rCN/strings.xml +++ b/res/values-zh-rCN/strings.xml @@ -84,6 +84,10 @@ "邮件已删除。" "邮件已取消。" "邮件已另存为草稿。" + + + + "设置电子邮件" "键入您的帐户电子邮件地址:" "电子邮件地址" diff --git a/res/values-zh-rTW/strings.xml b/res/values-zh-rTW/strings.xml index 30abecd74..d2345e1d9 100644 --- a/res/values-zh-rTW/strings.xml +++ b/res/values-zh-rTW/strings.xml @@ -84,6 +84,10 @@ "已刪除郵件。" "已捨棄郵件。" "已儲存郵件草稿。" + + + + "設定電子郵件" "請輸入您帳戶的電子郵件地址:" "電子郵件地址" diff --git a/src/com/android/email/activity/MessageView.java b/src/com/android/email/activity/MessageView.java index 0aab39cb5..22f248784 100644 --- a/src/com/android/email/activity/MessageView.java +++ b/src/com/android/email/activity/MessageView.java @@ -75,9 +75,8 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.Date; -import java.util.Map; -import java.util.Random; import java.util.regex.Matcher; +import java.util.regex.Pattern; public class MessageView extends Activity implements OnClickListener { @@ -93,6 +92,9 @@ public class MessageView extends Activity }; private static final int METHODS_STATUS_COLUMN = 1; + // regex that matches start of img tag. '.*<(?i)img\s+.*'. + private static final Pattern IMG_TAG_START_REGEX = Pattern.compile(".*<(?i)img\\s+.*"); + private TextView mSubjectView; private TextView mFromView; private TextView mDateView; @@ -451,6 +453,10 @@ public class MessageView extends Activity Intent contactIntent = new Intent(Contacts.Intents.SHOW_OR_CREATE_CONTACT); contactIntent.setData(contactUri); + // Pass along full E-mail string for possible create dialog + contactIntent.putExtra(Contacts.Intents.EXTRA_CREATE_DESCRIPTION, + senderEmail.toString()); + // Only provide personal name hint if we have one String senderPersonal = senderEmail.getPersonal(); if (senderPersonal != null) { @@ -694,6 +700,50 @@ public class MessageView extends Activity } } + /** + * Resolve content-id reference in src attribute of img tag to AttachmentProvider's + * content uri. This method calls itself recursively at most the number of + * LocalAttachmentPart that mime type is image and has content id. + * The attribute src="cid:content_id" is resolved as src="content://...". + * This method is package scope for testing purpose. + * + * @param text html email text + * @param part mime part which may contain inline image + * @return html text in which src attribute of img tag may be replaced with content uri + */ + /* package */ String resolveInlineImage(String text, Part part, int depth) + throws MessagingException { + // avoid too deep recursive call. + if (depth >= 10) { + return text; + } + String contentType = MimeUtility.unfoldAndDecode(part.getContentType()); + String contentId = part.getContentId(); + if (contentType.startsWith("image/") && + contentId != null && + part instanceof LocalAttachmentBodyPart) { + LocalAttachmentBodyPart attachment = (LocalAttachmentBodyPart)part; + Uri contentUri = AttachmentProvider.getAttachmentUri( + mAccount, + attachment.getAttachmentId()); + if (contentUri != null) { + // Regexp which matches ' src="cid:contentId"'. + String contentIdRe = "\\s+(?i)src=\"cid(?-i):\\Q" + contentId + "\\E\""; + // Replace all occurrences of src attribute with ' src="content://contentUri"'. + text = text.replaceAll(contentIdRe, " src=\"" + contentUri + "\""); + } + } + + if (part.getBody() instanceof Multipart) { + Multipart mp = (Multipart)part.getBody(); + for (int i = 0; i < mp.getCount(); i++) { + text = resolveInlineImage(text, mp.getBodyPart(i), depth + 1); + } + } + + return text; + } + private void renderAttachments(Part part, int depth) throws MessagingException { String contentType = MimeUtility.unfoldAndDecode(part.getContentType()); String name = MimeUtility.getHeaderParameter(contentType, "name"); @@ -875,7 +925,7 @@ public class MessageView extends Activity if (part != null) { String text = MimeUtility.getTextFromPart(part); if (part.getMimeType().equalsIgnoreCase("text/html")) { - text = text.replaceAll("cid:", "http://cid/"); + text = resolveInlineImage(text, mMessage, 0); } else { /* * Linkify the plain text and convert it to HTML by replacing @@ -898,10 +948,11 @@ public class MessageView extends Activity } /* - * TODO this should be smarter, change to regex for img, but consider how to - * get backgroung images and a million other things that HTML allows. + * TODO consider how to get background images and a million other things + * that HTML allows. */ - if (text.contains("img")) { + // Check if text contains img tag. + if (IMG_TAG_START_REGEX.matcher(text).matches()) { mHandler.showShowPictures(true); } diff --git a/src/com/android/email/mail/Address.java b/src/com/android/email/mail/Address.java index e0851db2d..3ed3a8f80 100644 --- a/src/com/android/email/mail/Address.java +++ b/src/com/android/email/mail/Address.java @@ -73,10 +73,10 @@ public class Address { * @return An array of 0 or more Addresses. */ public static Address[] parse(String addressList) { - ArrayList
addresses = new ArrayList
(); - if (addressList == null) { + if (addressList == null || addressList.length() == 0) { return new Address[] {}; } + ArrayList
addresses = new ArrayList
(); try { MailboxList parsedList = AddressList.parse(addressList).flatten(); for (int i = 0, count = parsedList.size(); i < count; i++) { @@ -173,7 +173,7 @@ public class Address { * @return */ public static Address[] unpack(String addressList) { - if (addressList == null) { + if (addressList == null || addressList.length() == 0) { return new Address[] { }; } ArrayList
addresses = new ArrayList
(); @@ -205,7 +205,7 @@ public class Address { /** * Packs an address list into a String that is very quick to read * and parse. Packed lists can be unpacked with unpackAddressList() - * The packed list is a comma seperated list of: + * The packed list is a comma separated list of: * URLENCODE(address)[;URLENCODE(personal)] * @param list * @return @@ -213,6 +213,8 @@ public class Address { public static String pack(Address[] addresses) { if (addresses == null) { return null; + } else if (addresses.length == 0) { + return ""; } StringBuffer sb = new StringBuffer(); for (int i = 0, count = addresses.length; i < count; i++) { diff --git a/src/com/android/email/mail/Part.java b/src/com/android/email/mail/Part.java index 1e1fcf498..175812591 100644 --- a/src/com/android/email/mail/Part.java +++ b/src/com/android/email/mail/Part.java @@ -31,6 +31,8 @@ public interface Part { public String getContentType() throws MessagingException; public String getDisposition() throws MessagingException; + + public String getContentId() throws MessagingException; public String[] getHeader(String name) throws MessagingException; diff --git a/src/com/android/email/mail/internet/MimeBodyPart.java b/src/com/android/email/mail/internet/MimeBodyPart.java index f105a3110..49b729f5d 100644 --- a/src/com/android/email/mail/internet/MimeBodyPart.java +++ b/src/com/android/email/mail/internet/MimeBodyPart.java @@ -20,6 +20,7 @@ import java.io.BufferedWriter; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; +import java.util.regex.Pattern; import com.android.email.mail.Body; import com.android.email.mail.BodyPart; @@ -34,6 +35,9 @@ public class MimeBodyPart extends BodyPart { protected Body mBody; protected int mSize; + // regex that matches content id surrounded by "<>" optionally. + private static final Pattern REMOVE_OPTIONAL_BRACKETS = Pattern.compile("^]+)>?$"); + public MimeBodyPart() throws MessagingException { this(null); } @@ -109,6 +113,16 @@ public class MimeBodyPart extends BodyPart { } } + public String getContentId() throws MessagingException { + String contentId = getFirstHeader(MimeHeader.HEADER_CONTENT_ID); + if (contentId == null) { + return null; + } else { + // remove optionally surrounding brackets. + return REMOVE_OPTIONAL_BRACKETS.matcher(contentId).replaceAll("$1"); + } + } + public String getMimeType() throws MessagingException { return MimeUtility.getHeaderParameter(getContentType(), null); } diff --git a/src/com/android/email/mail/internet/MimeHeader.java b/src/com/android/email/mail/internet/MimeHeader.java index 5132934a2..8a53ece34 100644 --- a/src/com/android/email/mail/internet/MimeHeader.java +++ b/src/com/android/email/mail/internet/MimeHeader.java @@ -38,6 +38,7 @@ public class MimeHeader { 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() diff --git a/src/com/android/email/mail/internet/MimeMessage.java b/src/com/android/email/mail/internet/MimeMessage.java index 01ad59a88..27d7aadb4 100644 --- a/src/com/android/email/mail/internet/MimeMessage.java +++ b/src/com/android/email/mail/internet/MimeMessage.java @@ -25,6 +25,7 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; import java.util.Stack; +import java.util.regex.Pattern; import org.apache.james.mime4j.BodyDescriptor; import org.apache.james.mime4j.ContentHandler; @@ -38,6 +39,7 @@ import com.android.email.mail.Body; import com.android.email.mail.BodyPart; import com.android.email.mail.Message; import com.android.email.mail.MessagingException; +import com.android.email.mail.Multipart; import com.android.email.mail.Part; /** @@ -46,12 +48,17 @@ import com.android.email.mail.Part; */ public class MimeMessage extends Message { protected MimeHeader mHeader = new MimeHeader(); + + // NOTE: The fields here are transcribed out of headers, and values stored here will supercede + // the values found in the headers. Use caution to prevent any out-of-phase errors. In + // particular, any adds/changes/deletes here must be echoed by changes in the parse() function. protected Address[] mFrom; protected Address[] mTo; protected Address[] mCc; protected Address[] mBcc; protected Address[] mReplyTo; protected Date mSentDate; + // In MIME, en_US-like date format should be used. In other words "MMM" should be encoded to // "Jan", not the other localized format like "Ene" (meaning January in locale es). // This conversion is used when generating outgoing MIME messages. Incoming MIME date @@ -62,6 +69,9 @@ public class MimeMessage extends Message { protected Body mBody; protected int mSize; + // regex that matches content id surrounded by "<>" optionally. + private static final Pattern REMOVE_OPTIONAL_BRACKETS = Pattern.compile("^]+)>?$"); + public MimeMessage() { /* * Every new messages gets a Message-ID @@ -100,12 +110,16 @@ public class MimeMessage extends Message { } protected void parse(InputStream in) throws IOException, MessagingException { + // Before parsing the input stream, clear all local fields that may be superceded by + // the new incoming message. mHeader.clear(); - mBody = null; - mBcc = null; - mTo = null; mFrom = null; + mTo = null; + mCc = null; + mBcc = null; + mReplyTo = null; mSentDate = null; + mBody = null; MimeStreamParser parser = new MimeStreamParser(); parser.setContentHandler(new MimeMessageBuilder()); @@ -152,6 +166,16 @@ public class MimeMessage extends Message { } } + public String getContentId() throws MessagingException { + String contentId = getFirstHeader(MimeHeader.HEADER_CONTENT_ID); + if (contentId == null) { + return null; + } else { + // remove optionally surrounding brackets. + return REMOVE_OPTIONAL_BRACKETS.matcher(contentId).replaceAll("$1"); + } + } + public String getMimeType() throws MessagingException { return MimeUtility.getHeaderParameter(getContentType(), null); } diff --git a/src/com/android/email/mail/internet/MimeUtility.java b/src/com/android/email/mail/internet/MimeUtility.java index 100a0abc5..7aa740221 100644 --- a/src/com/android/email/mail/internet/MimeUtility.java +++ b/src/com/android/email/mail/internet/MimeUtility.java @@ -126,13 +126,9 @@ public class MimeUtility { } } } - String[] header = part.getHeader("Content-ID"); - if (header != null) { - for (String s : header) { - if (s.equals(contentId)) { - return part; - } - } + String cid = part.getContentId(); + if (contentId.equals(cid)) { + return part; } return null; } diff --git a/src/com/android/email/mail/store/ImapResponseParser.java b/src/com/android/email/mail/store/ImapResponseParser.java index 4c7028b89..410be6601 100644 --- a/src/com/android/email/mail/store/ImapResponseParser.java +++ b/src/com/android/email/mail/store/ImapResponseParser.java @@ -16,6 +16,15 @@ package com.android.email.mail.store; +import com.android.email.Email; +import com.android.email.FixedLengthInputStream; +import com.android.email.PeekableInputStream; +import com.android.email.mail.MessagingException; +import com.android.email.mail.transport.LoggingInputStream; + +import android.util.Config; +import android.util.Log; + import java.io.IOException; import java.io.InputStream; import java.text.ParseException; @@ -24,14 +33,6 @@ import java.util.ArrayList; import java.util.Date; import java.util.Locale; -import android.util.Config; -import android.util.Log; - -import com.android.email.Email; -import com.android.email.FixedLengthInputStream; -import com.android.email.PeekableInputStream; -import com.android.email.mail.MessagingException; - public class ImapResponseParser { // DEBUG ONLY - Always check in as "false" private static boolean DEBUG_LOG_RAW_STREAM = false; @@ -43,11 +44,11 @@ public class ImapResponseParser { PeekableInputStream mIn; InputStream mActiveLiteral; - public ImapResponseParser(PeekableInputStream in) { + public ImapResponseParser(InputStream in) { if (DEBUG_LOG_RAW_STREAM && Config.LOGD && Email.DEBUG) { in = new LoggingInputStream(in); } - this.mIn = in; + this.mIn = new PeekableInputStream(in); } /** @@ -383,69 +384,4 @@ public class ImapResponseParser { } } - - /** - * Simple class used for debugging only that affords us a view of the raw Imap stream, - * in addition to the tokenized version. - */ - private static class LoggingInputStream extends PeekableInputStream { - - PeekableInputStream mIn; - StringBuilder mSb; - - public LoggingInputStream(PeekableInputStream in) { - super(null); - mIn = in; - mSb = new StringBuilder(); - } - - /** - * Collect chars as read, and log them when EOL reached. - */ - @Override - public int read() throws IOException { - int oneByte = mIn.read(); - logRaw(oneByte); - return oneByte; - } - - /** - * Collect chars as read, and log them when EOL reached. - */ - @Override - public int read(byte[] b, int offset, int length) throws IOException { - int bytesRead = mIn.read(b, offset, length); - int copyBytes = bytesRead; - while (copyBytes > 0) { - logRaw((char)b[offset]); - copyBytes--; - offset++; - } - - return bytesRead; - } - - /** - * Pass-through any peeks - */ - @Override - public int peek() throws IOException { - return mIn.peek(); - } - - /** - * Write and clear the buffer - */ - private void logRaw(int oneByte) { - if (oneByte == '\r' || oneByte == '\n') { - if (mSb.length() > 0) { - Log.d(Email.LOG_TAG, "RAW " + mSb.toString()); - mSb = new StringBuilder(); - } - } else { - mSb.append((char)oneByte); - } - } - } - } diff --git a/src/com/android/email/mail/store/ImapStore.java b/src/com/android/email/mail/store/ImapStore.java index d723982d7..13b85f12f 100644 --- a/src/com/android/email/mail/store/ImapStore.java +++ b/src/com/android/email/mail/store/ImapStore.java @@ -856,6 +856,7 @@ public class ImapStore extends Store { if (bs.get(2) instanceof ImapList) { bodyParams = bs.getList(2); } + String cid = bs.getString(3); String encoding = bs.getString(5); int size = bs.getNumber(6); @@ -941,6 +942,12 @@ public class ImapStore extends Store { * to parse the body. */ part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, encoding); + /* + * Set the Content-ID header. + */ + if (!"NIL".equalsIgnoreCase(cid)) { + part.setHeader(MimeHeader.HEADER_CONTENT_ID, cid); + } if (part instanceof ImapMessage) { ((ImapMessage) part).setSize(size); @@ -1084,8 +1091,6 @@ public class ImapStore extends Store { private int mNextCommandTag; public void open() throws IOException, MessagingException { - PeekableInputStream mIn; - if (mTransport != null && mTransport.isOpen()) { return; } @@ -1101,8 +1106,7 @@ public class ImapStore extends Store { mTransport.open(); mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT); - mIn = new PeekableInputStream(mTransport.getInputStream()); - mParser = new ImapResponseParser(mIn); + mParser = new ImapResponseParser(mTransport.getInputStream()); // BANNER mParser.readResponse(); @@ -1119,8 +1123,7 @@ public class ImapStore extends Store { mTransport.reopenTls(); mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT); - mIn = new PeekableInputStream(mTransport.getInputStream()); - mParser = new ImapResponseParser(mIn); + mParser = new ImapResponseParser(mTransport.getInputStream()); } else if (mTransport.getSecurity() == Transport.CONNECTION_SECURITY_TLS_REQUIRED) { if (Config.LOGD && Email.DEBUG) { diff --git a/src/com/android/email/mail/store/LocalStore.java b/src/com/android/email/mail/store/LocalStore.java index 0021abc42..995b64fef 100644 --- a/src/com/android/email/mail/store/LocalStore.java +++ b/src/com/android/email/mail/store/LocalStore.java @@ -76,10 +76,11 @@ public class LocalStore extends Store { * ---------- ---------- ----- * 18 pre-1.0 Development versions. No upgrade path. * 18 1.0, 1.1 1.0 Release version. - * 19 1.5 Added message_id column + * 19 - Added message_id column to messages table. + * 20 1.5 Added content_id column to attachments table. */ - private static final int DB_VERSION = 19; + private static final int DB_VERSION = 20; private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.X_DESTROYED, Flag.SEEN }; @@ -110,6 +111,10 @@ public class LocalStore extends Store { } mDb = SQLiteDatabase.openOrCreateDatabase(mPath, null); int oldVersion = mDb.getVersion(); + + /* + * TODO we should have more sophisticated way to upgrade database. + */ if (oldVersion != DB_VERSION) { if (Config.LOGV) { Log.v(Email.LOG_TAG, String.format("Upgrading database from %d to %d", @@ -133,7 +138,7 @@ public class LocalStore extends Store { mDb.execSQL("DROP TABLE IF EXISTS attachments"); mDb.execSQL("CREATE TABLE attachments (id INTEGER PRIMARY KEY, message_id INTEGER," + "store_data TEXT, content_uri TEXT, size INTEGER, name TEXT," - + "mime_type TEXT)"); + + "mime_type TEXT, content_id TEXT)"); mDb.execSQL("DROP TABLE IF EXISTS pending_commands"); mDb.execSQL("CREATE TABLE pending_commands " + @@ -146,12 +151,21 @@ public class LocalStore extends Store { mDb.execSQL("CREATE TRIGGER delete_message BEFORE DELETE ON messages BEGIN DELETE FROM attachments WHERE old.id = message_id; END;"); mDb.setVersion(DB_VERSION); } - else if (oldVersion < 19) { - /** - * Upgrade 18 to 19: add message_id to messages table - */ - mDb.execSQL("ALTER TABLE messages ADD COLUMN message_id TEXT;"); - mDb.setVersion(DB_VERSION); + else { + if (oldVersion < 19) { + /** + * Upgrade 18 to 19: add message_id to messages table + */ + mDb.execSQL("ALTER TABLE messages ADD COLUMN message_id TEXT;"); + mDb.setVersion(19); + } + if (oldVersion < 20) { + /** + * Upgrade 19 to 20: add content_id to attachments table + */ + mDb.execSQL("ALTER TABLE attachments ADD COLUMN content_id TEXT;"); + mDb.setVersion(20); + } } if (mDb.getVersion() != DB_VERSION) { @@ -523,7 +537,8 @@ public class LocalStore extends Store { "name", "mime_type", "store_data", - "content_uri" }, + "content_uri", + "content_id" }, "message_id = ?", new String[] { Long.toString(localMessage.mId) }, null, @@ -537,6 +552,7 @@ public class LocalStore extends Store { String type = cursor.getString(3); String storeData = cursor.getString(4); String contentUri = cursor.getString(5); + String contentId = cursor.getString(6); Body body = null; if (contentUri != null) { body = new LocalAttachmentBody(Uri.parse(contentUri), mContext); @@ -551,6 +567,7 @@ public class LocalStore extends Store { String.format("attachment;\n filename=\"%s\";\n size=%d", name, size)); + bp.setHeader(MimeHeader.HEADER_CONTENT_ID, contentId); /* * HEADER_ANDROID_ATTACHMENT_STORE_DATA is a custom header we add to that @@ -924,6 +941,7 @@ public class LocalStore extends Store { MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA), ','); String name = MimeUtility.getHeaderParameter(attachment.getContentType(), "name"); + String contentId = attachment.getContentId(); if (attachmentId == -1) { ContentValues cv = new ContentValues(); @@ -933,6 +951,7 @@ public class LocalStore extends Store { cv.put("size", size); cv.put("name", name); cv.put("mime_type", attachment.getMimeType()); + cv.put("content_id", contentId); attachmentId = mDb.insert("attachments", "message_id", cv); } @@ -940,6 +959,7 @@ public class LocalStore extends Store { ContentValues cv = new ContentValues(); cv.put("content_uri", contentUri != null ? contentUri.toString() : null); cv.put("size", size); + cv.put("content_id", contentId); mDb.update( "attachments", cv, diff --git a/src/com/android/email/mail/store/Pop3Store.java b/src/com/android/email/mail/store/Pop3Store.java index 48fe878fb..31468d896 100644 --- a/src/com/android/email/mail/store/Pop3Store.java +++ b/src/com/android/email/mail/store/Pop3Store.java @@ -29,6 +29,7 @@ import com.android.email.mail.Store; import com.android.email.mail.Transport; import com.android.email.mail.Folder.OpenMode; import com.android.email.mail.internet.MimeMessage; +import com.android.email.mail.transport.LoggingInputStream; import com.android.email.mail.transport.MailTransport; import android.util.Config; @@ -46,6 +47,7 @@ public class Pop3Store extends Store { // All flags defining debug or development code settings must be FALSE // when code is checked in or released. private static boolean DEBUG_FORCE_SINGLE_LINE_UIDL = false; + private static boolean DEBUG_LOG_RAW_STREAM = false; private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED }; @@ -718,7 +720,11 @@ public class Pop3Store extends Store { } if (response != null) { try { - message.parse(new Pop3ResponseInputStream(mTransport.getInputStream())); + InputStream in = mTransport.getInputStream(); + if (DEBUG_LOG_RAW_STREAM && Config.LOGD && Email.DEBUG) { + in = new LoggingInputStream(in); + } + message.parse(new Pop3ResponseInputStream(in)); } catch (MessagingException me) { /* diff --git a/src/com/android/email/mail/transport/LoggingInputStream.java b/src/com/android/email/mail/transport/LoggingInputStream.java new file mode 100644 index 000000000..233c3a5d5 --- /dev/null +++ b/src/com/android/email/mail/transport/LoggingInputStream.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.transport; + +import com.android.email.Email; + +import android.util.Log; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Simple class used for debugging only that affords us a view of the raw IMAP or POP3 stream, + * in addition to the tokenized version. + * + * Use of this class *MUST* be restricted to logging-enabled situations only. + */ +public class LoggingInputStream extends InputStream { + + InputStream mIn; + StringBuilder mSb; + boolean mBufferDirty; + + private final String LINE_TAG = "RAW "; + + public LoggingInputStream(InputStream in) { + super(); + mIn = in; + mSb = new StringBuilder(LINE_TAG); + mBufferDirty = false; + } + + /** + * Collect chars as read, and log them when EOL reached. + */ + @Override + public int read() throws IOException { + int oneByte = mIn.read(); + logRaw(oneByte); + return oneByte; + } + + /** + * Collect chars as read, and log them when EOL reached. + */ + @Override + public int read(byte[] b, int offset, int length) throws IOException { + int bytesRead = mIn.read(b, offset, length); + int copyBytes = bytesRead; + while (copyBytes > 0) { + logRaw((char)b[offset]); + copyBytes--; + offset++; + } + + return bytesRead; + } + + /** + * Write and clear the buffer + */ + private void logRaw(int oneByte) { + if (oneByte == '\r' || oneByte == '\n') { + if (mBufferDirty) { + Log.d(Email.LOG_TAG, mSb.toString()); + mSb = new StringBuilder(LINE_TAG); + mBufferDirty = false; + } + } else { + mSb.append((char)oneByte); + mBufferDirty = true; + } + } +} diff --git a/tests/src/com/android/email/activity/MessageViewTests.java b/tests/src/com/android/email/activity/MessageViewTests.java index 64b42d679..6217f2a94 100644 --- a/tests/src/com/android/email/activity/MessageViewTests.java +++ b/tests/src/com/android/email/activity/MessageViewTests.java @@ -21,16 +21,25 @@ import com.android.email.Email; import com.android.email.MessagingController; import com.android.email.Preferences; import com.android.email.R; +import com.android.email.mail.MessageTestUtils; +import com.android.email.mail.Message; +import com.android.email.mail.MessagingException; +import com.android.email.mail.MessageTestUtils.MessageBuilder; +import com.android.email.mail.MessageTestUtils.MultipartBuilder; +import com.android.email.mail.MessageTestUtils.TextBuilder; +import com.android.email.mail.internet.BinaryTempFileBody; +import com.android.email.mail.store.LocalStore; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.test.ActivityInstrumentationTestCase2; import android.test.suitebuilder.annotation.MediumTest; import android.test.suitebuilder.annotation.Suppress; -import android.view.MenuItem; import android.webkit.WebView; import android.widget.TextView; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -58,6 +67,7 @@ public class MessageViewTests private TextView mToView; private TextView mSubjectView; private WebView mMessageContentView; + private Context mContext; public MessageViewTests() { super("com.android.email", MessageView.class); @@ -66,13 +76,13 @@ public class MessageViewTests @Override protected void setUp() throws Exception { super.setUp(); - Context context = getInstrumentation().getTargetContext(); - Account[] accounts = Preferences.getPreferences(context).getAccounts(); + mContext = getInstrumentation().getTargetContext(); + Account[] accounts = Preferences.getPreferences(mContext).getAccounts(); if (accounts.length > 0) { // This depends on getDefaultAccount() to auto-assign the default account, if necessary - mAccount = Preferences.getPreferences(context).getDefaultAccount(); - Email.setServicesEnabled(context); + mAccount = Preferences.getPreferences(mContext).getDefaultAccount(); + Email.setServicesEnabled(mContext); } // configure a mock controller @@ -93,6 +103,9 @@ public class MessageViewTests mToView = (TextView) a.findViewById(R.id.to); mSubjectView = (TextView) a.findViewById(R.id.subject); mMessageContentView = (WebView) a.findViewById(R.id.message_content); + + // This is needed for mime image bodypart. + BinaryTempFileBody.setTempDirectory(getActivity().getCacheDir()); } /** @@ -135,6 +148,90 @@ public class MessageViewTests a.handleMenuItem(R.id.forward); a.handleMenuItem(R.id.mark_as_unread); } + + /** + * Tests for resolving inline image src cid: reference to content uri. + */ + + public void testResolveInlineImage() throws MessagingException, IOException { + final MessageView a = getActivity(); + final LocalStore store = new LocalStore(mAccount.getLocalStoreUri(), mContext); + + // Single cid case. + final String cid1 = "cid.1@android.com"; + final long aid1 = 10; + final Uri uri1 = MessageTestUtils.contentUri(aid1, mAccount); + final String text1 = new TextBuilder("text1 > ").addCidImg(cid1).build(" <."); + final String expected1 = new TextBuilder("text1 > ").addUidImg(uri1).build(" <."); + + // message with cid1 + final Message msg1 = new MessageBuilder() + .setBody(new MultipartBuilder("multipart/related") + .addBodyPart(MessageTestUtils.textPart("text/html", text1)) + .addBodyPart(MessageTestUtils.imagePart("image/jpeg", "<"+cid1+">", aid1, store)) + .build()) + .build(); + // Simple case. + final String actual1 = a.resolveInlineImage(text1, msg1, 0); + assertEquals("one content id reference is not resolved", + expected1, actual1); + + // Exceed recursive limit. + final String actual0 = a.resolveInlineImage(text1, msg1, 10); + assertEquals("recursive call limit may exceeded", + text1, actual0); + + // Multiple cids case. + final String cid2 = "cid.2@android.com"; + final long aid2 = 20; + final Uri uri2 = MessageTestUtils.contentUri(aid2, mAccount); + final String text2 = new TextBuilder("text2 ").addCidImg(cid2).build("."); + final String expected2 = new TextBuilder("text2 ").addUidImg(uri2).build("."); + + // message with only cid2 + final Message msg2 = new MessageBuilder() + .setBody(new MultipartBuilder("multipart/related") + .addBodyPart(MessageTestUtils.textPart("text/html", text1 + text2)) + .addBodyPart(MessageTestUtils.imagePart("image/gif", cid2, aid2, store)) + .build()) + .build(); + // cid1 is not replaced + final String actual2 = a.resolveInlineImage(text1 + text2, msg2, 0); + assertEquals("only one of two content id is resolved", + text1 + expected2, actual2); + + // message with cid1 and cid2 + final Message msg3 = new MessageBuilder() + .setBody(new MultipartBuilder("multipart/related") + .addBodyPart(MessageTestUtils.textPart("text/html", text2 + text1)) + .addBodyPart(MessageTestUtils.imagePart("image/jpeg", cid1, aid1, store)) + .addBodyPart(MessageTestUtils.imagePart("image/gif", cid2, aid2, store)) + .build()) + .build(); + // cid1 and cid2 are replaced + final String actual3 = a.resolveInlineImage(text2 + text1, msg3, 0); + assertEquals("two content ids are resolved correctly", + expected2 + expected1, actual3); + + // message with many cids and normal attachments + final Message msg4 = new MessageBuilder() + .setBody(new MultipartBuilder("multipart/mixed") + .addBodyPart(MessageTestUtils.imagePart("image/jpeg", null, 30, store)) + .addBodyPart(MessageTestUtils.imagePart("application/pdf", cid1, aid1, store)) + .addBodyPart(new MultipartBuilder("multipart/related") + .addBodyPart(MessageTestUtils.textPart("text/html", text2 + text1)) + .addBodyPart(MessageTestUtils.imagePart("image/jpg", cid1, aid1, store)) + .addBodyPart(MessageTestUtils.imagePart("image/gif", cid2, aid2, store)) + .buildBodyPart()) + .addBodyPart(MessageTestUtils.imagePart("application/pdf", cid2, aid2, store)) + .build()) + .build(); + // cid1 and cid2 are replaced + final String actual4 = a.resolveInlineImage(text2 + text1, msg4, 0); + assertEquals("two content ids in deep multipart level are resolved", + expected2 + expected1, actual4); + } + /** * Mock Messaging controller, so we can drive its callbacks. This probably should be diff --git a/tests/src/com/android/email/mail/AddressUnitTests.java b/tests/src/com/android/email/mail/AddressUnitTests.java index 10d151f24..585a1b9d0 100644 --- a/tests/src/com/android/email/mail/AddressUnitTests.java +++ b/tests/src/com/android/email/mail/AddressUnitTests.java @@ -43,9 +43,27 @@ public class AddressUnitTests extends AndroidTestCase { } /** - * TODO: test parse() + * TODO: more in-depth tests for parse() */ + /** + * Simple quick checks of empty-input edge conditions for parse() + * + * NOTE: This is not a claim that these edge cases are "correct", only to maintain consistent + * behavior while I am changing some of the code in the function under test. + */ + public void testEmptyParse() { + Address[] result; + + // null input => empty array + result = Address.parse(null); + assertTrue("parsing null address", result != null && result.length == 0); + + // empty string input => empty array + result = Address.parse(""); + assertTrue("parsing zero-length", result != null && result.length == 0); + } + /** * TODO: test toString() (single & list) */ @@ -75,6 +93,43 @@ public class AddressUnitTests extends AndroidTestCase { } /** - * TODO: test pack() and unpack() + * TODO: more in-depth tests for pack() and unpack() */ + + /** + * Simple quick checks of empty-input edge conditions for pack() + * + * NOTE: This is not a claim that these edge cases are "correct", only to maintain consistent + * behavior while I am changing some of the code in the function under test. + */ + public void testEmptyPack() { + String result; + + // null input => null string + result = Address.pack(null); + assertNull("packing null", result); + + // zero-length input => empty string + result = Address.pack(new Address[] { }); + assertEquals("packing empty array", "", result); + } + + /** + * Simple quick checks of empty-input edge conditions for unpack() + * + * NOTE: This is not a claim that these edge cases are "correct", only to maintain consistent + * behavior while I am changing some of the code in the function under test. + */ + public void testEmptyUnpack() { + Address[] result; + + // null input => empty array + result = Address.unpack(null); + assertTrue("unpacking null address", result != null && result.length == 0); + + // empty string input => empty array + result = Address.unpack(""); + assertTrue("unpacking zero-length", result != null && result.length == 0); + } + } diff --git a/tests/src/com/android/email/mail/MessageTestUtils.java b/tests/src/com/android/email/mail/MessageTestUtils.java new file mode 100644 index 000000000..506b54974 --- /dev/null +++ b/tests/src/com/android/email/mail/MessageTestUtils.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail; + +import com.android.email.Account; +import com.android.email.mail.internet.BinaryTempFileBody; +import com.android.email.mail.internet.MimeBodyPart; +import com.android.email.mail.internet.MimeHeader; +import com.android.email.mail.internet.MimeMessage; +import com.android.email.mail.internet.MimeMultipart; +import com.android.email.mail.internet.TextBody; +import com.android.email.mail.store.LocalStore; +import com.android.email.provider.AttachmentProvider; + +import android.net.Uri; + +import java.io.IOException; +import java.util.ArrayList; + +/** + * Utility class makes it easier for developer to build mail message objects. + *

+ * Typical usage of these helper functions and builder objects are as follows. + *

+ *

+ * String text2 = new TextBuilder("").text("")
+ *     .text("").cidImg("contetid@domain").text("").build("").text("")
+ *     .text("").uriImg(contentUri).text("").build("
+ */
+
+public class MessageTestUtils {
+
+    /**
+     * Generate AttachmentProvider content URI from attachment ID and Account.
+     * 
+     * @param attachmentId attachment id
+     * @param account Account object
+     * @return AttachmentProvider content URI
+     */
+    public static Uri contentUri(long attachmentId, Account account) {
+        return AttachmentProvider.getAttachmentUri(account, attachmentId);
+    }
+
+    /**
+     * Create simple MimeBodyPart.
+     *  
+     * @param mimeType MIME type of body part
+     * @param contentId content-id header value (optional - null for no header)
+     * @return MimeBodyPart object which body is null.
+     * @throws MessagingException
+     */
+    public static BodyPart bodyPart(String mimeType, String contentId) throws MessagingException {
+        final MimeBodyPart bp = new MimeBodyPart(null, mimeType);
+        if (contentId != null) {
+            bp.setHeader(MimeHeader.HEADER_CONTENT_ID, contentId);
+        }
+        return bp;
+    }
+    
+    /**
+     * Create MimeBodyPart with TextBody.
+     * 
+     * @param mimeType MIME type of text
+     * @param text body text string
+     * @return MimeBodyPart object whose body is TextBody
+     * @throws MessagingException
+     */
+    public static BodyPart textPart(String mimeType, String text) throws MessagingException {
+        final TextBody textBody = new TextBody(text);
+        final MimeBodyPart textPart = new MimeBodyPart(textBody);
+        textPart.setHeader(MimeHeader.HEADER_CONTENT_TYPE, mimeType);
+        return textPart;
+    }
+
+    /**
+     * Create attachment BodyPart with content-id.
+     * 
+     * @param mimeType MIME type of image body
+     * @param contentId content-id header value (optional - null for no header)
+     * @param attachmentId attachment id of store
+     * @param store LocalStore which stores attachment
+     * @return LocalAttachmentBodyPart with content-id 
+     * @throws MessagingException
+     * @throws IOException
+     */
+    public static BodyPart imagePart(String mimeType, String contentId,
+            long attachmentId, LocalStore store) throws MessagingException, IOException {
+        final BinaryTempFileBody imageBody = new BinaryTempFileBody();
+        final LocalStore.LocalAttachmentBodyPart imagePart =
+            store.new LocalAttachmentBodyPart(imageBody, attachmentId);
+        imagePart.setHeader(MimeHeader.HEADER_CONTENT_TYPE, mimeType);
+        if (contentId != null) {
+            imagePart.setHeader(MimeHeader.HEADER_CONTENT_ID, contentId);
+        }
+        return imagePart;
+    }
+
+    /**
+     * Builder class for Multipart.
+     * 
+     * This builder object accepts any number of BodyParts and then can produce
+     * Multipart or BodyPart which contains accepted BodyParts. Usually combined with other
+     * builder object and helper method.
+     */
+    public static class MultipartBuilder {
+        private final String mContentType;
+        private final ArrayList mParts = new ArrayList();
+        
+        /**
+         * Create builder object with MIME type and dummy boundary string.
+         * 
+         * @param mimeType MIME type of this Multipart  
+         */
+        public MultipartBuilder(String mimeType) {
+            this(mimeType, "this_is_boundary");
+        }
+
+        /**
+         * Create builder object with MIME type and boundary string.
+         * 
+         * @param mimeType MIME type of this Multipart
+         * @param boundary boundary string
+         */
+        public MultipartBuilder(String mimeType, String boundary) {
+            mContentType = mimeType + "; boundary=" + boundary;
+        }
+
+        /**
+         * Modifier method to add BodyPart to intended Multipart.
+         * 
+         * @param bodyPart BodyPart to be added
+         * @return builder object itself
+         */
+        public MultipartBuilder addBodyPart(final BodyPart bodyPart) {
+            mParts.add(bodyPart);
+            return this;
+        }
+
+        /**
+         * Build method to create Multipart.
+         * 
+         * @return intended Multipart object
+         * @throws MessagingException
+         */
+        public Multipart build() throws MessagingException {
+            final MimeMultipart mp = new MimeMultipart(mContentType);
+            for (BodyPart p : mParts) {
+                mp.addBodyPart(p);
+            }
+            return mp;
+        }
+
+        /**
+         * Build method to create BodyPart that contains this "Multipart"
+         * @return BodyPart whose body is intended Multipart.
+         * @throws MessagingException
+         */
+        public BodyPart buildBodyPart() throws MessagingException {
+            final BodyPart bp = new MimeBodyPart();
+            bp.setBody(this.build());
+            return bp;
+        }
+    }
+
+    /**
+     * Builder class for Message
+     * 
+     * This builder object accepts Body and then can produce Message object.
+     * Usually combined with other builder object and helper method.
+     */
+    public static class MessageBuilder {
+        private Body mBody;
+       
+        /**
+         * Create Builder object.
+         */
+        public MessageBuilder() {
+        }
+
+        /**
+         * Modifier method to set Body.
+         * 
+         * @param body Body of intended Message
+         * @return builder object itself
+         */
+        public MessageBuilder setBody(final Body body) {
+            mBody = body;
+            return this;
+        }
+
+        /**
+         * Build method to create Message.
+         * 
+         * @return intended Message object
+         * @throws MessagingException
+         */
+        public Message build() throws MessagingException {
+            final MimeMessage msg = new MimeMessage();
+            if (mBody == null) {
+                throw new MessagingException("body is not specified");
+            }
+            msg.setBody(mBody);
+            return msg;
+        }
+    }
+
+    /**
+     * Builder class for simple HTML String. 
+     * This builder object accepts some type of object or and string and then create String object.
+     * Usually combined with other builder object and helper method.
+     */
+    public static class TextBuilder {
+        final StringBuilder mBuilder = new StringBuilder();
+
+        /**
+         * Create builder with preamble string
+         * @param preamble 
+         */
+        public TextBuilder(String preamble) {
+            mBuilder.append(preamble);
+        }
+        
+        /**
+         * Modifier method to add img tag that has cid: src attribute.
+         * @param contentId content id string
+         * @return builder object itself
+         */
+        public TextBuilder addCidImg(String contentId) {
+            return addTag("img", "SRC", "cid:" + contentId);
+        }
+
+        /**
+         * Modifier method to add img tag that has content:// src attribute.
+         * @param contentUri content uri object
+         * @return builder object itself
+         */
+        public TextBuilder addUidImg(Uri contentUri) {
+            return addTag("img", "src", contentUri.toString());
+        }
+
+        /**
+         * Modifier method to add tag with specified attribute and value.
+         * 
+         * @param tag tag name
+         * @param attribute attribute name
+         * @param value attribute value
+         * @return builder object itself
+         */
+        public TextBuilder addTag(String tag, String attribute, String value) {
+            return addText(String.format("<%s %s=\"%s\">", tag, attribute, value));
+        }
+
+        /**
+         * Modifier method to add simple string.
+         * @param text string to add
+         * @return builder object itself
+         */
+        public TextBuilder addText(String text) {
+            mBuilder.append(text);
+            return this;
+        }
+
+        /**
+         * Build method to create intended String
+         * @param epilogue string to add to the end
+         * @return intended String
+         */
+        public String build(String epilogue) {
+            mBuilder.append(epilogue);
+            return mBuilder.toString();
+        }
+    }
+
+}
diff --git a/tests/src/com/android/email/mail/internet/MimeBodyPartTest.java b/tests/src/com/android/email/mail/internet/MimeBodyPartTest.java
new file mode 100644
index 000000000..d0ba8eacb
--- /dev/null
+++ b/tests/src/com/android/email/mail/internet/MimeBodyPartTest.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.email.mail.internet;
+
+import com.android.email.mail.MessagingException;
+import com.android.email.mail.internet.MimeBodyPart;
+import com.android.email.mail.internet.MimeHeader;
+
+import junit.framework.TestCase;
+
+import android.test.suitebuilder.annotation.SmallTest;
+
+/**
+ * This is a series of unit tests for the MimeBodyPart class.  These tests must be locally
+ * complete - no server(s) required.
+ */
+@SmallTest
+public class MimeBodyPartTest extends TestCase {
+
+    // TODO: more tests.
+    
+    /*
+     * Confirm getContentID() correctly works.
+     */
+    public void testGetContentId() throws MessagingException {
+        MimeBodyPart bp = new MimeBodyPart();
+
+        // no content-id
+        assertNull(bp.getContentId());
+
+        // normal case
+        final String cid1 = "cid.1@android.com";
+        bp.setHeader(MimeHeader.HEADER_CONTENT_ID, cid1);
+        assertEquals(cid1, bp.getContentId());
+
+        // surrounded by optional bracket
+        bp.setHeader(MimeHeader.HEADER_CONTENT_ID, "<" + cid1 + ">");
+        assertEquals(cid1, bp.getContentId());
+    }
+}
diff --git a/tests/src/com/android/email/mail/internet/MimeMessageTest.java b/tests/src/com/android/email/mail/internet/MimeMessageTest.java
index 4e52532fa..d1fde4a6e 100644
--- a/tests/src/com/android/email/mail/internet/MimeMessageTest.java
+++ b/tests/src/com/android/email/mail/internet/MimeMessageTest.java
@@ -17,6 +17,8 @@
 package com.android.email.mail.internet;
 
 import com.android.email.mail.MessagingException;
+import com.android.email.mail.internet.MimeHeader;
+import com.android.email.mail.internet.MimeMessage;
 
 import android.test.suitebuilder.annotation.SmallTest;
 
@@ -95,4 +97,23 @@ public class MimeMessageTest extends TestCase {
         message2.setMessageId(testId2);
         assertEquals("set and get Message-ID", testId2, message2.getMessageId());
     }
+
+    /*
+     * Confirm getContentID() correctly works.
+     */
+    public void testGetContentId() throws MessagingException {
+        MimeMessage message = new MimeMessage();
+
+        // no content-id
+        assertNull(message.getContentId());
+
+        // normal case
+        final String cid1 = "cid.1@android.com";
+        message.setHeader(MimeHeader.HEADER_CONTENT_ID, cid1);
+        assertEquals(cid1, message.getContentId());
+
+        // surrounded by optional bracket
+        message.setHeader(MimeHeader.HEADER_CONTENT_ID, "<" + cid1 + ">");
+        assertEquals(cid1, message.getContentId());
+    }
 }
\ No newline at end of file
diff --git a/tests/src/com/android/email/mail/internet/MimeUtilityTest.java b/tests/src/com/android/email/mail/internet/MimeUtilityTest.java
index bbc1e9359..e894161be 100644
--- a/tests/src/com/android/email/mail/internet/MimeUtilityTest.java
+++ b/tests/src/com/android/email/mail/internet/MimeUtilityTest.java
@@ -16,7 +16,13 @@
 
 package com.android.email.mail.internet;
 
+import com.android.email.mail.BodyPart;
+import com.android.email.mail.MessageTestUtils;
+import com.android.email.mail.Message;
 import com.android.email.mail.MessagingException;
+import com.android.email.mail.Part;
+import com.android.email.mail.MessageTestUtils.MessageBuilder;
+import com.android.email.mail.MessageTestUtils.MultipartBuilder;
 
 import android.test.suitebuilder.annotation.SmallTest;
 
@@ -35,7 +41,46 @@ public class MimeUtilityTest extends TestCase {
     // TODO:  tests for foldAndEncode(String s)
     // TODO:  tests for getHeaderParameter(String header, String name)
     // TODO:  tests for findFirstPartByMimeType(Part part, String mimeType)
-    // TODO:  tests for findPartByContentId(Part part, String contentId) throws Exception
+
+    /** Tests for findPartByContentId(Part part, String contentId) */
+    public void testFindPartByContentIdTestCase() throws MessagingException, Exception {
+        final String cid1 = "cid.1@android.com";
+        final Part cid1bp = MessageTestUtils.bodyPart("image/gif", cid1);
+        final String cid2 = "cid.2@android.com";
+        final Part cid2bp = MessageTestUtils.bodyPart("image/gif", "<" + cid2 + ">");
+
+        final Message msg1 = new MessageBuilder()
+            .setBody(new MultipartBuilder("multipart/related")
+                 .addBodyPart(MessageTestUtils.bodyPart("text/html", null))
+                 .addBodyPart((BodyPart)cid1bp)
+                 .build())
+            .build();
+        // found cid1 part
+        final Part actual1_1 = MimeUtility.findPartByContentId(msg1, cid1);
+        assertEquals("could not found expected content-id part", cid1bp, actual1_1);
+
+        final Message msg2 = new MessageBuilder()
+            .setBody(new MultipartBuilder("multipart/mixed")
+                .addBodyPart(MessageTestUtils.bodyPart("image/tiff", "cid.4@android.com"))
+                .addBodyPart(new MultipartBuilder("multipart/related")
+                    .addBodyPart(new MultipartBuilder("multipart/alternative")
+                        .addBodyPart(MessageTestUtils.bodyPart("text/plain", null))
+                        .addBodyPart(MessageTestUtils.bodyPart("text/html", null))
+                        .buildBodyPart())
+                    .addBodyPart((BodyPart)cid1bp)
+                    .buildBodyPart())
+                .addBodyPart(MessageTestUtils.bodyPart("image/gif", "cid.3@android.com"))
+                .addBodyPart((BodyPart)cid2bp)
+                .build())
+            .build();
+        // found cid1 part
+        final Part actual2_1 = MimeUtility.findPartByContentId(msg2, cid1);
+        assertEquals("found part from related multipart", cid1bp, actual2_1);
+
+        // found cid2 part
+        final Part actual2_2 = MimeUtility.findPartByContentId(msg2, cid2);
+        assertEquals("found part from mixed multipart", cid2bp, actual2_2);
+    }
     
     /** Tests for getTextFromPart(Part part) */
     public void testGetTextFromPartContentTypeCase() throws MessagingException {
@@ -111,4 +156,5 @@ public class MimeUtilityTest extends TestCase {
 
     // TODO:  tests for decodeBody(InputStream in, String contentTransferEncoding)    
     // TODO:  tests for collectParts(Part part, ArrayList viewables, ArrayList attachments)
+
 }
diff --git a/tests/src/com/android/email/mail/store/LocalStoreUnitTests.java b/tests/src/com/android/email/mail/store/LocalStoreUnitTests.java
index 57fc0ffeb..cbc32e80b 100644
--- a/tests/src/com/android/email/mail/store/LocalStoreUnitTests.java
+++ b/tests/src/com/android/email/mail/store/LocalStoreUnitTests.java
@@ -18,7 +18,6 @@ package com.android.email.mail.store;
 
 import com.android.email.mail.Address;
 import com.android.email.mail.Message;
-import com.android.email.mail.MessageRetrievalListener;
 import com.android.email.mail.MessagingException;
 import com.android.email.mail.Folder.OpenMode;
 import com.android.email.mail.Message.RecipientType;
@@ -26,13 +25,15 @@ import com.android.email.mail.internet.BinaryTempFileBody;
 import com.android.email.mail.internet.MimeMessage;
 import com.android.email.mail.internet.TextBody;
 
-import android.app.Application;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
 import android.test.AndroidTestCase;
-import android.test.mock.MockApplication;
 import android.test.suitebuilder.annotation.SmallTest;
 
 import java.io.File;
 import java.net.URI;
+import java.net.URISyntaxException;
 
 /**
  * This is a series of unit tests for the LocalStore class.
@@ -53,6 +54,7 @@ public class LocalStoreUnitTests extends AndroidTestCase {
     private String mLocalStoreUri = null;
     private LocalStore mStore = null;
     private LocalStore.LocalFolder mFolder = null;
+    private File mCacheDir;
     
     /**
      * Setup code.  We generate a lightweight LocalStore and LocalStore.LocalFolder.
@@ -69,7 +71,8 @@ public class LocalStoreUnitTests extends AndroidTestCase {
         mFolder = (LocalStore.LocalFolder) mStore.getFolder("TEST");
         
         // This is needed for parsing mime messages
-        BinaryTempFileBody.setTempDirectory(this.getContext().getCacheDir());
+        mCacheDir = getContext().getCacheDir();
+        BinaryTempFileBody.setTempDirectory(mCacheDir);
     }
     
     /**
@@ -199,6 +202,200 @@ public class LocalStoreUnitTests extends AndroidTestCase {
         return message;
     }
     
+    /**
+     * Tests for database version.
+     */
+    public void testDbVersion() throws MessagingException, URISyntaxException {
+        final LocalStore store = new LocalStore(mLocalStoreUri, getContext());
+        final URI uri = new URI(mLocalStoreUri);
+        final String dbPath = uri.getPath();
+        final SQLiteDatabase db = SQLiteDatabase.openOrCreateDatabase(dbPath, null);
 
+        // database version should be latest.
+        assertEquals("database version should be latest", 20, db.getVersion());
+        db.close();
+    }
+    
+    /**
+     * Helper function convert Cursor data to ContentValues
+     */
+    private ContentValues cursorToContentValues(Cursor c, String[] schema) {
+        if (c.getColumnCount() != schema.length) {
+            throw new IndexOutOfBoundsException("schema length is not mach with cursor columns");
+        }
+        
+        final ContentValues cv = new ContentValues();
+        for (int i = 0, count = c.getColumnCount(); i < count; ++i) {
+            final String key = c.getColumnName(i);
+            final String type = schema[i];
+            if (type == "text") {
+                cv.put(key, c.getString(i));
+            } else if (type == "integer" || type == "primary") {
+                cv.put(key, c.getLong(i));
+            } else if (type == "numeric" || type == "real") {
+                cv.put(key, c.getDouble(i));
+            } else if (type == "blob") {
+                cv.put(key, c.getBlob(i));
+            } else {
+                throw new IllegalArgumentException("unsupported type at index " + i);
+            }
+        }
+        return cv;
+    }
+    
+    /**
+     * Tests for database upgrade from version 18 to version 20.
+     */
+    public void testDbUpgrade18To20() throws MessagingException, URISyntaxException {
+        final URI uri = new URI(mLocalStoreUri);
+        final String dbPath = uri.getPath();
+        SQLiteDatabase db = SQLiteDatabase.openOrCreateDatabase(dbPath, null);
+
+        // create sample version 18 db tables
+        createSampleDb(db, 18);
+
+        // sample message data and expected data
+        final ContentValues initialMessage = new ContentValues();
+        initialMessage.put("folder_id", (long) 2);        // folder_id type integer == Long
+        initialMessage.put("internal_date", (long) 3);    // internal_date type integer == Long
+        final ContentValues expectedMessage = new ContentValues(initialMessage);
+        expectedMessage.put("id", db.insert("messages", null, initialMessage));
+
+        // sample attachment data and expected data
+        final ContentValues initialAttachment = new ContentValues();
+        initialAttachment.put("message_id", (long) 4);    // message_id type integer == Long
+        initialAttachment.put("mime_type", (String) "a"); // mime_type type text == String
+        final ContentValues expectedAttachment = new ContentValues(initialAttachment);
+        expectedAttachment.put("id", db.insert("attachments", null, initialAttachment));
+        db.close();
+
+        // upgrade database 18 to 20
+        new LocalStore(mLocalStoreUri, getContext());
+
+        // added message_id column should be initialized as null
+        expectedMessage.put("message_id", (String) null);    // message_id type text == String
+        // added content_id column should be initialized as null
+        expectedAttachment.put("content_id", (String) null); // content_id type text == String
+
+        // database should be upgraded
+        db = SQLiteDatabase.openOrCreateDatabase(dbPath, null);
+        assertEquals("database should be upgraded", 20, db.getVersion());
+        Cursor c;
+
+        // check message table
+        c = db.query("messages",
+                new String[] { "id", "folder_id", "internal_date", "message_id" },
+                null, null, null, null, null);
+        // check if data is available
+        assertTrue("messages table should have one data", c.moveToNext());
+        
+        // check if data are expected
+        final ContentValues actualMessage = cursorToContentValues(c,
+                new String[] { "primary", "integer", "integer", "text" });
+       assertEquals("messages table cursor does not have expected values",
+                expectedMessage, actualMessage);
+        c.close();
+
+        // check attachment table
+        c = db.query("attachments",
+                new String[] { "id", "message_id", "mime_type", "content_id" },
+                null, null, null, null, null);
+        // check if data is available
+        assertTrue("attachments table should have one data", c.moveToNext());
+
+        // check if data are expected
+        final ContentValues actualAttachment = cursorToContentValues(c,
+                new String[] { "primary", "integer", "text", "text" });
+        assertEquals("attachment table cursor does not have expected values",
+                expectedAttachment, actualAttachment);
+        c.close();
+
+        db.close();
+    }
+
+    /**
+     * Tests for database upgrade from version 19 to version 20.
+     */
+    public void testDbUpgrade19To20() throws MessagingException, URISyntaxException {
+        final URI uri = new URI(mLocalStoreUri);
+        final String dbPath = uri.getPath();
+        SQLiteDatabase db = SQLiteDatabase.openOrCreateDatabase(dbPath, null);
+
+        // create minimu version 18 db tables
+        createSampleDb(db, 19);
+
+        // sample message data and expected data
+        final ContentValues initialMessage = new ContentValues();
+        initialMessage.put("folder_id", (long) 2);      // folder_id type integer == Long
+        initialMessage.put("internal_date", (long) 3);  // internal_date integer == Long
+        initialMessage.put("message_id", (String) "x"); // message_id text == String
+        final ContentValues expectedMessage = new ContentValues(initialMessage);
+        expectedMessage.put("id", db.insert("messages", null, initialMessage));
+
+        // sample attachment data and expected data
+        final ContentValues initialAttachment = new ContentValues();
+        initialAttachment.put("message_id", (long) 4);  // message_id type integer == Long
+        initialAttachment.put("mime_type", (String) "a"); // mime_type type text == String
+        final ContentValues expectedAttachment = new ContentValues(initialAttachment);
+        expectedAttachment.put("id", db.insert("attachments", null, initialAttachment));
+        
+        db.close();
+
+        // upgrade database 19 to 20
+        new LocalStore(mLocalStoreUri, getContext());
+
+        // added content_id column should be initialized as null
+        expectedAttachment.put("content_id", (String) null);  // content_id type text == String
+
+        // database should be upgraded
+        db = SQLiteDatabase.openOrCreateDatabase(dbPath, null);
+        assertEquals(20, db.getVersion());
+        Cursor c;
+
+        // check message table
+        c = db.query("messages",
+                new String[] { "id", "folder_id", "internal_date", "message_id" },
+                null, null, null, null, null);
+        // check if data is available
+        assertTrue("attachments table should have one data", c.moveToNext());
+
+        // check if data are expected
+        final ContentValues actualMessage = cursorToContentValues(c,
+                new String[] { "primary", "integer", "integer", "text" });
+        assertEquals("messages table cursor does not have expected values",
+                expectedMessage, actualMessage);
+
+        // check attachment table
+        c = db.query("attachments",
+                new String[] { "id", "message_id", "mime_type", "content_id" },
+                null, null, null, null, null);
+        // check if data is available
+        assertTrue("attachments table should have one data", c.moveToNext());
+
+        // check if data are expected
+        final ContentValues actualAttachment = cursorToContentValues(c,
+                        new String[] { "primary", "integer", "text", "text" });
+        assertEquals("attachment table cursor does not have expected values",
+                expectedAttachment, actualAttachment);
+
+        db.close();
+    }
+
+    private static void createSampleDb(SQLiteDatabase db, int version) {
+        db.execSQL("DROP TABLE IF EXISTS messages");
+        db.execSQL("CREATE TABLE messages (id INTEGER PRIMARY KEY, folder_id INTEGER, " +
+                   "uid TEXT, subject TEXT, date INTEGER, flags TEXT, sender_list TEXT, " +
+                   "to_list TEXT, cc_list TEXT, bcc_list TEXT, reply_to_list TEXT, " +
+                   "html_content TEXT, text_content TEXT, attachment_count INTEGER, " +
+                   "internal_date INTEGER" +
+                   ((version >= 19) ? ", message_id TEXT" : "") +
+                   ")");
+        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" +
+                   ((version >= 20) ? ", content_id" : "") +
+                   ")");
+        db.setVersion(version);
+    }
 }
-
diff --git a/tests/src/com/android/email/mail/store/Pop3StoreUnitTests.java b/tests/src/com/android/email/mail/store/Pop3StoreUnitTests.java
index 07ec55aad..862bed5f8 100644
--- a/tests/src/com/android/email/mail/store/Pop3StoreUnitTests.java
+++ b/tests/src/com/android/email/mail/store/Pop3StoreUnitTests.java
@@ -27,6 +27,7 @@ import com.android.email.mail.Folder.FolderType;
 import com.android.email.mail.Folder.OpenMode;
 import com.android.email.mail.Message.RecipientType;
 import com.android.email.mail.internet.BinaryTempFileBody;
+import com.android.email.mail.internet.MimeMessage;
 import com.android.email.mail.transport.MockTransport;
 
 import android.test.AndroidTestCase;
@@ -529,6 +530,16 @@ public class Pop3StoreUnitTests extends AndroidTestCase {
         fp.add(FetchProfile.Item.ENVELOPE);
         mFolder.fetch(messages, fp, null);
         assertEquals(PER_MESSAGE_SIZE, messages[0].getSize());
+        
+        // A side effect of how messages work is that if you get fields that are empty, 
+        // then empty arrays are written back into the parsed header fields (e.g. mTo, mFrom).  The
+        // standard message parser needs to clear these before parsing.  Make sure that this
+        // is happening.  (This doesn't affect IMAP, which reads the headers directly via
+        // IMAP evelopes.)
+        MimeMessage message = (MimeMessage) messages[0];
+        message.getRecipients(RecipientType.TO);
+        message.getRecipients(RecipientType.CC);
+        message.getRecipients(RecipientType.BCC);
 
         // now try fetching the message
         setupSingleMessage(mockTransport, 1, false);
@@ -586,6 +597,20 @@ public class Pop3StoreUnitTests extends AndroidTestCase {
         assertEquals(1, from.length);
         assertEquals("Jones@Registry.Org", from[0].getAddress());
         assertNull(from[0].getPersonal());
+        
+        // check Cc:
+        Address[] cc = message.getRecipients(RecipientType.CC);
+        assertNotNull(cc);
+        assertEquals(1, cc.length);
+        assertEquals("Chris@Registry.Org", cc[0].getAddress());
+        assertNull(cc[0].getPersonal());
+
+        // check Reply-To:
+        Address[] replyto = message.getReplyTo();
+        assertNotNull(replyto);
+        assertEquals(1, replyto.length);
+        assertEquals("Roger@Registry.Org", replyto[0].getAddress());
+        assertNull(replyto[0].getPersonal());
 
         // TODO date
         
@@ -649,6 +674,10 @@ public class Pop3StoreUnitTests extends AndroidTestCase {
      *     Date:     26 Aug 76 1429 EDT
      *     From:     Jones@Registry.Org
      *     To:       Smith@Registry.Org
+     * 
+     * We'll add the following fields to support additional tests:
+     *     Cc:       Chris@Registry.Org
+     *     Reply-To: Roger@Registry.Org
      *     
      * @param transport the mock transport to preload
      * @param msgNum the message number to expect and return
@@ -659,6 +688,8 @@ public class Pop3StoreUnitTests extends AndroidTestCase {
         transport.expect(null, "Date: 26 Aug 76 1429 EDT");
         transport.expect(null, "From: Jones@Registry.Org");
         transport.expect(null, "To:   Smith@Registry.Org");
+        transport.expect(null, "CC:   Chris@Registry.Org");
+        transport.expect(null, "Reply-To: Roger@Registry.Org");
         transport.expect(null, "");
         transport.expect(null, ".");
     }