diff --git a/src/com/android/email/mail/Part.java b/src/com/android/email/mail/Part.java index 175812591..fe1f258b5 100644 --- a/src/com/android/email/mail/Part.java +++ b/src/com/android/email/mail/Part.java @@ -36,6 +36,10 @@ public interface Part { public String[] getHeader(String name) throws MessagingException; + public void setExtendedHeader(String name, String value) throws MessagingException; + + public String getExtendedHeader(String name) throws MessagingException; + public int getSize() throws MessagingException; public boolean isMimeType(String mimeType) throws MessagingException; diff --git a/src/com/android/email/mail/internet/MimeBodyPart.java b/src/com/android/email/mail/internet/MimeBodyPart.java index 49b729f5d..f40e2f5e9 100644 --- a/src/com/android/email/mail/internet/MimeBodyPart.java +++ b/src/com/android/email/mail/internet/MimeBodyPart.java @@ -32,11 +32,14 @@ import com.android.email.mail.MessagingException; */ public class MimeBodyPart extends BodyPart { protected MimeHeader mHeader = new MimeHeader(); + protected MimeHeader mExtendedHeader; protected Body mBody; protected int mSize; // regex that matches content id surrounded by "<>" optionally. private static final Pattern REMOVE_OPTIONAL_BRACKETS = Pattern.compile("^]+)>?$"); + // regex that matches end of line. + private static final Pattern END_OF_LINE = Pattern.compile("\r?\n"); public MimeBodyPart() throws MessagingException { this(null); @@ -135,6 +138,41 @@ public class MimeBodyPart extends BodyPart { return mSize; } + /** + * Set extended header + * + * @param name Extended header name + * @param value header value - flattened by removing CR-NL if any + * remove header if value is null + * @throws MessagingException + */ + public void setExtendedHeader(String name, String value) throws MessagingException { + if (value == null) { + if (mExtendedHeader != null) { + mExtendedHeader.removeHeader(name); + } + return; + } + if (mExtendedHeader == null) { + mExtendedHeader = new MimeHeader(); + } + mExtendedHeader.setHeader(name, END_OF_LINE.matcher(value).replaceAll("")); + } + + /** + * Get extended header + * + * @param name Extended header name + * @return header value - null if header does not exist + * @throws MessagingException + */ + public String getExtendedHeader(String name) throws MessagingException { + if (mExtendedHeader == null) { + return null; + } + return mExtendedHeader.getFirstHeader(name); + } + /** * Write the MimeMessage out in MIME format. */ diff --git a/src/com/android/email/mail/internet/MimeHeader.java b/src/com/android/email/mail/internet/MimeHeader.java index 8a53ece34..2f58e0e7a 100644 --- a/src/com/android/email/mail/internet/MimeHeader.java +++ b/src/com/android/email/mail/internet/MimeHeader.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.util.ArrayList; +import java.util.regex.Pattern; import com.android.email.Utility; import com.android.email.mail.MessagingException; @@ -98,6 +99,25 @@ public class MimeHeader { mFields.removeAll(removeFields); } + /** + * Write header into String + * + * @return CR-NL separated header string except the headers in writeOmitFields + * null if header is empty + */ + public String writeToString() { + if (mFields.size() == 0) { + return null; + } + StringBuilder builder = new StringBuilder(); + for (Field field : mFields) { + if (!Utility.arrayContains(writeOmitFields, field.name)) { + builder.append(field.name + ": " + field.value + "\r\n"); + } + } + return builder.toString(); + } + public void writeTo(OutputStream out) throws IOException, MessagingException { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); for (Field field : mFields) { diff --git a/src/com/android/email/mail/internet/MimeMessage.java b/src/com/android/email/mail/internet/MimeMessage.java index d72ed5707..67a2313fe 100644 --- a/src/com/android/email/mail/internet/MimeMessage.java +++ b/src/com/android/email/mail/internet/MimeMessage.java @@ -30,7 +30,10 @@ import org.apache.james.mime4j.MimeStreamParser; import org.apache.james.mime4j.field.DateTimeField; import org.apache.james.mime4j.field.Field; +import android.text.TextUtils; + import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -47,6 +50,7 @@ import java.util.regex.Pattern; */ public class MimeMessage extends Message { protected MimeHeader mHeader = new MimeHeader(); + protected MimeHeader mExtendedHeader; // 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 @@ -70,6 +74,8 @@ public class MimeMessage extends Message { // regex that matches content id surrounded by "<>" optionally. private static final Pattern REMOVE_OPTIONAL_BRACKETS = Pattern.compile("^]+)>?$"); + // regex that matches end of line. + private static final Pattern END_OF_LINE = Pattern.compile("\r?\n"); public MimeMessage() { /* @@ -361,9 +367,85 @@ public class MimeMessage extends Message { mHeader.removeHeader(name); } + /** + * Set extended header + * + * @param name Extended header name + * @param value header value - flattened by removing CR-NL if any + * remove header if value is null + * @throws MessagingException + */ + public void setExtendedHeader(String name, String value) throws MessagingException { + if (value == null) { + if (mExtendedHeader != null) { + mExtendedHeader.removeHeader(name); + } + return; + } + if (mExtendedHeader == null) { + mExtendedHeader = new MimeHeader(); + } + mExtendedHeader.setHeader(name, END_OF_LINE.matcher(value).replaceAll("")); + } + + /** + * Get extended header + * + * @param name Extended header name + * @return header value - null if header does not exist + * @throws MessagingException + */ + public String getExtendedHeader(String name) throws MessagingException { + if (mExtendedHeader == null) { + return null; + } + return mExtendedHeader.getFirstHeader(name); + } + + /** + * Set entire extended headers from String + * + * @param headers Extended header and its value - "CR-NL-separated pairs + * if null or empty, remove entire extended headers + * @throws MessagingException + */ + public void setExtendedHeaders(String headers) throws MessagingException { + if (TextUtils.isEmpty(headers)) { + mExtendedHeader = null; + } else { + mExtendedHeader = new MimeHeader(); + for (String header : END_OF_LINE.split(headers)) { + String[] tokens = header.split(":", 2); + if (tokens.length != 2) { + throw new MessagingException("Illegal extended headers: " + headers); + } + mExtendedHeader.setHeader(tokens[0].trim(), tokens[1].trim()); + } + } + } + + /** + * Get entire extended headers as String + * + * @return "CR-NL-separated extended headers - null if extended header does not exist + */ + public String getExtendedHeaders() { + if (mExtendedHeader != null) { + return mExtendedHeader.writeToString(); + } + return null; + } + + /** + * Write message header and body to output stream + * + * @param out Output steam to write message header and body. + */ public void writeTo(OutputStream out) throws IOException, MessagingException { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); mHeader.writeTo(out); + // mExtendedHeader will not be write out to external output stream, + // because it is intended to internal use. writer.write("\r\n"); writer.flush(); if (mBody != null) { diff --git a/src/com/android/email/mail/store/LocalStore.java b/src/com/android/email/mail/store/LocalStore.java index 99facba04..71298cb2e 100644 --- a/src/com/android/email/mail/store/LocalStore.java +++ b/src/com/android/email/mail/store/LocalStore.java @@ -82,9 +82,10 @@ public class LocalStore extends Store implements PersistentDataCallbacks { * 22 - Added store_flag_1 and store_flag_2 columns to messages table. * 23 - Added flag_downloaded_full, flag_downloaded_partial, flag_deleted * columns to message table. + * 24 - Added x_headers to messages table. */ - private static final int DB_VERSION = 23; + private static final int DB_VERSION = 24; private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.X_DESTROYED, Flag.SEEN }; @@ -148,7 +149,7 @@ public class LocalStore extends Store implements PersistentDataCallbacks { "html_content TEXT, text_content TEXT, attachment_count INTEGER, " + "internal_date INTEGER, message_id TEXT, store_flag_1 INTEGER, " + "store_flag_2 INTEGER, flag_downloaded_full INTEGER," + - "flag_downloaded_partial INTEGER, flag_deleted INTEGER)"); + "flag_downloaded_partial INTEGER, flag_deleted INTEGER, x_headers TEXT)"); mDb.execSQL("DROP TABLE IF EXISTS attachments"); mDb.execSQL("CREATE TABLE attachments (id INTEGER PRIMARY KEY, message_id INTEGER," @@ -218,6 +219,13 @@ public class LocalStore extends Store implements PersistentDataCallbacks { mDb.endTransaction(); } } + if (oldVersion < 24) { + /** + * Upgrade 23 to 24: add x_headers to messages table + */ + mDb.execSQL("ALTER TABLE messages ADD COLUMN x_headers TEXT;"); + mDb.setVersion(24); + } } if (mDb.getVersion() != DB_VERSION) { @@ -845,7 +853,7 @@ public class LocalStore extends Store implements PersistentDataCallbacks { "subject, sender_list, date, uid, flags, id, to_list, cc_list, " + "bcc_list, reply_to_list, attachment_count, internal_date, message_id, " + "store_flag_1, store_flag_2, flag_downloaded_full, flag_downloaded_partial, " + - "flag_deleted"; + "flag_deleted, x_headers"; /** * Populate a message from a cursor with the following columns: @@ -868,6 +876,7 @@ public class LocalStore extends Store implements PersistentDataCallbacks { * 15 flag "downloaded full" * 16 flag "downloaded partial" * 17 flag "deleted" + * 18 extended headers ("\r\n"-separated string) */ private void populateMessageFromGetMessageCursor(LocalMessage message, Cursor cursor) throws MessagingException{ @@ -901,6 +910,7 @@ public class LocalStore extends Store implements PersistentDataCallbacks { message.setFlagInternal(Flag.X_DOWNLOADED_FULL, (0 != cursor.getInt(15))); message.setFlagInternal(Flag.X_DOWNLOADED_PARTIAL, (0 != cursor.getInt(16))); message.setFlagInternal(Flag.DELETED, (0 != cursor.getInt(17))); + message.setExtendedHeaders(cursor.getString(18)); } @Override @@ -1155,6 +1165,7 @@ public class LocalStore extends Store implements PersistentDataCallbacks { cv.put("flag_downloaded_partial", makeFlagNumeric(message, Flag.X_DOWNLOADED_PARTIAL)); cv.put("flag_deleted", makeFlagNumeric(message, Flag.DELETED)); + cv.put("x_headers", ((MimeMessage) message).getExtendedHeaders()); long messageId = mDb.insert("messages", "uid", cv); for (Part attachment : attachments) { saveAttachment(messageId, attachment, copy); @@ -1209,7 +1220,7 @@ public class LocalStore extends Store implements PersistentDataCallbacks { + "html_content = ?, text_content = ?, reply_to_list = ?, " + "attachment_count = ?, message_id = ?, store_flag_1 = ?, " + "store_flag_2 = ?, flag_downloaded_full = ?, " - + "flag_downloaded_partial = ?, flag_deleted = ? " + + "flag_downloaded_partial = ?, flag_deleted = ?, x_headers = ? " + "WHERE id = ?", new Object[] { message.getUid(), @@ -1236,6 +1247,7 @@ public class LocalStore extends Store implements PersistentDataCallbacks { makeFlagNumeric(message, Flag.X_DOWNLOADED_FULL), makeFlagNumeric(message, Flag.X_DOWNLOADED_PARTIAL), makeFlagNumeric(message, Flag.DELETED), + message.getExtendedHeaders(), message.mId }); diff --git a/tests/src/com/android/email/mail/internet/MimeHeaderUnitTests.java b/tests/src/com/android/email/mail/internet/MimeHeaderUnitTests.java new file mode 100644 index 000000000..5d126dac5 --- /dev/null +++ b/tests/src/com/android/email/mail/internet/MimeHeaderUnitTests.java @@ -0,0 +1,61 @@ +/* + * 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 android.test.suitebuilder.annotation.SmallTest; + +import junit.framework.TestCase; + +/** + * This is a series of unit tests for the MimeHeader class. These tests must be locally + * complete - no server(s) required. + */ +@SmallTest +public class MimeHeaderUnitTests extends TestCase { + + // TODO more test + + /** + * Test for writeToString() + */ + public void testWriteToString() throws Exception { + MimeHeader header = new MimeHeader(); + + // empty header + String actual1 = header.writeToString(); + assertEquals("empty header", actual1, null); + + // single header + header.setHeader("Header1", "value1"); + String actual2 = header.writeToString(); + assertEquals("single header", actual2, "Header1: value1\r\n"); + + // multiple headers + header.setHeader("Header2", "value2"); + String actual3 = header.writeToString(); + assertEquals("multiple headers", actual3, + "Header1: value1\r\n" + + "Header2: value2\r\n"); + + // omit header + header.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, "value3"); + String actual4 = header.writeToString(); + assertEquals("multiple headers", actual4, + "Header1: value1\r\n" + + "Header2: value2\r\n"); + } +} diff --git a/tests/src/com/android/email/mail/internet/MimeMessageTest.java b/tests/src/com/android/email/mail/internet/MimeMessageTest.java index b29d63353..24474b38a 100644 --- a/tests/src/com/android/email/mail/internet/MimeMessageTest.java +++ b/tests/src/com/android/email/mail/internet/MimeMessageTest.java @@ -19,12 +19,11 @@ package com.android.email.mail.internet; import com.android.email.mail.Address; import com.android.email.mail.Flag; import com.android.email.mail.MessagingException; -import com.android.email.mail.internet.MimeHeader; -import com.android.email.mail.internet.MimeMessage; import com.android.email.mail.Message.RecipientType; import android.test.suitebuilder.annotation.SmallTest; +import java.io.ByteArrayOutputStream; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; @@ -334,4 +333,90 @@ public class MimeMessageTest extends TestCase { } + /* + * Test for setExtendedHeader() and getExtendedHeader() + */ + public void testExtendedHeader() throws MessagingException { + MimeMessage message = new MimeMessage(); + + assertNull("non existent header", message.getExtendedHeader("X-Non-Existent")); + + message.setExtendedHeader("X-Header1", "value1"); + message.setExtendedHeader("X-Header2", "value2\n value3\r\n value4\r\n"); + assertEquals("simple value", "value1", + message.getExtendedHeader("X-Header1")); + assertEquals("multi line value", "value2 value3 value4", + message.getExtendedHeader("X-Header2")); + assertNull("non existent header 2", message.getExtendedHeader("X-Non-Existent")); + + message.setExtendedHeader("X-Header1", "value4"); + assertEquals("over written value", "value4", message.getExtendedHeader("X-Header1")); + + message.setExtendedHeader("X-Header1", null); + assertNull("remove header", message.getExtendedHeader("X-Header1")); + } + + /* + * Test for setExtendedHeaders() and getExtendedheaders() + */ + public void testExtendedHeaders() throws MessagingException { + MimeMessage message = new MimeMessage(); + + assertNull("new message", message.getExtendedHeaders()); + message.setExtendedHeaders(null); + assertNull("null headers", message.getExtendedHeaders()); + message.setExtendedHeaders(""); + assertNull("empty headers", message.getExtendedHeaders()); + + message.setExtendedHeaders("X-Header1: value1\r\n"); + assertEquals("header 1 value", "value1", message.getExtendedHeader("X-Header1")); + assertEquals("header 1", "X-Header1: value1\r\n", message.getExtendedHeaders()); + + message.setExtendedHeaders(null); + message.setExtendedHeader("X-Header2", "value2"); + message.setExtendedHeader("X-Header3", "value3\n value4\r\n value5\r\n"); + assertEquals("headers 2,3", + "X-Header2: value2\r\n" + + "X-Header3: value3 value4 value5\r\n", + message.getExtendedHeaders()); + + message.setExtendedHeaders( + "X-Header3: value3 value4 value5\r\n" + + "X-Header2: value2\r\n"); + assertEquals("header 2", "value2", message.getExtendedHeader("X-Header2")); + assertEquals("header 3", "value3 value4 value5", message.getExtendedHeader("X-Header3")); + assertEquals("headers 3,2", + "X-Header3: value3 value4 value5\r\n" + + "X-Header2: value2\r\n", + message.getExtendedHeaders()); + } + + /* + * Test for writeTo(), only for header part. + */ + public void testWriteToHeader() throws Exception { + MimeMessage message = new MimeMessage(); + + message.setHeader("Header1", "value1"); + message.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, "value2"); + message.setExtendedHeader("X-Header3", "value3"); + message.setHeader("Header4", "value4"); + message.setExtendedHeader("X-Header5", "value5"); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + message.writeTo(out); + out.close(); + String expectedString = "Message-ID: " + message.getMessageId() + "\r\n" + + "Header1: value1\r\n" + + "Header4: value4\r\n" + + "\r\n"; + byte[] expected = expectedString.getBytes(); + byte[] actual = out.toByteArray(); + assertEquals("output length", expected.length, actual.length); + for (int i = 0; i < actual.length; ++i) { + assertEquals("output byte["+i+"]", expected[i], actual[i]); + } + } + + // TODO more test for writeTo() } diff --git a/tests/src/com/android/email/mail/store/LocalStoreUnitTests.java b/tests/src/com/android/email/mail/store/LocalStoreUnitTests.java index 2da970413..2dc1b9a5c 100644 --- a/tests/src/com/android/email/mail/store/LocalStoreUnitTests.java +++ b/tests/src/com/android/email/mail/store/LocalStoreUnitTests.java @@ -35,6 +35,7 @@ import com.android.email.mail.internet.BinaryTempFileBody; import com.android.email.mail.internet.MimeMessage; import com.android.email.mail.internet.MimeUtility; import com.android.email.mail.internet.TextBody; +import com.android.email.mail.store.LocalStore.LocalMessage; import android.content.ContentValues; import android.database.Cursor; @@ -64,7 +65,7 @@ public class LocalStoreUnitTests extends AndroidTestCase { private static final String MESSAGE_ID = "Test-Message-ID"; private static final String MESSAGE_ID_2 = "Test-Message-ID-Second"; - private static final int DATABASE_VERSION = 23; + private static final int DATABASE_VERSION = 24; private static final String FOLDER_NAME = "TEST"; private static final String MISSING_FOLDER_NAME = "TEST-NO-FOLDER"; @@ -99,6 +100,11 @@ public class LocalStoreUnitTests extends AndroidTestCase { */ @Override protected void tearDown() throws Exception { + super.tearDown(); + if (mFolder != null) { + mFolder.close(false); + } + // First, try the official way if (mStore != null) { mStore.delete(); @@ -873,6 +879,28 @@ public class LocalStoreUnitTests extends AndroidTestCase { } } + /** + * Test for setExtendedHeader() and getExtendedHeader() + */ + public void testExtendedHeader() throws MessagingException { + MimeMessage message = new MimeMessage(); + message.setUid("message1"); + mFolder.appendMessages(new Message[] { message }); + + message.setUid("message2"); + message.setExtendedHeader("X-Header1", "value1"); + message.setExtendedHeader("X-Header2", "value2\r\n value3\n value4\r\n"); + mFolder.appendMessages(new Message[] { message }); + + LocalMessage message1 = (LocalMessage) mFolder.getMessage("message1"); + assertNull("none existent header", message1.getExtendedHeader("X-None-Existent")); + + LocalMessage message2 = (LocalMessage) mFolder.getMessage("message2"); + assertEquals("header 1", "value1", message2.getExtendedHeader("X-Header1")); + assertEquals("header 2", "value2 value3 value4", message2.getExtendedHeader("X-Header2")); + assertNull("header 3", message2.getExtendedHeader("X-Header3")); + } + /** * Tests for database version. */ @@ -978,7 +1006,7 @@ public class LocalStoreUnitTests extends AndroidTestCase { // 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", + assertEquals("messages table cursor does not have expected values", expectedMessage, actualMessage); c.close(); @@ -1187,6 +1215,57 @@ public class LocalStoreUnitTests extends AndroidTestCase { } /** + * Tests for database upgrade from version 23 to current version. + */ + public void testDbUpgrade23ToLatest() throws MessagingException, URISyntaxException { + final URI uri = new URI(mLocalStoreUri); + final String dbPath = uri.getPath(); + SQLiteDatabase db = SQLiteDatabase.openOrCreateDatabase(dbPath, null); + + // create sample version 23 db tables + createSampleDb(db, 23); + + // 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)); + + db.close(); + + // upgrade database 23 to latest + LocalStore.newInstance(mLocalStoreUri, getContext(), null); + + // added message_id column should be initialized as null + expectedMessage.put("message_id", (String) null); // message_id type text == String + + // database should be upgraded + db = SQLiteDatabase.openOrCreateDatabase(dbPath, null); + assertEquals("database should be upgraded", DATABASE_VERSION, db.getVersion()); + Cursor c; + + // check for all "latest version" tables + checkAllTablesFound(db); + + // 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(); + + db.close(); + } + + /** * Checks the database to confirm that all tables, with all expected columns are found. */ private void checkAllTablesFound(SQLiteDatabase db) { @@ -1204,7 +1283,7 @@ public class LocalStoreUnitTests extends AndroidTestCase { "to_list", "cc_list", "bcc_list", "reply_to_list", "html_content", "text_content", "attachment_count", "internal_date", "store_flag_1", "store_flag_2", "flag_downloaded_full", - "flag_downloaded_partial", "flag_deleted" } + "flag_downloaded_partial", "flag_deleted", "x_headers" } )); assertTrue("messages", foundNames.containsAll(expectedNames)); @@ -1243,6 +1322,7 @@ public class LocalStoreUnitTests extends AndroidTestCase { ((version >= 23) ? ", flag_downloaded_full INTEGER, flag_downloaded_partial INTEGER" : "") + ((version >= 23) ? ", flag_deleted INTEGER" : "") + + ((version >= 24) ? ", x_headers TEXT" : "") + ")"); db.execSQL("DROP TABLE IF EXISTS attachments"); db.execSQL("CREATE TABLE attachments (id INTEGER PRIMARY KEY, message_id INTEGER," +