diff --git a/res/values/strings.xml b/res/values/strings.xml index d2866406a..2d370b6a6 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -349,6 +349,10 @@ > + + + %1$s - %2$s + Set up email diff --git a/src/com/android/email/LegacyConversions.java b/src/com/android/email/LegacyConversions.java index 8979af115..7d7e884bd 100644 --- a/src/com/android/email/LegacyConversions.java +++ b/src/com/android/email/LegacyConversions.java @@ -45,6 +45,7 @@ import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.provider.OpenableColumns; +import android.text.TextUtils; import android.util.Log; import java.io.File; @@ -217,11 +218,18 @@ public class LegacyConversions { } // write the combined data to the body part - if (sbText != null && sbText.length() != 0) { - body.mTextContent = sbText.toString(); + if (!TextUtils.isEmpty(sbText)) { + String text = sbText.toString(); + body.mTextContent = text; + localMessage.mSnippet = Snippet.fromPlainText(text); } - if (sbHtml != null && sbHtml.length() != 0) { + if (!TextUtils.isEmpty(sbHtml)) { + String text = sbHtml.toString(); + body.mHtmlContent = text; body.mHtmlContent = sbHtml.toString(); + if (localMessage.mSnippet == null) { + localMessage.mSnippet = Snippet.fromHtmlText(text); + } } if (sbHtmlReply != null && sbHtmlReply.length() != 0) { body.mHtmlReply = sbHtmlReply.toString(); diff --git a/src/com/android/email/Snippet.java b/src/com/android/email/Snippet.java new file mode 100644 index 000000000..95285c3ca --- /dev/null +++ b/src/com/android/email/Snippet.java @@ -0,0 +1,434 @@ +/* + * Copyright (C) 2010 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; + +import android.text.TextUtils; + +import java.util.HashMap; +import java.util.Map; + +/** + * Class to generate a short 'snippet' from either plain text or html text + * + * If the sync protocol can get plain text, that's great, but we'll still strip out extraneous + * whitespace. If it's HTML, we'll 1) strip out tags, 2) turn entities into the appropriate + * characters, and 3) strip out extraneous whitespace, all in one pass + * + * Why not use an existing class? The best answer is performance; yet another answer is + * correctness (e.g. Html.textFromHtml simply doesn't generate well-stripped text). But performance + * is key; we frequently sync text that is 10K or (much) longer, yet we really only care about a + * small amount of text for the snippet. So it's critically important that we just stop when we've + * gotten enough; existing methods that exist will go through the entire incoming string, at great + * (and useless) expense. + */ +public class Snippet { + // This is how many chars we'll allow in a snippet + private static final int MAX_PLAIN_TEXT_SCAN_LENGTH = 200; + // For some reason, isWhitespace() returns false with the following... + /*package*/ static final char NON_BREAKING_SPACE_CHARACTER = (char)160; + + // Note: ESCAPE_STRINGS is taken from the StringUtil class which is part of the + // unbundled_google package + static final Map ESCAPE_STRINGS; + static { + // HTML character entity references as defined in HTML 4 + // see http://www.w3.org/TR/REC-html40/sgml/entities.html + ESCAPE_STRINGS = new HashMap(252); + + ESCAPE_STRINGS.put(" ", '\u00A0'); + ESCAPE_STRINGS.put("¡", '\u00A1'); + ESCAPE_STRINGS.put("¢", '\u00A2'); + ESCAPE_STRINGS.put("£", '\u00A3'); + ESCAPE_STRINGS.put("¤", '\u00A4'); + ESCAPE_STRINGS.put("¥", '\u00A5'); + ESCAPE_STRINGS.put("¦", '\u00A6'); + ESCAPE_STRINGS.put("§", '\u00A7'); + ESCAPE_STRINGS.put("¨", '\u00A8'); + ESCAPE_STRINGS.put("©", '\u00A9'); + ESCAPE_STRINGS.put("ª", '\u00AA'); + ESCAPE_STRINGS.put("«", '\u00AB'); + ESCAPE_STRINGS.put("¬", '\u00AC'); + ESCAPE_STRINGS.put("­", '\u00AD'); + ESCAPE_STRINGS.put("®", '\u00AE'); + ESCAPE_STRINGS.put("¯", '\u00AF'); + ESCAPE_STRINGS.put("°", '\u00B0'); + ESCAPE_STRINGS.put("±", '\u00B1'); + ESCAPE_STRINGS.put("²", '\u00B2'); + ESCAPE_STRINGS.put("³", '\u00B3'); + ESCAPE_STRINGS.put("´", '\u00B4'); + ESCAPE_STRINGS.put("µ", '\u00B5'); + ESCAPE_STRINGS.put("¶", '\u00B6'); + ESCAPE_STRINGS.put("·", '\u00B7'); + ESCAPE_STRINGS.put("¸", '\u00B8'); + ESCAPE_STRINGS.put("¹", '\u00B9'); + ESCAPE_STRINGS.put("º", '\u00BA'); + ESCAPE_STRINGS.put("»", '\u00BB'); + ESCAPE_STRINGS.put("¼", '\u00BC'); + ESCAPE_STRINGS.put("½", '\u00BD'); + ESCAPE_STRINGS.put("¾", '\u00BE'); + ESCAPE_STRINGS.put("¿", '\u00BF'); + ESCAPE_STRINGS.put("À", '\u00C0'); + ESCAPE_STRINGS.put("Á", '\u00C1'); + ESCAPE_STRINGS.put("Â", '\u00C2'); + ESCAPE_STRINGS.put("Ã", '\u00C3'); + ESCAPE_STRINGS.put("Ä", '\u00C4'); + ESCAPE_STRINGS.put("Å", '\u00C5'); + ESCAPE_STRINGS.put("Æ", '\u00C6'); + ESCAPE_STRINGS.put("Ç", '\u00C7'); + ESCAPE_STRINGS.put("È", '\u00C8'); + ESCAPE_STRINGS.put("É", '\u00C9'); + ESCAPE_STRINGS.put("Ê", '\u00CA'); + ESCAPE_STRINGS.put("Ë", '\u00CB'); + ESCAPE_STRINGS.put("Ì", '\u00CC'); + ESCAPE_STRINGS.put("Í", '\u00CD'); + ESCAPE_STRINGS.put("Î", '\u00CE'); + ESCAPE_STRINGS.put("Ï", '\u00CF'); + ESCAPE_STRINGS.put("Ð", '\u00D0'); + ESCAPE_STRINGS.put("Ñ", '\u00D1'); + ESCAPE_STRINGS.put("Ò", '\u00D2'); + ESCAPE_STRINGS.put("Ó", '\u00D3'); + ESCAPE_STRINGS.put("Ô", '\u00D4'); + ESCAPE_STRINGS.put("Õ", '\u00D5'); + ESCAPE_STRINGS.put("Ö", '\u00D6'); + ESCAPE_STRINGS.put("×", '\u00D7'); + ESCAPE_STRINGS.put("Ø", '\u00D8'); + ESCAPE_STRINGS.put("Ù", '\u00D9'); + ESCAPE_STRINGS.put("Ú", '\u00DA'); + ESCAPE_STRINGS.put("Û", '\u00DB'); + ESCAPE_STRINGS.put("Ü", '\u00DC'); + ESCAPE_STRINGS.put("Ý", '\u00DD'); + ESCAPE_STRINGS.put("Þ", '\u00DE'); + ESCAPE_STRINGS.put("ß", '\u00DF'); + ESCAPE_STRINGS.put("à", '\u00E0'); + ESCAPE_STRINGS.put("á", '\u00E1'); + ESCAPE_STRINGS.put("â", '\u00E2'); + ESCAPE_STRINGS.put("ã", '\u00E3'); + ESCAPE_STRINGS.put("ä", '\u00E4'); + ESCAPE_STRINGS.put("å", '\u00E5'); + ESCAPE_STRINGS.put("æ", '\u00E6'); + ESCAPE_STRINGS.put("ç", '\u00E7'); + ESCAPE_STRINGS.put("è", '\u00E8'); + ESCAPE_STRINGS.put("é", '\u00E9'); + ESCAPE_STRINGS.put("ê", '\u00EA'); + ESCAPE_STRINGS.put("ë", '\u00EB'); + ESCAPE_STRINGS.put("ì", '\u00EC'); + ESCAPE_STRINGS.put("í", '\u00ED'); + ESCAPE_STRINGS.put("î", '\u00EE'); + ESCAPE_STRINGS.put("ï", '\u00EF'); + ESCAPE_STRINGS.put("ð", '\u00F0'); + ESCAPE_STRINGS.put("ñ", '\u00F1'); + ESCAPE_STRINGS.put("ò", '\u00F2'); + ESCAPE_STRINGS.put("ó", '\u00F3'); + ESCAPE_STRINGS.put("ô", '\u00F4'); + ESCAPE_STRINGS.put("õ", '\u00F5'); + ESCAPE_STRINGS.put("ö", '\u00F6'); + ESCAPE_STRINGS.put("÷", '\u00F7'); + ESCAPE_STRINGS.put("ø", '\u00F8'); + ESCAPE_STRINGS.put("ù", '\u00F9'); + ESCAPE_STRINGS.put("ú", '\u00FA'); + ESCAPE_STRINGS.put("û", '\u00FB'); + ESCAPE_STRINGS.put("ü", '\u00FC'); + ESCAPE_STRINGS.put("ý", '\u00FD'); + ESCAPE_STRINGS.put("þ", '\u00FE'); + ESCAPE_STRINGS.put("ÿ", '\u00FF'); + ESCAPE_STRINGS.put("&fnof", '\u0192'); + ESCAPE_STRINGS.put("&Alpha", '\u0391'); + ESCAPE_STRINGS.put("&Beta", '\u0392'); + ESCAPE_STRINGS.put("&Gamma", '\u0393'); + ESCAPE_STRINGS.put("&Delta", '\u0394'); + ESCAPE_STRINGS.put("&Epsilon", '\u0395'); + ESCAPE_STRINGS.put("&Zeta", '\u0396'); + ESCAPE_STRINGS.put("&Eta", '\u0397'); + ESCAPE_STRINGS.put("&Theta", '\u0398'); + ESCAPE_STRINGS.put("&Iota", '\u0399'); + ESCAPE_STRINGS.put("&Kappa", '\u039A'); + ESCAPE_STRINGS.put("&Lambda", '\u039B'); + ESCAPE_STRINGS.put("&Mu", '\u039C'); + ESCAPE_STRINGS.put("&Nu", '\u039D'); + ESCAPE_STRINGS.put("&Xi", '\u039E'); + ESCAPE_STRINGS.put("&Omicron", '\u039F'); + ESCAPE_STRINGS.put("&Pi", '\u03A0'); + ESCAPE_STRINGS.put("&Rho", '\u03A1'); + ESCAPE_STRINGS.put("&Sigma", '\u03A3'); + ESCAPE_STRINGS.put("&Tau", '\u03A4'); + ESCAPE_STRINGS.put("&Upsilon", '\u03A5'); + ESCAPE_STRINGS.put("&Phi", '\u03A6'); + ESCAPE_STRINGS.put("&Chi", '\u03A7'); + ESCAPE_STRINGS.put("&Psi", '\u03A8'); + ESCAPE_STRINGS.put("&Omega", '\u03A9'); + ESCAPE_STRINGS.put("&alpha", '\u03B1'); + ESCAPE_STRINGS.put("&beta", '\u03B2'); + ESCAPE_STRINGS.put("&gamma", '\u03B3'); + ESCAPE_STRINGS.put("&delta", '\u03B4'); + ESCAPE_STRINGS.put("&epsilon", '\u03B5'); + ESCAPE_STRINGS.put("&zeta", '\u03B6'); + ESCAPE_STRINGS.put("&eta", '\u03B7'); + ESCAPE_STRINGS.put("&theta", '\u03B8'); + ESCAPE_STRINGS.put("&iota", '\u03B9'); + ESCAPE_STRINGS.put("&kappa", '\u03BA'); + ESCAPE_STRINGS.put("&lambda", '\u03BB'); + ESCAPE_STRINGS.put("&mu", '\u03BC'); + ESCAPE_STRINGS.put("&nu", '\u03BD'); + ESCAPE_STRINGS.put("&xi", '\u03BE'); + ESCAPE_STRINGS.put("&omicron", '\u03BF'); + ESCAPE_STRINGS.put("&pi", '\u03C0'); + ESCAPE_STRINGS.put("&rho", '\u03C1'); + ESCAPE_STRINGS.put("&sigmaf", '\u03C2'); + ESCAPE_STRINGS.put("&sigma", '\u03C3'); + ESCAPE_STRINGS.put("&tau", '\u03C4'); + ESCAPE_STRINGS.put("&upsilon", '\u03C5'); + ESCAPE_STRINGS.put("&phi", '\u03C6'); + ESCAPE_STRINGS.put("&chi", '\u03C7'); + ESCAPE_STRINGS.put("&psi", '\u03C8'); + ESCAPE_STRINGS.put("&omega", '\u03C9'); + ESCAPE_STRINGS.put("&thetasym", '\u03D1'); + ESCAPE_STRINGS.put("&upsih", '\u03D2'); + ESCAPE_STRINGS.put("&piv", '\u03D6'); + ESCAPE_STRINGS.put("&bull", '\u2022'); + ESCAPE_STRINGS.put("&hellip", '\u2026'); + ESCAPE_STRINGS.put("&prime", '\u2032'); + ESCAPE_STRINGS.put("&Prime", '\u2033'); + ESCAPE_STRINGS.put("&oline", '\u203E'); + ESCAPE_STRINGS.put("&frasl", '\u2044'); + ESCAPE_STRINGS.put("&weierp", '\u2118'); + ESCAPE_STRINGS.put("&image", '\u2111'); + ESCAPE_STRINGS.put("&real", '\u211C'); + ESCAPE_STRINGS.put("&trade", '\u2122'); + ESCAPE_STRINGS.put("&alefsym", '\u2135'); + ESCAPE_STRINGS.put("&larr", '\u2190'); + ESCAPE_STRINGS.put("&uarr", '\u2191'); + ESCAPE_STRINGS.put("&rarr", '\u2192'); + ESCAPE_STRINGS.put("&darr", '\u2193'); + ESCAPE_STRINGS.put("&harr", '\u2194'); + ESCAPE_STRINGS.put("&crarr", '\u21B5'); + ESCAPE_STRINGS.put("&lArr", '\u21D0'); + ESCAPE_STRINGS.put("&uArr", '\u21D1'); + ESCAPE_STRINGS.put("&rArr", '\u21D2'); + ESCAPE_STRINGS.put("&dArr", '\u21D3'); + ESCAPE_STRINGS.put("&hArr", '\u21D4'); + ESCAPE_STRINGS.put("&forall", '\u2200'); + ESCAPE_STRINGS.put("&part", '\u2202'); + ESCAPE_STRINGS.put("&exist", '\u2203'); + ESCAPE_STRINGS.put("&empty", '\u2205'); + ESCAPE_STRINGS.put("&nabla", '\u2207'); + ESCAPE_STRINGS.put("&isin", '\u2208'); + ESCAPE_STRINGS.put("¬in", '\u2209'); + ESCAPE_STRINGS.put("&ni", '\u220B'); + ESCAPE_STRINGS.put("&prod", '\u220F'); + ESCAPE_STRINGS.put("&sum", '\u2211'); + ESCAPE_STRINGS.put("&minus", '\u2212'); + ESCAPE_STRINGS.put("&lowast", '\u2217'); + ESCAPE_STRINGS.put("&radic", '\u221A'); + ESCAPE_STRINGS.put("&prop", '\u221D'); + ESCAPE_STRINGS.put("&infin", '\u221E'); + ESCAPE_STRINGS.put("&ang", '\u2220'); + ESCAPE_STRINGS.put("&and", '\u2227'); + ESCAPE_STRINGS.put("&or", '\u2228'); + ESCAPE_STRINGS.put("&cap", '\u2229'); + ESCAPE_STRINGS.put("&cup", '\u222A'); + ESCAPE_STRINGS.put("&int", '\u222B'); + ESCAPE_STRINGS.put("&there4", '\u2234'); + ESCAPE_STRINGS.put("&sim", '\u223C'); + ESCAPE_STRINGS.put("&cong", '\u2245'); + ESCAPE_STRINGS.put("&asymp", '\u2248'); + ESCAPE_STRINGS.put("&ne", '\u2260'); + ESCAPE_STRINGS.put("&equiv", '\u2261'); + ESCAPE_STRINGS.put("&le", '\u2264'); + ESCAPE_STRINGS.put("&ge", '\u2265'); + ESCAPE_STRINGS.put("&sub", '\u2282'); + ESCAPE_STRINGS.put("&sup", '\u2283'); + ESCAPE_STRINGS.put("&nsub", '\u2284'); + ESCAPE_STRINGS.put("&sube", '\u2286'); + ESCAPE_STRINGS.put("&supe", '\u2287'); + ESCAPE_STRINGS.put("&oplus", '\u2295'); + ESCAPE_STRINGS.put("&otimes", '\u2297'); + ESCAPE_STRINGS.put("&perp", '\u22A5'); + ESCAPE_STRINGS.put("&sdot", '\u22C5'); + ESCAPE_STRINGS.put("&lceil", '\u2308'); + ESCAPE_STRINGS.put("&rceil", '\u2309'); + ESCAPE_STRINGS.put("&lfloor", '\u230A'); + ESCAPE_STRINGS.put("&rfloor", '\u230B'); + ESCAPE_STRINGS.put("&lang", '\u2329'); + ESCAPE_STRINGS.put("&rang", '\u232A'); + ESCAPE_STRINGS.put("&loz", '\u25CA'); + ESCAPE_STRINGS.put("&spades", '\u2660'); + ESCAPE_STRINGS.put("&clubs", '\u2663'); + ESCAPE_STRINGS.put("&hearts", '\u2665'); + ESCAPE_STRINGS.put("&diams", '\u2666'); + ESCAPE_STRINGS.put(""", '\u0022'); + ESCAPE_STRINGS.put("&", '\u0026'); + ESCAPE_STRINGS.put("<", '\u003C'); + ESCAPE_STRINGS.put(">", '\u003E'); + ESCAPE_STRINGS.put("&OElig", '\u0152'); + ESCAPE_STRINGS.put("&oelig", '\u0153'); + ESCAPE_STRINGS.put("&Scaron", '\u0160'); + ESCAPE_STRINGS.put("&scaron", '\u0161'); + ESCAPE_STRINGS.put("&Yuml", '\u0178'); + ESCAPE_STRINGS.put("&circ", '\u02C6'); + ESCAPE_STRINGS.put("&tilde", '\u02DC'); + ESCAPE_STRINGS.put("&ensp", '\u2002'); + ESCAPE_STRINGS.put("&emsp", '\u2003'); + ESCAPE_STRINGS.put("&thinsp", '\u2009'); + ESCAPE_STRINGS.put("&zwnj", '\u200C'); + ESCAPE_STRINGS.put("&zwj", '\u200D'); + ESCAPE_STRINGS.put("&lrm", '\u200E'); + ESCAPE_STRINGS.put("&rlm", '\u200F'); + ESCAPE_STRINGS.put("&ndash", '\u2013'); + ESCAPE_STRINGS.put("&mdash", '\u2014'); + ESCAPE_STRINGS.put("&lsquo", '\u2018'); + ESCAPE_STRINGS.put("&rsquo", '\u2019'); + ESCAPE_STRINGS.put("&sbquo", '\u201A'); + ESCAPE_STRINGS.put("&ldquo", '\u201C'); + ESCAPE_STRINGS.put("&rdquo", '\u201D'); + ESCAPE_STRINGS.put("&bdquo", '\u201E'); + ESCAPE_STRINGS.put("&dagger", '\u2020'); + ESCAPE_STRINGS.put("&Dagger", '\u2021'); + ESCAPE_STRINGS.put("&permil", '\u2030'); + ESCAPE_STRINGS.put("&lsaquo", '\u2039'); + ESCAPE_STRINGS.put("&rsaquo", '\u203A'); + ESCAPE_STRINGS.put("&euro", '\u20AC'); + } + + public static String fromHtmlText(String text) { + return fromText(text, true); + } + + public static String fromPlainText(String text) { + return fromText(text, false); + } + + public static String fromText(String text, boolean stripHtml) { + // Handle null and empty string + if (TextUtils.isEmpty(text)) return ""; + + final int length = text.length(); + // Use char[] instead of StringBuilder purely for performance; fewer method calls, etc. + char[] buffer = new char[MAX_PLAIN_TEXT_SCAN_LENGTH]; + // skipCount is an array of a single int; that int is set inside stripHtmlEntity and is + // used to determine how many characters can be "skipped" due to the transformation of the + // entity to a single character. When Java allows multiple return values, we can make this + // much cleaner :-) + int[] skipCount = new int[1]; + int bufferCount = 0; + // Start with space as last character to avoid leading whitespace + char last = ' '; + // Indicates whether we're in the middle of an HTML tag + boolean inTag = false; + + // Walk through the text until we're done with the input OR we've got a large enough snippet + for (int i = 0; i < length && bufferCount < MAX_PLAIN_TEXT_SCAN_LENGTH; i++) { + char c = text.charAt(i); + if (stripHtml && !inTag && (c == '<')) { + // Find tags to strip; they will begin with ')) { + // Terminate stripping here + inTag = false; + continue; + } + + if (inTag) { + // We just skip by everything while we're in a tag + continue; + } else if (stripHtml && (c == '&')) { + // Handle a possible HTML entity here + // We always get back a character to use; we also get back a "skip count", + // indicating how many characters were eaten from the entity + c = stripHtmlEntity(text, i, skipCount); + i += skipCount[0]; + } + + if (Character.isWhitespace(c) || (c == NON_BREAKING_SPACE_CHARACTER)) { + // The idea is to find the content in the message, not the whitespace, so we'll + // turn any combination of contiguous whitespace into a single space + if (last == ' ') { + continue; + } else { + // Make every whitespace character a simple space + c = ' '; + } + } else if ((c == '-' || c == '=') && (last == c)) { + // Lots of messages (especially digests) have whole lines of --- or === + // We'll get rid of those duplicates here + continue; + } + + // After all that, maybe we've got a character for our snippet + buffer[bufferCount++] = c; + last = c; + } + + // Lose trailing space and return our snippet + if ((bufferCount > 0) && (last == ' ')) { + bufferCount--; + } + return new String(buffer, 0, bufferCount); + } + + static /*package*/ char stripHtmlEntity(String text, int pos, int[] skipCount) { + int length = text.length(); + // Ugly, but we store our skip count in this array; we can't use a static here, because + // multiple threads might be calling in + skipCount[0] = 0; + // All entities are <= 8 characters long, so that's how far we'll look for one (+ & and ;) + int end = pos + 10; + String entity = null; + // Isolate the entity + for (int i = pos; (i < length) && (i < end); i++) { + if (text.charAt(i) == ';') { + entity = text.substring(pos, i); + break; + } + } + if (entity == null) { + // This wasn't really an HTML entity + return '&'; + } else { + // Skip count is the length of the entity + Character mapping = ESCAPE_STRINGS.get(entity); + int entityLength = entity.length(); + if (mapping != null) { + skipCount[0] = entityLength; + return mapping; + } else if ((entityLength > 2) && (entity.charAt(1) == '#')) { + // &#nn; means ascii nn (decimal) and &#xnn means ascii nn (hex) + char c = '?'; + try { + int i; + if ((entity.charAt(2) == 'x') && (entityLength > 3)) { + i = Integer.parseInt(entity.substring(3), 16); + } else { + i = Integer.parseInt(entity.substring(2)); + } + c = (char)i; + } catch (NumberFormatException e) { + // We'll just return the ? in this case + } + skipCount[0] = entityLength; + return c; + } + } + // Worst case, we return the original start character, ampersand + return '&'; + } + +} diff --git a/src/com/android/email/activity/MessagesAdapter.java b/src/com/android/email/activity/MessagesAdapter.java index f9e1d1e2e..1cc965414 100644 --- a/src/com/android/email/activity/MessagesAdapter.java +++ b/src/com/android/email/activity/MessagesAdapter.java @@ -28,13 +28,13 @@ import android.content.Context; import android.content.Loader; import android.content.res.ColorStateList; import android.content.res.Resources; -import android.content.res.Resources.Theme; import android.content.res.TypedArray; +import android.content.res.Resources.Theme; import android.database.Cursor; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.os.Bundle; -import android.os.Handler; +import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -59,7 +59,7 @@ import java.util.Set; EmailContent.RECORD_ID, MessageColumns.MAILBOX_KEY, MessageColumns.ACCOUNT_KEY, MessageColumns.DISPLAY_NAME, MessageColumns.SUBJECT, MessageColumns.TIMESTAMP, MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE, MessageColumns.FLAG_ATTACHMENT, - MessageColumns.FLAGS, + MessageColumns.FLAGS, MessageColumns.SNIPPET }; public static final int COLUMN_ID = 0; @@ -72,6 +72,7 @@ import java.util.Set; public static final int COLUMN_FAVORITE = 7; public static final int COLUMN_ATTACHMENTS = 8; public static final int COLUMN_FLAGS = 9; + public static final int COLUMN_SNIPPET = 10; private static final int ITEM_BACKGROUND_SELECTED = 0xFFB0FFB0; // TODO color not finalized @@ -184,6 +185,15 @@ import java.util.Set; TextView subjectView = (TextView) view.findViewById(R.id.subject); text = cursor.getString(COLUMN_SUBJECT); + // Add in the snippet if we have one + // TODO Should this be spanned text? + // The mocks show, for new messages, only the real subject in bold... + // Would it be easier to simply use a 2nd TextView? This would also allow ellipsizing an + // overly-long subject, to let the beautiful snippet shine through. + String snippet = cursor.getString(COLUMN_SNIPPET); + if (!TextUtils.isEmpty(snippet)) { + text = context.getString(R.string.message_list_snippet, text, snippet); + } subjectView.setText(text); boolean hasInvitation = diff --git a/src/com/android/email/provider/EmailContent.java b/src/com/android/email/provider/EmailContent.java index da0e4bd37..0d3174237 100644 --- a/src/com/android/email/provider/EmailContent.java +++ b/src/com/android/email/provider/EmailContent.java @@ -16,6 +16,7 @@ package com.android.email.provider; +import com.android.email.Snippet; import com.android.email.Utility; import android.content.ContentProviderOperation; @@ -426,9 +427,10 @@ public abstract class EmailContent { public static final String CC_LIST = "ccList"; public static final String BCC_LIST = "bccList"; public static final String REPLY_TO_LIST = "replyToList"; - // Meeting invitation related information (for now, start time in ms) public static final String MEETING_INFO = "meetingInfo"; + // A text "snippet" derived from the body of the message + public static final String SNIPPET = "snippet"; } public static final class Message extends EmailContent implements SyncColumns, MessageColumns { @@ -468,6 +470,7 @@ public abstract class EmailContent { public static final int CONTENT_REPLY_TO_COLUMN = 18; public static final int CONTENT_SERVER_TIMESTAMP_COLUMN = 19; public static final int CONTENT_MEETING_INFO_COLUMN = 20; + public static final int CONTENT_SNIPPET_COLUMN = 21; public static final String[] CONTENT_PROJECTION = new String[] { RECORD_ID, @@ -480,7 +483,8 @@ public abstract class EmailContent { MessageColumns.ACCOUNT_KEY, MessageColumns.FROM_LIST, MessageColumns.TO_LIST, MessageColumns.CC_LIST, MessageColumns.BCC_LIST, MessageColumns.REPLY_TO_LIST, - SyncColumns.SERVER_TIMESTAMP, MessageColumns.MEETING_INFO + SyncColumns.SERVER_TIMESTAMP, MessageColumns.MEETING_INFO, + MessageColumns.SNIPPET }; public static final int LIST_ID_COLUMN = 0; @@ -495,6 +499,7 @@ public abstract class EmailContent { public static final int LIST_MAILBOX_KEY_COLUMN = 9; public static final int LIST_ACCOUNT_KEY_COLUMN = 10; public static final int LIST_SERVER_ID_COLUMN = 11; + public static final int LIST_SNIPPET_COLUMN = 12; // Public projection for common list columns public static final String[] LIST_PROJECTION = new String[] { @@ -504,7 +509,7 @@ public abstract class EmailContent { MessageColumns.FLAG_LOADED, MessageColumns.FLAG_FAVORITE, MessageColumns.FLAG_ATTACHMENT, MessageColumns.FLAGS, MessageColumns.MAILBOX_KEY, MessageColumns.ACCOUNT_KEY, - SyncColumns.SERVER_ID + SyncColumns.SERVER_ID, MessageColumns.SNIPPET }; public static final int ID_COLUMNS_ID_COLUMN = 0; @@ -551,6 +556,8 @@ public abstract class EmailContent { // For now, just the start time of a meeting invite, in ms public String mMeetingInfo; + public String mSnippet; + // The following transient members may be used while building and manipulating messages, // but they are NOT persisted directly by EmailProvider transient public String mText; @@ -632,6 +639,8 @@ public abstract class EmailContent { values.put(MessageColumns.MEETING_INFO, mMeetingInfo); + values.put(MessageColumns.SNIPPET, mSnippet); + return values; } @@ -676,6 +685,7 @@ public abstract class EmailContent { mBcc = c.getString(CONTENT_BCC_LIST_COLUMN); mReplyTo = c.getString(CONTENT_REPLY_TO_COLUMN); mMeetingInfo = c.getString(CONTENT_MEETING_INFO_COLUMN); + mSnippet = c.getString(CONTENT_SNIPPET_COLUMN); return this; } @@ -746,6 +756,12 @@ public abstract class EmailContent { public void addSaveOps(ArrayList ops) { // First, save the message ContentProviderOperation.Builder b = ContentProviderOperation.newInsert(mBaseUri); + // Generate the snippet here, before we create the CPO for Message + if (mText != null) { + mSnippet = Snippet.fromPlainText(mText); + } else if (mHtml != null) { + mSnippet = Snippet.fromHtmlText(mHtml); + } ops.add(b.withValues(toContentValues()).build()); // Create and save the body diff --git a/src/com/android/email/provider/EmailProvider.java b/src/com/android/email/provider/EmailProvider.java index 75abd0726..68516eb08 100644 --- a/src/com/android/email/provider/EmailProvider.java +++ b/src/com/android/email/provider/EmailProvider.java @@ -90,7 +90,8 @@ public class EmailProvider extends ContentProvider { // Version 11: Add content and flags to attachment table // Version 12: Add content_bytes to attachment table. content is deprecated. // Version 13: Add messageCount to Mailbox table. - public static final int DATABASE_VERSION = 13; + // Version 14: Add snippet to Message table + public static final int DATABASE_VERSION = 14; // Any changes to the database format *must* include update-in-place code. // Original version: 2 @@ -314,7 +315,8 @@ public class EmailProvider extends ContentProvider { + MessageColumns.CC_LIST + " text, " + MessageColumns.BCC_LIST + " text, " + MessageColumns.REPLY_TO_LIST + " text, " - + MessageColumns.MEETING_INFO + " text" + + MessageColumns.MEETING_INFO + " text, " + + MessageColumns.SNIPPET + " text" + ");"; // This String and the following String MUST have the same columns, except for the type @@ -849,6 +851,17 @@ public class EmailProvider extends ContentProvider { } oldVersion = 13; } + if (oldVersion == 13) { + try { + db.execSQL("alter table " + Message.TABLE_NAME + + " add column " + Message.SNIPPET + +" text" + ";"); + } catch (SQLException e) { + // Shouldn't be needed unless we're debugging and interrupt the process + Log.w(TAG, "Exception upgrading EmailProvider.db from 13 to 14 " + e); + } + oldVersion = 14; + } } @Override diff --git a/tests/src/com/android/email/SnippetTests.java b/tests/src/com/android/email/SnippetTests.java new file mode 100644 index 000000000..6b3b06ba5 --- /dev/null +++ b/tests/src/com/android/email/SnippetTests.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2010 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. + */ + +/** + * This is a series of unit tests for snippet creation + * + * You can run this entire test case with: + * runtest -c com.android.email.SnippetTests email + */ +package com.android.email; + +import android.test.AndroidTestCase; + +public class SnippetTests extends AndroidTestCase { + + public void testPlainSnippet() { + // Test the simplest cases + assertEquals("", Snippet.fromPlainText(null)); + assertEquals("", Snippet.fromPlainText("")); + + // Test handling leading, trailing, and duplicated whitespace + // Just test common whitespace characters; we calls Character.isWhitespace() internally, so + // other whitespace should be fine as well + assertEquals("", Snippet.fromPlainText(" \n\r\t\r\t\n")); + char c = Snippet.NON_BREAKING_SPACE_CHARACTER; + assertEquals("foo", Snippet.fromPlainText(c + "\r\n\tfoo \n\t\r" + c)); + assertEquals("foo bar", Snippet.fromPlainText(c + "\r\n\tfoo \r\n bar\n\t\r" + c)); + + // Handle duplicated - and = + assertEquals("Foo-Bar=Bletch", Snippet.fromPlainText("Foo-----Bar=======Bletch")); + + // We shouldn't muck with HTML entities + assertEquals(" >", Snippet.fromPlainText(" >")); + } + + public void testHtmlSnippet() { + // Test the simplest cases + assertEquals("", Snippet.fromHtmlText(null)); + assertEquals("", Snippet.fromHtmlText("")); + + // Test handling leading, trailing, and duplicated whitespace + // Just test common whitespace characters; we calls Character.isWhitespace() internally, so + // other whitespace should be fine as well + assertEquals("", Snippet.fromHtmlText(" \n\r\t\r\t\n")); + char c = Snippet.NON_BREAKING_SPACE_CHARACTER; + assertEquals("foo", Snippet.fromHtmlText(c + "\r\n\tfoo \n\t\r" + c)); + assertEquals("foo bar", Snippet.fromHtmlText(c + "\r\n\tfoo \r\n bar\n\t\r" + c)); + + // Handle duplicated - and = + assertEquals("Foo-Bar=Bletch", Snippet.fromPlainText("Foo-----Bar=======Bletch")); + + // We should catch HTML entities in these tests + assertEquals(">", Snippet.fromHtmlText(" >")); + assertEquals("&<> \"", Snippet.fromHtmlText("&<> "")); + // Test for decimal and hex entities + assertEquals("ABC", Snippet.fromHtmlText("ABC")); + assertEquals("ABC", Snippet.fromHtmlText("ABC")); + + // Test for stripping simple tags + assertEquals("Hi there", Snippet.fromHtmlText("Hi there")); + // TODO: Add tests here if/when we find problematic HTML + } + + public void testStripHtmlEntityEdgeCases() { + int[] skipCount = new int[1]; + // Bare & isn't an entity + char c = Snippet.stripHtmlEntity("&", 0, skipCount); + assertEquals(c, '&'); + assertEquals(0, skipCount[0]); + // Also not legal + c = Snippet.stripHtmlEntity("&;", 0, skipCount); + assertEquals(c, '&'); + assertEquals(0, skipCount[0]); + // This is an entity, but shouldn't be found + c = Snippet.stripHtmlEntity("&nosuch;", 0, skipCount); + assertEquals(c, '&'); + assertEquals(0, skipCount[0]); + // This is too long for an entity, even though it starts like a valid one + c = Snippet.stripHtmlEntity(" andmore;", 0, skipCount); + assertEquals(c, '&'); + assertEquals(0, skipCount[0]); + // Illegal decimal entities + c = Snippet.stripHtmlEntity("&#ABC", 0, skipCount); + assertEquals(c, '&'); + assertEquals(0, skipCount[0]); + c = Snippet.stripHtmlEntity(" B", 0, skipCount); + assertEquals(c, '&'); + assertEquals(0, skipCount[0]); + // Illegal hex entities + c = Snippet.stripHtmlEntity("઼", 0, skipCount); + assertEquals(c, '&'); + assertEquals(0, skipCount[0]); + // Illegal hex entities + c = Snippet.stripHtmlEntity("G", 0, skipCount); + assertEquals(c, '&'); + assertEquals(0, skipCount[0]); + } + } diff --git a/tests/src/com/android/email/provider/ProviderTestUtils.java b/tests/src/com/android/email/provider/ProviderTestUtils.java index 2a6407ad5..a5a3339d5 100644 --- a/tests/src/com/android/email/provider/ProviderTestUtils.java +++ b/tests/src/com/android/email/provider/ProviderTestUtils.java @@ -20,6 +20,7 @@ import com.android.email.Utility; import com.android.email.mail.transport.Rfc822Output; import com.android.email.provider.EmailContent.Account; import com.android.email.provider.EmailContent.Attachment; +import com.android.email.provider.EmailContent.Body; import com.android.email.provider.EmailContent.HostAuth; import com.android.email.provider.EmailContent.Mailbox; import com.android.email.provider.EmailContent.Message; @@ -190,6 +191,31 @@ public class ProviderTestUtils extends Assert { return message; } + /** + * Create a test body + * + * @param messageId the message this body belongs to + * @param textContent the plain text for the body + * @param htmlContent the html text for the body + * @param saveIt if true, write the new attachment directly to the DB + * @param context use this context + */ + public static Body setupBody(long messageId, String textContent, String htmlContent, + boolean saveIt, Context context) { + Body body = new Body(); + body.mMessageKey = messageId; + body.mTextContent = textContent; + body.mHtmlContent = htmlContent; + body.mTextReply = "text reply " + messageId; + body.mHtmlReply = "html reply " + messageId; + body.mSourceKey = messageId + 0x1000; + body.mIntroText = "intro text " + messageId; + if (saveIt) { + body.save(context); + } + return body; + } + /** * Create a test attachment. A few fields are specified by params, and all other fields * are generated using pseudo-unique values. @@ -364,6 +390,8 @@ public class ProviderTestUtils extends Assert { assertEquals(caller + " mMeetingInfo", expect.mMeetingInfo, actual.mMeetingInfo); + assertEquals(caller + " mSnippet", expect.mSnippet, actual.mSnippet); + assertEquals(caller + " mText", expect.mText, actual.mText); assertEquals(caller + " mHtml", expect.mHtml, actual.mHtml); assertEquals(caller + " mTextReply", expect.mTextReply, actual.mTextReply); diff --git a/tests/src/com/android/email/provider/ProviderTests.java b/tests/src/com/android/email/provider/ProviderTests.java index badbb6b74..c1289cdc2 100644 --- a/tests/src/com/android/email/provider/ProviderTests.java +++ b/tests/src/com/android/email/provider/ProviderTests.java @@ -16,6 +16,7 @@ package com.android.email.provider; +import com.android.email.Snippet; import com.android.email.provider.EmailContent.Account; import com.android.email.provider.EmailContent.AccountColumns; import com.android.email.provider.EmailContent.Attachment; @@ -518,6 +519,34 @@ public class ProviderTests extends ProviderTestCase2 { } } + /** + * Test that saving a message creates the proper snippet for that message + */ + public void testMessageSaveAddsSnippet() { + Account account = ProviderTestUtils.setupAccount("message-snippet", true, mMockContext); + Mailbox box = ProviderTestUtils.setupMailbox("box1", account.mId, true, mMockContext); + + // Create a message without a body, unsaved + Message message = ProviderTestUtils.setupMessage("message", account.mId, box.mId, false, + false, mMockContext); + message.mText = "This is some text"; + message.mHtml = "This is some text"; + message.save(mMockContext); + Message restoredMessage = Message.restoreMessageWithId(mMockContext, message.mId); + // We should have the plain text as the snippet + assertEquals(restoredMessage.mSnippet, Snippet.fromPlainText(message.mText)); + + // Start again + message = ProviderTestUtils.setupMessage("message", account.mId, box.mId, false, + false, mMockContext); + message.mText = null; + message.mHtml = "This is some text"; + message.save(mMockContext); + restoredMessage = Message.restoreMessageWithId(mMockContext, message.mId); + // We should have the plain text as the snippet + assertEquals(restoredMessage.mSnippet, Snippet.fromHtmlText(message.mHtml)); + } + /** * TODO: update account */