diff options
Diffstat (limited to 'core/java/android/provider/Gmail.java')
-rw-r--r-- | core/java/android/provider/Gmail.java | 2453 |
1 files changed, 2453 insertions, 0 deletions
diff --git a/core/java/android/provider/Gmail.java b/core/java/android/provider/Gmail.java new file mode 100644 index 0000000..5b3c223 --- /dev/null +++ b/core/java/android/provider/Gmail.java @@ -0,0 +1,2453 @@ +/* + * Copyright (C) 2007 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 android.provider; + +import com.google.android.collect.Lists; +import com.google.android.collect.Maps; +import com.google.android.collect.Sets; + +import android.content.AsyncQueryHandler; +import android.content.ContentQueryMap; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.DataSetObserver; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextUtils.SimpleStringSplitter; +import android.text.style.CharacterStyle; +import android.text.util.Regex; +import android.util.Config; +import android.util.Log; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Observable; +import java.util.Observer; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A thin wrapper over the content resolver for accessing the gmail provider. + * + * @hide + */ +public final class Gmail { + public static final String GMAIL_AUTH_SERVICE = "mail"; + // These constants come from google3/java/com/google/caribou/backend/MailLabel.java. + public static final String LABEL_SENT = "^f"; + public static final String LABEL_INBOX = "^i"; + public static final String LABEL_DRAFT = "^r"; + public static final String LABEL_UNREAD = "^u"; + public static final String LABEL_TRASH = "^k"; + public static final String LABEL_SPAM = "^s"; + public static final String LABEL_STARRED = "^t"; + public static final String LABEL_CHAT = "^b"; // 'b' for 'buzz' + public static final String LABEL_VOICEMAIL = "^vm"; + public static final String LABEL_IGNORED = "^g"; + public static final String LABEL_ALL = "^all"; + // These constants (starting with "^^") are only used locally and are not understood by the + // server. + public static final String LABEL_VOICEMAIL_INBOX = "^^vmi"; + public static final String LABEL_CACHED = "^^cached"; + public static final String LABEL_OUTBOX = "^^out"; + + public static final String AUTHORITY = "gmail-ls"; + private static final String TAG = "gmail-ls"; + private static final String AUTHORITY_PLUS_CONVERSATIONS = + "content://" + AUTHORITY + "/conversations/"; + private static final String AUTHORITY_PLUS_LABELS = + "content://" + AUTHORITY + "/labels/"; + private static final String AUTHORITY_PLUS_MESSAGES = + "content://" + AUTHORITY + "/messages/"; + private static final String AUTHORITY_PLUS_SETTINGS = + "content://" + AUTHORITY + "/settings/"; + + public static final Uri BASE_URI = Uri.parse( + "content://" + AUTHORITY); + private static final Uri LABELS_URI = + Uri.parse(AUTHORITY_PLUS_LABELS); + private static final Uri CONVERSATIONS_URI = + Uri.parse(AUTHORITY_PLUS_CONVERSATIONS); + private static final Uri SETTINGS_URI = + Uri.parse(AUTHORITY_PLUS_SETTINGS); + + /** Separates email addresses in strings in the database. */ + public static final String EMAIL_SEPARATOR = "\n"; + public static final Pattern EMAIL_SEPARATOR_PATTERN = Pattern.compile(EMAIL_SEPARATOR); + + /** + * Space-separated lists have separators only between items. + */ + private static final char SPACE_SEPARATOR = ' '; + public static final Pattern SPACE_SEPARATOR_PATTERN = Pattern.compile(" "); + + /** + * Comma-separated lists have separators between each item, before the first and after the last + * item. The empty list is <tt>,</tt>. + * + * <p>This makes them easier to modify with SQL since it is not a special case to add or + * remove the last item. Having a separator on each side of each value also makes it safe to use + * SQL's REPLACE to remove an item from a string by using REPLACE(',value,', ','). + * + * <p>We could use the same separator for both lists but this makes it easier to remember which + * kind of list one is dealing with. + */ + private static final char COMMA_SEPARATOR = ','; + public static final Pattern COMMA_SEPARATOR_PATTERN = Pattern.compile(","); + + /** Separates attachment info parts in strings in the database. */ + public static final String ATTACHMENT_INFO_SEPARATOR = "\n"; + public static final Pattern ATTACHMENT_INFO_SEPARATOR_PATTERN = + Pattern.compile(ATTACHMENT_INFO_SEPARATOR); + + public static final Character SENDER_LIST_SEPARATOR = '\n'; + public static final String SENDER_LIST_TOKEN_ELIDED = "e"; + public static final String SENDER_LIST_TOKEN_NUM_MESSAGES = "n"; + public static final String SENDER_LIST_TOKEN_NUM_DRAFTS = "d"; + public static final String SENDER_LIST_TOKEN_LITERAL = "l"; + public static final String SENDER_LIST_TOKEN_SENDING = "s"; + public static final String SENDER_LIST_TOKEN_SEND_FAILED = "f"; + + /** Used for finding status in a cursor's extras. */ + public static final String EXTRA_STATUS = "status"; + + public static final String RESPOND_INPUT_COMMAND = "command"; + public static final String COMMAND_RETRY = "retry"; + public static final String COMMAND_ACTIVATE = "activate"; + public static final String COMMAND_SET_VISIBLE = "setVisible"; + public static final String SET_VISIBLE_PARAM_VISIBLE = "visible"; + public static final String RESPOND_OUTPUT_COMMAND_RESPONSE = "commandResponse"; + public static final String COMMAND_RESPONSE_OK = "ok"; + public static final String COMMAND_RESPONSE_UNKNOWN = "unknownCommand"; + + public static final String INSERT_PARAM_ATTACHMENT_ORIGIN = "origin"; + public static final String INSERT_PARAM_ATTACHMENT_ORIGIN_EXTRAS = "originExtras"; + + private static final Pattern NAME_ADDRESS_PATTERN = Pattern.compile("\"(.*)\""); + private static final Pattern UNNAMED_ADDRESS_PATTERN = Pattern.compile("([^<]+)@"); + + private static final Map<Integer, Integer> sPriorityToLength = Maps.newHashMap(); + public static final SimpleStringSplitter sSenderListSplitter = + new SimpleStringSplitter(SENDER_LIST_SEPARATOR); + public static String[] sSenderFragments = new String[8]; + + /** + * Returns the name in an address string + * @param addressString such as "bobby" <bob@example.com> + * @return returns the quoted name in the addressString, otherwise the username from the email + * address + */ + public static String getNameFromAddressString(String addressString) { + Matcher namedAddressMatch = NAME_ADDRESS_PATTERN.matcher(addressString); + if (namedAddressMatch.find()) { + String name = namedAddressMatch.group(1); + if (name.length() > 0) return name; + addressString = + addressString.substring(namedAddressMatch.end(), addressString.length()); + } + + Matcher unnamedAddressMatch = UNNAMED_ADDRESS_PATTERN.matcher(addressString); + if (unnamedAddressMatch.find()) { + return unnamedAddressMatch.group(1); + } + + return addressString; + } + + /** + * Returns the email address in an address string + * @param addressString such as "bobby" <bob@example.com> + * @return returns the email address, such as bob@example.com from the example above + */ + public static String getEmailFromAddressString(String addressString) { + String result = addressString; + Matcher match = Regex.EMAIL_ADDRESS_PATTERN.matcher(addressString); + if (match.find()) { + result = addressString.substring(match.start(), match.end()); + } + + return result; + } + + /** + * Returns whether the label is user-defined (versus system-defined labels such as inbox, whose + * names start with "^"). + */ + public static boolean isLabelUserDefined(String label) { + // TODO: label should never be empty so we should be able to say [label.charAt(0) != '^']. + // However, it's a release week and I'm too scared to make that change. + return !label.startsWith("^"); + } + + private static final Set<String> USER_SETTABLE_BUILTIN_LABELS = Sets.newHashSet( + Gmail.LABEL_INBOX, + Gmail.LABEL_UNREAD, + Gmail.LABEL_TRASH, + Gmail.LABEL_SPAM, + Gmail.LABEL_STARRED, + Gmail.LABEL_IGNORED); + + /** + * Returns whether the label is user-settable. For example, labels such as LABEL_DRAFT should + * only be set internally. + */ + public static boolean isLabelUserSettable(String label) { + return USER_SETTABLE_BUILTIN_LABELS.contains(label) || isLabelUserDefined(label); + } + + /** + * Returns the set of labels using the raw labels from a previous getRawLabels() + * as input. + * @return a copy of the set of labels. To add or remove labels call + * MessageCursor.addOrRemoveLabel on each message in the conversation. + */ + public static Set<Long> getLabelIdsFromLabelIdsString( + TextUtils.StringSplitter splitter) { + Set<Long> labelIds = Sets.newHashSet(); + for (String labelIdString : splitter) { + labelIds.add(Long.valueOf(labelIdString)); + } + return labelIds; + } + + /** + * @deprecated remove when the activities stop using canonical names to identify labels + */ + public static Set<String> getCanonicalNamesFromLabelIdsString( + LabelMap labelMap, TextUtils.StringSplitter splitter) { + Set<String> canonicalNames = Sets.newHashSet(); + for (long labelId : getLabelIdsFromLabelIdsString(splitter)) { + final String canonicalName = labelMap.getCanonicalName(labelId); + // We will sometimes see labels that the label map does not yet know about or that + // do not have names yet. + if (!TextUtils.isEmpty(canonicalName)) { + canonicalNames.add(canonicalName); + } else { + Log.w(TAG, "getCanonicalNamesFromLabelIdsString skipping label id: " + labelId); + } + } + return canonicalNames; + } + + /** + * @return a StringSplitter that is configured to split message label id strings + */ + public static TextUtils.StringSplitter newMessageLabelIdsSplitter() { + return new TextUtils.SimpleStringSplitter(SPACE_SEPARATOR); + } + + /** + * @return a StringSplitter that is configured to split conversation label id strings + */ + public static TextUtils.StringSplitter newConversationLabelIdsSplitter() { + return new CommaStringSplitter(); + } + + /** + * A splitter for strings of the form described in the docs for COMMA_SEPARATOR. + */ + private static class CommaStringSplitter extends TextUtils.SimpleStringSplitter { + + public CommaStringSplitter() { + super(COMMA_SEPARATOR); + } + + @Override + public void setString(String string) { + // The string should always be at least a single comma. + super.setString(string.substring(1)); + } + } + + /** + * Creates a single string of the form that getLabelIdsFromLabelIdsString can split. + */ + public static String getLabelIdsStringFromLabelIds(Set<Long> labelIds) { + StringBuilder sb = new StringBuilder(); + sb.append(COMMA_SEPARATOR); + for (Long labelId : labelIds) { + sb.append(labelId); + sb.append(COMMA_SEPARATOR); + } + return sb.toString(); + } + + public static final class ConversationColumns { + public static final String ID = "_id"; + public static final String SUBJECT = "subject"; + public static final String SNIPPET = "snippet"; + public static final String FROM = "fromAddress"; + public static final String DATE = "date"; + public static final String PERSONAL_LEVEL = "personalLevel"; + /** A list of label names with a space after each one (including the last one). This makes + * it easier remove individual labels from this list using SQL. */ + public static final String LABEL_IDS = "labelIds"; + public static final String NUM_MESSAGES = "numMessages"; + public static final String MAX_MESSAGE_ID = "maxMessageId"; + public static final String HAS_ATTACHMENTS = "hasAttachments"; + public static final String HAS_MESSAGES_WITH_ERRORS = "hasMessagesWithErrors"; + public static final String FORCE_ALL_UNREAD = "forceAllUnread"; + + private ConversationColumns() {} + } + + public static final class MessageColumns { + + public static final String ID = "_id"; + public static final String MESSAGE_ID = "messageId"; + public static final String CONVERSATION_ID = "conversation"; + public static final String SUBJECT = "subject"; + public static final String SNIPPET = "snippet"; + public static final String FROM = "fromAddress"; + public static final String TO = "toAddresses"; + public static final String CC = "ccAddresses"; + public static final String BCC = "bccAddresses"; + public static final String REPLY_TO = "replyToAddresses"; + public static final String DATE_SENT_MS = "dateSentMs"; + public static final String DATE_RECEIVED_MS = "dateReceivedMs"; + public static final String LIST_INFO = "listInfo"; + public static final String PERSONAL_LEVEL = "personalLevel"; + public static final String BODY = "body"; + public static final String EMBEDS_EXTERNAL_RESOURCES = "bodyEmbedsExternalResources"; + public static final String LABEL_IDS = "labelIds"; + public static final String JOINED_ATTACHMENT_INFOS = "joinedAttachmentInfos"; + public static final String ERROR = "error"; + // TODO: add a method for accessing this + public static final String REF_MESSAGE_ID = "refMessageId"; + + // Fake columns used only for saving or sending messages. + public static final String FAKE_SAVE = "save"; + public static final String FAKE_REF_MESSAGE_ID = "refMessageId"; + + private MessageColumns() {} + } + + public static final class LabelColumns { + public static final String CANONICAL_NAME = "canonicalName"; + public static final String NAME = "name"; + public static final String NUM_CONVERSATIONS = "numConversations"; + public static final String NUM_UNREAD_CONVERSATIONS = + "numUnreadConversations"; + + private LabelColumns() {} + } + + public static final class SettingsColumns { + public static final String LABELS_INCLUDED = "labelsIncluded"; + public static final String LABELS_PARTIAL = "labelsPartial"; + public static final String CONVERSATION_AGE_DAYS = + "conversationAgeDays"; + public static final String MAX_ATTACHMENET_SIZE_MB = + "maxAttachmentSize"; + } + + /** + * These flags can be included as Selection Arguments when + * querying the provider. + */ + public static class SelectionArguments { + private SelectionArguments() { + // forbid instantiation + } + + /** + * Specifies that you do NOT wish the returned cursor to + * become the Active Network Cursor. If you do not include + * this flag as a selectionArg, the new cursor will become the + * Active Network Cursor by default. + */ + public static final String DO_NOT_BECOME_ACTIVE_NETWORK_CURSOR = + "SELECTION_ARGUMENT_DO_NOT_BECOME_ACTIVE_NETWORK_CURSOR"; + } + + // These are the projections that we need when getting cursors from the + // content provider. + private static String[] CONVERSATION_PROJECTION = { + ConversationColumns.ID, + ConversationColumns.SUBJECT, + ConversationColumns.SNIPPET, + ConversationColumns.FROM, + ConversationColumns.DATE, + ConversationColumns.PERSONAL_LEVEL, + ConversationColumns.LABEL_IDS, + ConversationColumns.NUM_MESSAGES, + ConversationColumns.MAX_MESSAGE_ID, + ConversationColumns.HAS_ATTACHMENTS, + ConversationColumns.HAS_MESSAGES_WITH_ERRORS, + ConversationColumns.FORCE_ALL_UNREAD}; + private static String[] MESSAGE_PROJECTION = { + MessageColumns.ID, + MessageColumns.MESSAGE_ID, + MessageColumns.CONVERSATION_ID, + MessageColumns.SUBJECT, + MessageColumns.SNIPPET, + MessageColumns.FROM, + MessageColumns.TO, + MessageColumns.CC, + MessageColumns.BCC, + MessageColumns.REPLY_TO, + MessageColumns.DATE_SENT_MS, + MessageColumns.DATE_RECEIVED_MS, + MessageColumns.LIST_INFO, + MessageColumns.PERSONAL_LEVEL, + MessageColumns.BODY, + MessageColumns.EMBEDS_EXTERNAL_RESOURCES, + MessageColumns.LABEL_IDS, + MessageColumns.JOINED_ATTACHMENT_INFOS, + MessageColumns.ERROR}; + private static String[] LABEL_PROJECTION = { + BaseColumns._ID, + LabelColumns.CANONICAL_NAME, + LabelColumns.NAME, + LabelColumns.NUM_CONVERSATIONS, + LabelColumns.NUM_UNREAD_CONVERSATIONS}; + private static String[] SETTINGS_PROJECTION = { + SettingsColumns.LABELS_INCLUDED, + SettingsColumns.LABELS_PARTIAL, + SettingsColumns.CONVERSATION_AGE_DAYS, + SettingsColumns.MAX_ATTACHMENET_SIZE_MB, + }; + + private ContentResolver mContentResolver; + + public Gmail(ContentResolver contentResolver) { + mContentResolver = contentResolver; + } + + /** + * Returns source if source is non-null. Returns the empty string otherwise. + */ + private static String toNonnullString(String source) { + if (source == null) { + return ""; + } else { + return source; + } + } + + /** + * Behavior for a new cursor: should it become the Active Network + * Cursor? This could potentially lead to bad behavior if someone + * else is using the Active Network Cursor, since theirs will stop + * being the Active Network Cursor. + */ + public static enum BecomeActiveNetworkCursor { + /** + * The new cursor should become the one and only Active + * Network Cursor. Any other cursor that might already be the + * Active Network Cursor will cease to be so. + */ + YES, + + /** + * The new cursor should not become the Active Network + * Cursor. Any other cursor that might already be the Active + * Network Cursor will continue to be so. + */ + NO + } + + /** + * Wraps a Cursor in a ConversationCursor + * + * @param account the account the cursor is associated with + * @param cursor The Cursor to wrap + * @return a new ConversationCursor + */ + public ConversationCursor getConversationCursorForCursor(String account, Cursor cursor) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + return new ConversationCursor(this, account, cursor); + } + + /** + * Creates an array of SelectionArguments suitable for passing to the provider's query. + * Currently this only handles one flag, but it could be expanded in the future. + */ + private static String[] getSelectionArguments( + BecomeActiveNetworkCursor becomeActiveNetworkCursor) { + if (BecomeActiveNetworkCursor.NO == becomeActiveNetworkCursor) { + return new String[] {SelectionArguments.DO_NOT_BECOME_ACTIVE_NETWORK_CURSOR}; + } else { + // Default behavior; no args required. + return null; + } + } + + /** + * Asynchronously gets a cursor over all conversations matching a query. The + * query is in Gmail's query syntax. When the operation is complete the handler's + * onQueryComplete() method is called with the resulting Cursor. + * + * @param account run the query on this account + * @param handler An AsyncQueryHanlder that will be used to run the query + * @param token The token to pass to startQuery, which will be passed back to onQueryComplete + * @param query a query in Gmail's query syntax + * @param becomeActiveNetworkCursor whether or not the returned + * cursor should become the Active Network Cursor + */ + public void runQueryForConversations(String account, AsyncQueryHandler handler, int token, + String query, BecomeActiveNetworkCursor becomeActiveNetworkCursor) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + String[] selectionArgs = getSelectionArguments(becomeActiveNetworkCursor); + handler.startQuery(token, null, Uri.withAppendedPath(CONVERSATIONS_URI, account), + CONVERSATION_PROJECTION, query, selectionArgs, null); + } + + /** + * Synchronously gets a cursor over all conversations matching a query. The + * query is in Gmail's query syntax. + * + * @param account run the query on this account + * @param query a query in Gmail's query syntax + * @param becomeActiveNetworkCursor whether or not the returned + * cursor should become the Active Network Cursor + */ + public ConversationCursor getConversationCursorForQuery( + String account, String query, BecomeActiveNetworkCursor becomeActiveNetworkCursor) { + String[] selectionArgs = getSelectionArguments(becomeActiveNetworkCursor); + Cursor cursor = mContentResolver.query( + Uri.withAppendedPath(CONVERSATIONS_URI, account), CONVERSATION_PROJECTION, + query, selectionArgs, null); + return new ConversationCursor(this, account, cursor); + } + + /** + * Gets a message cursor over the single message with the given id. + * + * @param account get the cursor for messages in this account + * @param messageId the id of the message + * @return a cursor over the message + */ + public MessageCursor getMessageCursorForMessageId(String account, long messageId) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + Uri uri = Uri.parse(AUTHORITY_PLUS_MESSAGES + account + "/" + messageId); + Cursor cursor = mContentResolver.query(uri, MESSAGE_PROJECTION, null, null, null); + return new MessageCursor(this, mContentResolver, account, cursor); + } + + /** + * Gets a message cursor over the messages that match the query. Note that + * this simply finds all of the messages that match and returns them. It + * does not return all messages in conversations where any message matches. + * + * @param account get the cursor for messages in this account + * @param query a query in GMail's query syntax. Currently only queries of + * the form [label:<label>] are supported + * @return a cursor over the messages + */ + public MessageCursor getLocalMessageCursorForQuery(String account, String query) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + Uri uri = Uri.parse(AUTHORITY_PLUS_MESSAGES + account + "/"); + Cursor cursor = mContentResolver.query(uri, MESSAGE_PROJECTION, query, null, null); + return new MessageCursor(this, mContentResolver, account, cursor); + } + + /** + * Gets a cursor over all of the messages in a conversation. + * + * @param account get the cursor for messages in this account + * @param conversationId the id of the converstion to fetch messages for + * @return a cursor over messages in the conversation + */ + public MessageCursor getMessageCursorForConversationId(String account, long conversationId) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + Uri uri = Uri.parse( + AUTHORITY_PLUS_CONVERSATIONS + account + "/" + conversationId + "/messages"); + Cursor cursor = mContentResolver.query( + uri, MESSAGE_PROJECTION, null, null, null); + return new MessageCursor(this, mContentResolver, account, cursor); + } + + /** + * Expunge the indicated message. One use of this is to discard drafts. + * + * @param account the account of the message id + * @param messageId the id of the message to expunge + */ + public void expungeMessage(String account, long messageId) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + Uri uri = Uri.parse(AUTHORITY_PLUS_MESSAGES + account + "/" + messageId); + mContentResolver.delete(uri, null, null); + } + + /** + * Adds or removes the label on the conversation. + * + * @param account the account of the conversation + * @param conversationId the conversation + * @param maxServerMessageId the highest message id to whose labels should be changed. Note that + * everywhere else in this file messageId means local message id but here you need to use a + * server message id. + * @param label the label to add or remove + * @param add true to add the label, false to remove it + */ + public void addOrRemoveLabelOnConversation( + String account, long conversationId, long maxServerMessageId, String label, + boolean add) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + if (add) { + Uri uri = Uri.parse( + AUTHORITY_PLUS_CONVERSATIONS + account + "/" + conversationId + "/labels"); + ContentValues values = new ContentValues(); + values.put(LabelColumns.CANONICAL_NAME, label); + values.put(ConversationColumns.MAX_MESSAGE_ID, maxServerMessageId); + mContentResolver.insert(uri, values); + } else { + String encodedLabel; + try { + encodedLabel = URLEncoder.encode(label, "utf-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + Uri uri = Uri.parse( + AUTHORITY_PLUS_CONVERSATIONS + account + "/" + + conversationId + "/labels/" + encodedLabel); + mContentResolver.delete( + uri, ConversationColumns.MAX_MESSAGE_ID, new String[]{"" + maxServerMessageId}); + } + } + + /** + * Adds or removes the label on the message. + * + * @param contentResolver the content resolver. + * @param account the account of the message + * @param conversationId the conversation containing the message + * @param messageId the id of the message to whose labels should be changed + * @param label the label to add or remove + * @param add true to add the label, false to remove it + */ + public static void addOrRemoveLabelOnMessage(ContentResolver contentResolver, String account, + long conversationId, long messageId, String label, boolean add) { + + // conversationId is unused but we want to start passing it whereever we pass a message id. + if (add) { + Uri uri = Uri.parse( + AUTHORITY_PLUS_MESSAGES + account + "/" + messageId + "/labels"); + ContentValues values = new ContentValues(); + values.put(LabelColumns.CANONICAL_NAME, label); + contentResolver.insert(uri, values); + } else { + String encodedLabel; + try { + encodedLabel = URLEncoder.encode(label, "utf-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + Uri uri = Uri.parse( + AUTHORITY_PLUS_MESSAGES + account + "/" + messageId + + "/labels/" + encodedLabel); + contentResolver.delete(uri, null, null); + } + } + + /** + * The mail provider will send an intent when certain changes happen in certain labels. + * Currently those labels are inbox and voicemail. + * + * <p>The intent will have the action ACTION_PROVIDER_CHANGED and the extras mentioned below. + * The data for the intent will be content://gmail-ls/unread/<name of label>. + * + * <p>The goal is to support the following user experience:<ul> + * <li>When present the new mail indicator reports the number of unread conversations in the + * inbox (or some other label).</li> + * <li>When the user views the inbox the indicator is removed immediately. They do not have to + * read all of the conversations.</li> + * <li>If more mail arrives the indicator reappears and shows the total number of unread + * conversations in the inbox.</li> + * <li>If the user reads the new conversations on the web the indicator disappears on the + * phone since there is no unread mail in the inbox that the user hasn't seen.</li> + * <li>The phone should vibrate/etc when it transitions from having no unseen unread inbox + * mail to having some.</li> + */ + + /** The account in which the change occurred. */ + static public final String PROVIDER_CHANGED_EXTRA_ACCOUNT = "account"; + + /** The number of unread conversations matching the label. */ + static public final String PROVIDER_CHANGED_EXTRA_COUNT = "count"; + + /** Whether to get the user's attention, perhaps by vibrating. */ + static public final String PROVIDER_CHANGED_EXTRA_GET_ATTENTION = "getAttention"; + + /** + * A label that is attached to all of the conversations being notified about. This enables the + * receiver of a notification to get a list of matching conversations. + */ + static public final String PROVIDER_CHANGED_EXTRA_TAG_LABEL = "tagLabel"; + + /** + * Settings for which conversations should be synced to the phone. + * Conversations are synced if any message matches any of the following + * criteria: + * + * <ul> + * <li>the message has a label in the include set</li> + * <li>the message is no older than conversationAgeDays and has a label in the partial set. + * </li> + * <li>also, pending changes on the server: the message has no user-controllable labels.</li> + * </ul> + * + * <p>A user-controllable label is a user-defined label or star, inbox, + * trash, spam, etc. LABEL_UNREAD is not considered user-controllable. + */ + public static class Settings { + public long conversationAgeDays; + public long maxAttachmentSizeMb; + public String[] labelsIncluded; + public String[] labelsPartial; + } + + /** + * Returns the settings. + * @param account the account whose setting should be retrieved + */ + public Settings getSettings(String account) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + Settings settings = new Settings(); + Cursor cursor = mContentResolver.query( + Uri.withAppendedPath(SETTINGS_URI, account), SETTINGS_PROJECTION, null, null, null); + cursor.moveToNext(); + settings.labelsIncluded = TextUtils.split(cursor.getString(0), SPACE_SEPARATOR_PATTERN); + settings.labelsPartial = TextUtils.split(cursor.getString(1), SPACE_SEPARATOR_PATTERN); + settings.conversationAgeDays = Long.parseLong(cursor.getString(2)); + settings.maxAttachmentSizeMb = Long.parseLong(cursor.getString(3)); + cursor.close(); + return settings; + } + + /** + * Sets the settings. A sync will be scheduled automatically. + */ + public void setSettings(String account, Settings settings) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + ContentValues values = new ContentValues(); + values.put( + SettingsColumns.LABELS_INCLUDED, + TextUtils.join(" ", settings.labelsIncluded)); + values.put( + SettingsColumns.LABELS_PARTIAL, + TextUtils.join(" ", settings.labelsPartial)); + values.put( + SettingsColumns.CONVERSATION_AGE_DAYS, + settings.conversationAgeDays); + values.put( + SettingsColumns.MAX_ATTACHMENET_SIZE_MB, + settings.maxAttachmentSizeMb); + mContentResolver.update(Uri.withAppendedPath(SETTINGS_URI, account), values, null, null); + } + + /** + * Uses sender instructions to build a formatted string. + * + * <p>Sender list instructions contain compact information about the sender list. Most work that + * can be done without knowing how much room will be availble for the sender list is done when + * creating the instructions. + * + * <p>The instructions string consists of tokens separated by SENDER_LIST_SEPARATOR. Here are + * the tokens, one per line:<ul> + * <li><tt>n</tt></li> + * <li><em>int</em>, the number of non-draft messages in the conversation</li> + * <li><tt>d</tt</li> + * <li><em>int</em>, the number of drafts in the conversation</li> + * <li><tt>l</tt></li> + * <li><em>literal html to be included in the output</em></li> + * <li><tt>s</tt> indicates that the message is sending (in the outbox without errors)</li> + * <li><tt>f</tt> indicates that the message failed to send (in the outbox with errors)</li> + * <li><em>for each message</em><ul> + * <li><em>int</em>, 0 for read, 1 for unread</li> + * <li><em>int</em>, the priority of the message. Zero is the most important</li> + * <li><em>text</em>, the sender text or blank for messages from 'me'</li> + * </ul></li> + * <li><tt>e</tt> to indicate that one or more messages have been elided</li> + * + * <p>The instructions indicate how many messages and drafts are in the conversation and then + * describe the most important messages in order, indicating the priority of each message and + * whether the message is unread. + * + * @param instructions instructions as described above + * @param sb the SpannableStringBuilder to append to + * @param maxChars the number of characters available to display the text + * @param unreadStyle the CharacterStyle for unread messages, or null + * @param draftsStyle the CharacterStyle for draft messages, or null + * @param sendingString the string to use when there are messages scheduled to be sent + * @param sendFailedString the string to use when there are messages that mailed to send + * @param meString the string to use for messages sent by this user + * @param draftString the string to use for "Draft" + * @param draftPluralString the string to use for "Drafts" + */ + public static void getSenderSnippet( + String instructions, SpannableStringBuilder sb, int maxChars, + CharacterStyle unreadStyle, + CharacterStyle draftsStyle, + CharSequence meString, CharSequence draftString, CharSequence draftPluralString, + CharSequence sendingString, CharSequence sendFailedString, + boolean forceAllUnread, boolean forceAllRead) { + assert !(forceAllUnread && forceAllRead); + boolean unreadStatusIsForced = forceAllUnread || forceAllRead; + boolean forcedUnreadStatus = forceAllUnread; + + // Measure each fragment. It's ok to iterate over the entire set of fragments because it is + // never a long list, even if there are many senders. + final Map<Integer, Integer> priorityToLength = sPriorityToLength; + priorityToLength.clear(); + + int maxFoundPriority = Integer.MIN_VALUE; + int numMessages = 0; + int numDrafts = 0; + CharSequence draftsFragment = ""; + CharSequence sendingFragment = ""; + CharSequence sendFailedFragment = ""; + + sSenderListSplitter.setString(instructions); + int numFragments = 0; + String[] fragments = sSenderFragments; + int currentSize = fragments.length; + while (sSenderListSplitter.hasNext()) { + fragments[numFragments++] = sSenderListSplitter.next(); + if (numFragments == currentSize) { + sSenderFragments = new String[2 * currentSize]; + System.arraycopy(fragments, 0, sSenderFragments, 0, currentSize); + currentSize *= 2; + fragments = sSenderFragments; + } + } + + for (int i = 0; i < numFragments;) { + String fragment0 = fragments[i++]; + if ("".equals(fragment0)) { + // This should be the final fragment. + } else if (Gmail.SENDER_LIST_TOKEN_ELIDED.equals(fragment0)) { + // ignore + } else if (Gmail.SENDER_LIST_TOKEN_NUM_MESSAGES.equals(fragment0)) { + numMessages = Integer.valueOf(fragments[i++]); + } else if (Gmail.SENDER_LIST_TOKEN_NUM_DRAFTS.equals(fragment0)) { + String numDraftsString = fragments[i++]; + numDrafts = Integer.parseInt(numDraftsString); + draftsFragment = numDrafts == 1 ? draftString : + draftPluralString + " (" + numDraftsString + ")"; + } else if (Gmail.SENDER_LIST_TOKEN_LITERAL.equals(fragment0)) { + sb.append(Html.fromHtml(fragments[i++])); + return; + } else if (Gmail.SENDER_LIST_TOKEN_SENDING.equals(fragment0)) { + sendingFragment = sendingString; + } else if (Gmail.SENDER_LIST_TOKEN_SEND_FAILED.equals(fragment0)) { + sendFailedFragment = sendFailedString; + } else { + String priorityString = fragments[i++]; + CharSequence nameString = fragments[i++]; + if (nameString.length() == 0) nameString = meString; + int priority = Integer.parseInt(priorityString); + priorityToLength.put(priority, nameString.length()); + maxFoundPriority = Math.max(maxFoundPriority, priority); + } + } + String numMessagesFragment = + (numMessages != 0) ? " (" + Integer.toString(numMessages + numDrafts) + ")" : ""; + + // Don't allocate fixedFragment unless we need it + SpannableStringBuilder fixedFragment = null; + int fixedFragmentLength = 0; + if (draftsFragment.length() != 0) { + if (fixedFragment == null) { + fixedFragment = new SpannableStringBuilder(); + } + fixedFragment.append(draftsFragment); + if (draftsStyle != null) { + fixedFragment.setSpan( + CharacterStyle.wrap(draftsStyle), + 0, fixedFragment.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + if (sendingFragment.length() != 0) { + if (fixedFragment == null) { + fixedFragment = new SpannableStringBuilder(); + } + if (fixedFragment.length() != 0) fixedFragment.append(", "); + fixedFragment.append(sendingFragment); + } + if (sendFailedFragment.length() != 0) { + if (fixedFragment == null) { + fixedFragment = new SpannableStringBuilder(); + } + if (fixedFragment.length() != 0) fixedFragment.append(", "); + fixedFragment.append(sendFailedFragment); + } + + if (fixedFragment != null) { + fixedFragmentLength = fixedFragment.length(); + } + + final boolean normalMessagesExist = + numMessagesFragment.length() != 0 || maxFoundPriority != Integer.MIN_VALUE; + String preFixedFragement = ""; + if (normalMessagesExist && fixedFragmentLength != 0) { + preFixedFragement = ", "; + } + int maxPriorityToInclude = -1; // inclusive + int numCharsUsed = + numMessagesFragment.length() + preFixedFragement.length() + fixedFragmentLength; + int numSendersUsed = 0; + while (maxPriorityToInclude < maxFoundPriority) { + if (priorityToLength.containsKey(maxPriorityToInclude + 1)) { + int length = numCharsUsed + priorityToLength.get(maxPriorityToInclude + 1); + if (numCharsUsed > 0) length += 2; + // We must show at least two senders if they exist. If we don't have space for both + // then we will truncate names. + if (length > maxChars && numSendersUsed >= 2) { + break; + } + numCharsUsed = length; + numSendersUsed++; + } + maxPriorityToInclude++; + } + + int numCharsToRemovePerWord = 0; + if (numCharsUsed > maxChars) { + numCharsToRemovePerWord = (numCharsUsed - maxChars) / numSendersUsed; + } + + boolean elided = false; + for (int i = 0; i < numFragments;) { + String fragment0 = fragments[i++]; + if ("".equals(fragment0)) { + // This should be the final fragment. + } else if (SENDER_LIST_TOKEN_ELIDED.equals(fragment0)) { + elided = true; + } else if (SENDER_LIST_TOKEN_NUM_MESSAGES.equals(fragment0)) { + i++; + } else if (SENDER_LIST_TOKEN_NUM_DRAFTS.equals(fragment0)) { + i++; + } else if (SENDER_LIST_TOKEN_SENDING.equals(fragment0)) { + } else if (SENDER_LIST_TOKEN_SEND_FAILED.equals(fragment0)) { + } else { + final String unreadString = fragment0; + final String priorityString = fragments[i++]; + String nameString = fragments[i++]; + if (nameString.length() == 0) nameString = meString.toString(); + if (numCharsToRemovePerWord != 0) { + nameString = nameString.substring( + 0, Math.max(nameString.length() - numCharsToRemovePerWord, 0)); + } + final boolean unread = unreadStatusIsForced + ? forcedUnreadStatus : Integer.parseInt(unreadString) != 0; + final int priority = Integer.parseInt(priorityString); + if (priority <= maxPriorityToInclude) { + if (sb.length() != 0) { + sb.append(elided ? " .. " : ", "); + } + elided = false; + int pos = sb.length(); + sb.append(nameString); + if (unread && unreadStyle != null) { + sb.setSpan(CharacterStyle.wrap(unreadStyle), + pos, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } else { + elided = true; + } + } + } + sb.append(numMessagesFragment); + if (fixedFragmentLength != 0) { + sb.append(preFixedFragement); + sb.append(fixedFragment); + } + } + + /** + * This is a cursor that only defines methods to move throught the results + * and register to hear about changes. All access to the data is left to + * subinterfaces. + */ + public static class MailCursor extends ContentObserver { + + // A list of observers of this cursor. + private Set<MailCursorObserver> mObservers; + + // Updated values are accumulated here before being written out if the + // cursor is asked to persist the changes. + private ContentValues mUpdateValues; + + protected Cursor mCursor; + protected String mAccount; + + public Cursor getCursor() { + return mCursor; + } + + /** + * Constructs the MailCursor given a regular cursor, registering as a + * change observer of the cursor. + * @param account the account the cursor is associated with + * @param cursor the underlying cursor + */ + protected MailCursor(String account, Cursor cursor) { + super(new Handler()); + mObservers = new HashSet<MailCursorObserver>(); + mCursor = cursor; + mAccount = account; + if (mCursor != null) mCursor.registerContentObserver(this); + } + + /** + * Gets the account associated with this cursor. + * @return the account. + */ + public String getAccount() { + return mAccount; + } + + protected void checkThread() { + // Turn this on when activity code no longer runs in the sync thread + // after notifications of changes. +// Thread currentThread = Thread.currentThread(); +// if (currentThread != mThread) { +// throw new RuntimeException("Accessed from the wrong thread"); +// } + } + + /** + * Lazily constructs a map of update values to apply to the database + * if requested. This map is cleared out when we move to a different + * item in the result set. + * + * @return a map of values to be applied by an update. + */ + protected ContentValues getUpdateValues() { + if (mUpdateValues == null) { + mUpdateValues = new ContentValues(); + } + return mUpdateValues; + } + + /** + * Called whenever mCursor is changed to point to a different row. + * Subclasses should override this if they need to clear out state + * when this happens. + * + * Subclasses must call the inherited version if they override this. + */ + protected void onCursorPositionChanged() { + mUpdateValues = null; + } + + // ********* MailCursor + + /** + * Returns the numbers of rows in the cursor. + * + * @return the number of rows in the cursor. + */ + final public int count() { + if (mCursor != null) { + return mCursor.getCount(); + } else { + return 0; + } + } + + /** + * @return the current position of this cursor, or -1 if this cursor + * has not been initialized. + */ + final public int position() { + if (mCursor != null) { + return mCursor.getPosition(); + } else { + return -1; + } + } + + /** + * Move the cursor to an absolute position. The valid + * range of vaues is -1 <= position <= count. + * + * <p>This method will return true if the request destination was + * reachable, otherwise it returns false. + * + * @param position the zero-based position to move to. + * @return whether the requested move fully succeeded. + */ + final public boolean moveTo(int position) { + checkCursor(); + checkThread(); + boolean moved = mCursor.moveToPosition(position); + if (moved) onCursorPositionChanged(); + return moved; + } + + /** + * Move the cursor to the next row. + * + * <p>This method will return false if the cursor is already past the + * last entry in the result set. + * + * @return whether the move succeeded. + */ + final public boolean next() { + checkCursor(); + checkThread(); + boolean moved = mCursor.moveToNext(); + if (moved) onCursorPositionChanged(); + return moved; + } + + /** + * Release all resources and locks associated with the cursor. The + * cursor will not be valid after this function is called. + */ + final public void release() { + if (mCursor != null) { + mCursor.unregisterContentObserver(this); + mCursor.deactivate(); + } + } + + final public void registerContentObserver(ContentObserver observer) { + mCursor.registerContentObserver(observer); + } + + final public void unregisterContentObserver(ContentObserver observer) { + mCursor.unregisterContentObserver(observer); + } + + final public void registerDataSetObserver(DataSetObserver observer) { + mCursor.registerDataSetObserver(observer); + } + + final public void unregisterDataSetObserver(DataSetObserver observer) { + mCursor.unregisterDataSetObserver(observer); + } + + /** + * Register an observer to hear about changes to the cursor. + * + * @param observer the observer to register + */ + final public void registerObserver(MailCursorObserver observer) { + mObservers.add(observer); + } + + /** + * Unregister an observer. + * + * @param observer the observer to unregister + */ + final public void unregisterObserver(MailCursorObserver observer) { + mObservers.remove(observer); + } + + // ********* ContentObserver + + @Override + final public boolean deliverSelfNotifications() { + return false; + } + + @Override + public void onChange(boolean selfChange) { + if (Config.DEBUG) { + Log.d(TAG, "MailCursor is notifying " + mObservers.size() + " observers"); + } + for (MailCursorObserver o: mObservers) { + o.onCursorChanged(this); + } + } + + protected void checkCursor() { + if (mCursor == null) { + throw new IllegalStateException( + "cannot read from an insertion cursor"); + } + } + + /** + * Returns the string value of the column, or "" if the value is null. + */ + protected String getStringInColumn(int columnIndex) { + checkCursor(); + return toNonnullString(mCursor.getString(columnIndex)); + } + } + + /** + * A MailCursor observer is notified of changes to the result set of a + * cursor. + */ + public interface MailCursorObserver { + + /** + * Called when the result set of a cursor has changed. + * + * @param cursor the cursor whose result set has changed. + */ + void onCursorChanged(MailCursor cursor); + } + + /** + * A cursor over labels. + */ + public final class LabelCursor extends MailCursor { + + private int mNameIndex; + private int mNumConversationsIndex; + private int mNumUnreadConversationsIndex; + + private LabelCursor(String account, Cursor cursor) { + super(account, cursor); + + mNameIndex = mCursor.getColumnIndexOrThrow(LabelColumns.CANONICAL_NAME); + mNumConversationsIndex = + mCursor.getColumnIndexOrThrow(LabelColumns.NUM_CONVERSATIONS); + mNumUnreadConversationsIndex = mCursor.getColumnIndexOrThrow( + LabelColumns.NUM_UNREAD_CONVERSATIONS); + } + + /** + * Gets the canonical name of the current label. + * + * @return the current label's name. + */ + public String getName() { + return getStringInColumn(mNameIndex); + } + + /** + * Gets the number of conversations with this label. + * + * @return the number of conversations with this label. + */ + public int getNumConversations() { + return mCursor.getInt(mNumConversationsIndex); + } + + /** + * Gets the number of unread conversations with this label. + * + * @return the number of unread conversations with this label. + */ + public int getNumUnreadConversations() { + return mCursor.getInt(mNumUnreadConversationsIndex); + } + } + + /** + * This is a map of labels. TODO: make it observable. + */ + public static final class LabelMap extends Observable { + private final static ContentValues EMPTY_CONTENT_VALUES = new ContentValues(); + + private ContentQueryMap mQueryMap; + private SortedSet<String> mSortedUserLabels; + private Map<String, Long> mCanonicalNameToId; + + private long mLabelIdSent; + private long mLabelIdInbox; + private long mLabelIdDraft; + private long mLabelIdUnread; + private long mLabelIdTrash; + private long mLabelIdSpam; + private long mLabelIdStarred; + private long mLabelIdChat; + private long mLabelIdVoicemail; + private long mLabelIdIgnored; + private long mLabelIdVoicemailInbox; + private long mLabelIdCached; + private long mLabelIdOutbox; + + private boolean mLabelsSynced = false; + + public LabelMap(ContentResolver contentResolver, String account, boolean keepUpdated) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + Cursor cursor = contentResolver.query( + Uri.withAppendedPath(LABELS_URI, account), LABEL_PROJECTION, null, null, null); + init(cursor, keepUpdated); + } + + public LabelMap(Cursor cursor, boolean keepUpdated) { + init(cursor, keepUpdated); + } + + private void init(Cursor cursor, boolean keepUpdated) { + mQueryMap = new ContentQueryMap(cursor, BaseColumns._ID, keepUpdated, null); + mSortedUserLabels = new TreeSet<String>(java.text.Collator.getInstance()); + mCanonicalNameToId = Maps.newHashMap(); + updateDataStructures(); + mQueryMap.addObserver(new Observer() { + public void update(Observable observable, Object data) { + updateDataStructures(); + setChanged(); + notifyObservers(); + } + }); + } + + /** + * @return whether at least some labels have been synced. + */ + public boolean labelsSynced() { + return mLabelsSynced; + } + + /** + * Updates the data structures that are maintained separately from mQueryMap after the query + * map has changed. + */ + private void updateDataStructures() { + mSortedUserLabels.clear(); + mCanonicalNameToId.clear(); + for (Map.Entry<String, ContentValues> row : mQueryMap.getRows().entrySet()) { + long labelId = Long.valueOf(row.getKey()); + String canonicalName = row.getValue().getAsString(LabelColumns.CANONICAL_NAME); + if (isLabelUserDefined(canonicalName)) { + mSortedUserLabels.add(canonicalName); + } + mCanonicalNameToId.put(canonicalName, labelId); + + if (LABEL_SENT.equals(canonicalName)) { + mLabelIdSent = labelId; + } else if (LABEL_INBOX.equals(canonicalName)) { + mLabelIdInbox = labelId; + } else if (LABEL_DRAFT.equals(canonicalName)) { + mLabelIdDraft = labelId; + } else if (LABEL_UNREAD.equals(canonicalName)) { + mLabelIdUnread = labelId; + } else if (LABEL_TRASH.equals(canonicalName)) { + mLabelIdTrash = labelId; + } else if (LABEL_SPAM.equals(canonicalName)) { + mLabelIdSpam = labelId; + } else if (LABEL_STARRED.equals(canonicalName)) { + mLabelIdStarred = labelId; + } else if (LABEL_CHAT.equals(canonicalName)) { + mLabelIdChat = labelId; + } else if (LABEL_IGNORED.equals(canonicalName)) { + mLabelIdIgnored = labelId; + } else if (LABEL_VOICEMAIL.equals(canonicalName)) { + mLabelIdVoicemail = labelId; + } else if (LABEL_VOICEMAIL_INBOX.equals(canonicalName)) { + mLabelIdVoicemailInbox = labelId; + } else if (LABEL_CACHED.equals(canonicalName)) { + mLabelIdCached = labelId; + } else if (LABEL_OUTBOX.equals(canonicalName)) { + mLabelIdOutbox = labelId; + } + mLabelsSynced = mLabelIdSent != 0 + && mLabelIdInbox != 0 + && mLabelIdDraft != 0 + && mLabelIdUnread != 0 + && mLabelIdTrash != 0 + && mLabelIdSpam != 0 + && mLabelIdStarred != 0 + && mLabelIdChat != 0 + && mLabelIdIgnored != 0 + && mLabelIdVoicemail != 0; + } + } + + public long getLabelIdSent() { + checkLabelsSynced(); + return mLabelIdSent; + } + + public long getLabelIdInbox() { + checkLabelsSynced(); + return mLabelIdInbox; + } + + public long getLabelIdDraft() { + checkLabelsSynced(); + return mLabelIdDraft; + } + + public long getLabelIdUnread() { + checkLabelsSynced(); + return mLabelIdUnread; + } + + public long getLabelIdTrash() { + checkLabelsSynced(); + return mLabelIdTrash; + } + + public long getLabelIdSpam() { + checkLabelsSynced(); + return mLabelIdSpam; + } + + public long getLabelIdStarred() { + checkLabelsSynced(); + return mLabelIdStarred; + } + + public long getLabelIdChat() { + checkLabelsSynced(); + return mLabelIdChat; + } + + public long getLabelIdIgnored() { + checkLabelsSynced(); + return mLabelIdIgnored; + } + + public long getLabelIdVoicemail() { + checkLabelsSynced(); + return mLabelIdVoicemail; + } + + public long getLabelIdVoicemailInbox() { + checkLabelsSynced(); + return mLabelIdVoicemailInbox; + } + + public long getLabelIdCached() { + checkLabelsSynced(); + return mLabelIdCached; + } + + public long getLabelIdOutbox() { + checkLabelsSynced(); + return mLabelIdOutbox; + } + + private void checkLabelsSynced() { + if (!labelsSynced()) { + throw new IllegalStateException("LabelMap not initalized"); + } + } + + /** Returns the list of user-defined labels in alphabetical order. */ + public SortedSet<String> getSortedUserLabels() { + return mSortedUserLabels; + } + + private static final List<String> SORTED_USER_MEANINGFUL_SYSTEM_LABELS = + Lists.newArrayList( + LABEL_INBOX, LABEL_STARRED, LABEL_CHAT, LABEL_SENT, + LABEL_OUTBOX, LABEL_DRAFT, LABEL_ALL, + LABEL_SPAM, LABEL_TRASH); + + + private static final Set<String> USER_MEANINGFUL_SYSTEM_LABELS_SET = + Sets.newHashSet( + SORTED_USER_MEANINGFUL_SYSTEM_LABELS.toArray( + new String[]{})); + + public static List<String> getSortedUserMeaningfulSystemLabels() { + return SORTED_USER_MEANINGFUL_SYSTEM_LABELS; + } + + public static Set<String> getUserMeaningfulSystemLabelsSet() { + return USER_MEANINGFUL_SYSTEM_LABELS_SET; + } + + /** + * If you are ever tempted to remove outbox or draft from this set make sure you have a + * way to stop draft and outbox messages from getting purged before they are sent to the + * server. + */ + private static final Set<String> FORCED_INCLUDED_LABELS = + Sets.newHashSet(LABEL_OUTBOX, LABEL_DRAFT); + + public static Set<String> getForcedIncludedLabels() { + return FORCED_INCLUDED_LABELS; + } + + private static final Set<String> FORCED_INCLUDED_OR_PARTIAL_LABELS = + Sets.newHashSet(LABEL_INBOX); + + public static Set<String> getForcedIncludedOrPartialLabels() { + return FORCED_INCLUDED_OR_PARTIAL_LABELS; + } + + private static final Set<String> FORCED_UNSYNCED_LABELS = + Sets.newHashSet(LABEL_ALL, LABEL_CHAT, LABEL_SPAM, LABEL_TRASH); + + public static Set<String> getForcedUnsyncedLabels() { + return FORCED_UNSYNCED_LABELS; + } + + /** + * Returns the number of conversation with a given label. + * @deprecated + */ + public int getNumConversations(String label) { + return getNumConversations(getLabelId(label)); + } + + /** Returns the number of conversation with a given label. */ + public int getNumConversations(long labelId) { + return getLabelIdValues(labelId).getAsInteger(LabelColumns.NUM_CONVERSATIONS); + } + + /** + * Returns the number of unread conversation with a given label. + * @deprecated + */ + public int getNumUnreadConversations(String label) { + return getNumUnreadConversations(getLabelId(label)); + } + + /** Returns the number of unread conversation with a given label. */ + public int getNumUnreadConversations(long labelId) { + return getLabelIdValues(labelId).getAsInteger(LabelColumns.NUM_UNREAD_CONVERSATIONS); + } + + /** + * @return the canonical name for a label + */ + public String getCanonicalName(long labelId) { + return getLabelIdValues(labelId).getAsString(LabelColumns.CANONICAL_NAME); + } + + /** + * @return the human name for a label + */ + public String getName(long labelId) { + return getLabelIdValues(labelId).getAsString(LabelColumns.NAME); + } + + /** + * @return whether a given label is known + */ + public boolean hasLabel(long labelId) { + return mQueryMap.getRows().containsKey(Long.toString(labelId)); + } + + /** + * @return returns the id of a label given the canonical name + * @deprecated this is only needed because most of the UI uses label names instead of ids + */ + public long getLabelId(String canonicalName) { + if (mCanonicalNameToId.containsKey(canonicalName)) { + return mCanonicalNameToId.get(canonicalName); + } else { + throw new IllegalArgumentException("Unknown canonical name: " + canonicalName); + } + } + + private ContentValues getLabelIdValues(long labelId) { + final ContentValues values = mQueryMap.getValues(Long.toString(labelId)); + if (values != null) { + return values; + } else { + return EMPTY_CONTENT_VALUES; + } + } + + /** Force the map to requery. This should not be necessary outside tests. */ + public void requery() { + mQueryMap.requery(); + } + + public void close() { + mQueryMap.close(); + } + } + + private Map<String, Gmail.LabelMap> mLabelMaps = Maps.newHashMap(); + + public LabelMap getLabelMap(String account) { + Gmail.LabelMap labelMap = mLabelMaps.get(account); + if (labelMap == null) { + labelMap = new Gmail.LabelMap(mContentResolver, account, true /* keepUpdated */); + mLabelMaps.put(account, labelMap); + } + return labelMap; + } + + public enum PersonalLevel { + NOT_TO_ME(0), + TO_ME_AND_OTHERS(1), + ONLY_TO_ME(2); + + private int mLevel; + + PersonalLevel(int level) { + mLevel = level; + } + + public int toInt() { + return mLevel; + } + + public static PersonalLevel fromInt(int level) { + switch (level) { + case 0: return NOT_TO_ME; + case 1: return TO_ME_AND_OTHERS; + case 2: return ONLY_TO_ME; + default: + throw new IllegalArgumentException( + level + " is not a personal level"); + } + } + } + + /** + * Indicates a version of an attachment. + */ + public enum AttachmentRendition { + /** + * The full version of an attachment if it can be handled on the device, otherwise the + * preview. + */ + BEST, + + /** A smaller or simpler version of the attachment, such as a scaled-down image or an HTML + * version of a document. Not always available. + */ + SIMPLE, + } + + /** + * The columns that can be requested when querying an attachment's download URI. See + * getAttachmentDownloadUri. + */ + public static final class AttachmentColumns implements BaseColumns { + + /** Contains a STATUS value from {@link android.provider.Downloads} */ + public static final String STATUS = "status"; + + /** + * The name of the file to open (with ContentProvider.open). If this is empty then continue + * to use the attachment's URI. + * + * TODO: I'm not sure that we need this. See the note in CL 66853-p9. + */ + public static final String FILENAME = "filename"; + } + + /** + * We track where an attachment came from so that we know how to download it and include it + * in new messages. + */ + public enum AttachmentOrigin { + /** Extras are "<conversationId>-<messageId>-<partId>". */ + SERVER_ATTACHMENT, + /** Extras are "<path>". */ + LOCAL_FILE; + + private static final String SERVER_EXTRAS_SEPARATOR = "_"; + + public static String serverExtras( + long conversationId, long messageId, String partId) { + return conversationId + SERVER_EXTRAS_SEPARATOR + + messageId + SERVER_EXTRAS_SEPARATOR + partId; + } + + /** + * @param extras extras as returned by serverExtras + * @return an array of conversationId, messageId, partId (all as strings) + */ + public static String[] splitServerExtras(String extras) { + return TextUtils.split(extras, SERVER_EXTRAS_SEPARATOR); + } + + public static String localFileExtras(Uri path) { + return path.toString(); + } + } + + public static final class Attachment { + /** Identifies the attachment uniquely when combined wih a message id.*/ + public String partId; + + /** The intended filename of the attachment.*/ + public String name; + + /** The native content type.*/ + public String contentType; + + /** The size of the attachment in its native form.*/ + public int size; + + /** + * The content type of the simple version of the attachment. Blank if no simple version is + * available. + */ + public String simpleContentType; + + public AttachmentOrigin origin; + + public String originExtras; + + public String toJoinedString() { + return TextUtils.join( + "|", Lists.newArrayList(partId == null ? "" : partId, + name.replace("|", ""), contentType, + size, simpleContentType, + origin.toString(), originExtras)); + } + + public static Attachment parseJoinedString(String joinedString) { + String[] fragments = TextUtils.split(joinedString, "\\|"); + int i = 0; + Attachment attachment = new Attachment(); + attachment.partId = fragments[i++]; + if (TextUtils.isEmpty(attachment.partId)) { + attachment.partId = null; + } + attachment.name = fragments[i++]; + attachment.contentType = fragments[i++]; + attachment.size = Integer.parseInt(fragments[i++]); + attachment.simpleContentType = fragments[i++]; + attachment.origin = AttachmentOrigin.valueOf(fragments[i++]); + attachment.originExtras = fragments[i++]; + return attachment; + } + } + + /** + * Any given attachment can come in two different renditions (see + * {@link android.provider.Gmail.AttachmentRendition}) and can be saved to the sd card or to a + * cache. The gmail provider automatically syncs some attachments to the cache. Other + * attachments can be downloaded on demand. Attachments in the cache will be purged as needed to + * save space. Attachments on the SD card must be managed by the user or other software. + * + * @param account which account to use + * @param messageId the id of the mesage with the attachment + * @param attachment the attachment + * @param rendition the desired rendition + * @param saveToSd whether the attachment should be saved to (or loaded from) the sd card or + * @return the URI to ask the content provider to open in order to open an attachment. + */ + public static Uri getAttachmentUri( + String account, long messageId, Attachment attachment, + AttachmentRendition rendition, boolean saveToSd) { + if (TextUtils.isEmpty(account)) { + throw new IllegalArgumentException("account is empty"); + } + if (attachment.origin == AttachmentOrigin.LOCAL_FILE) { + return Uri.parse(attachment.originExtras); + } else { + return Uri.parse( + AUTHORITY_PLUS_MESSAGES).buildUpon() + .appendPath(account).appendPath(Long.toString(messageId)) + .appendPath("attachments").appendPath(attachment.partId) + .appendPath(rendition.toString()) + .appendPath(Boolean.toString(saveToSd)) + .build(); + } + } + + /** + * Return the URI to query in order to find out whether an attachment is downloaded. + * + * <p>Querying this will also start a download if necessary. The cursor returned by querying + * this URI can contain the columns in {@link android.provider.Gmail.AttachmentColumns}. + * + * <p>Deleting this URI will cancel the download if it was not started automatically by the + * provider. It will also remove bookkeeping for saveToSd downloads. + * + * @param attachmentUri the attachment URI as returned by getAttachmentUri. The URI's authority + * Gmail.AUTHORITY. If it is not then you should open the file directly. + */ + public static Uri getAttachmentDownloadUri(Uri attachmentUri) { + if (!"content".equals(attachmentUri.getScheme())) { + throw new IllegalArgumentException("Uri's scheme must be 'content': " + attachmentUri); + } + return attachmentUri.buildUpon().appendPath("download").build(); + } + + public enum CursorStatus { + LOADED, + LOADING, + ERROR, // A network error occurred. + } + + /** + * A cursor over messages. + */ + public static final class MessageCursor extends MailCursor { + + private LabelMap mLabelMap; + + private ContentResolver mContentResolver; + + /** + * Only valid if mCursor == null, in which case we are inserting a new + * message. + */ + long mInReplyToLocalMessageId; + boolean mPreserveAttachments; + + private int mIdIndex; + private int mConversationIdIndex; + private int mSubjectIndex; + private int mSnippetIndex; + private int mFromIndex; + private int mToIndex; + private int mCcIndex; + private int mBccIndex; + private int mReplyToIndex; + private int mDateSentMsIndex; + private int mDateReceivedMsIndex; + private int mListInfoIndex; + private int mPersonalLevelIndex; + private int mBodyIndex; + private int mBodyEmbedsExternalResourcesIndex; + private int mLabelIdsIndex; + private int mJoinedAttachmentInfosIndex; + private int mErrorIndex; + + private TextUtils.StringSplitter mLabelIdsSplitter = newMessageLabelIdsSplitter(); + + public MessageCursor(Gmail gmail, ContentResolver cr, String account, Cursor cursor) { + super(account, cursor); + mLabelMap = gmail.getLabelMap(account); + if (cursor == null) { + throw new IllegalArgumentException( + "null cursor passed to MessageCursor()"); + } + + mContentResolver = cr; + + mIdIndex = mCursor.getColumnIndexOrThrow(MessageColumns.ID); + mConversationIdIndex = + mCursor.getColumnIndexOrThrow(MessageColumns.CONVERSATION_ID); + mSubjectIndex = mCursor.getColumnIndexOrThrow(MessageColumns.SUBJECT); + mSnippetIndex = mCursor.getColumnIndexOrThrow(MessageColumns.SNIPPET); + mFromIndex = mCursor.getColumnIndexOrThrow(MessageColumns.FROM); + mToIndex = mCursor.getColumnIndexOrThrow(MessageColumns.TO); + mCcIndex = mCursor.getColumnIndexOrThrow(MessageColumns.CC); + mBccIndex = mCursor.getColumnIndexOrThrow(MessageColumns.BCC); + mReplyToIndex = mCursor.getColumnIndexOrThrow(MessageColumns.REPLY_TO); + mDateSentMsIndex = + mCursor.getColumnIndexOrThrow(MessageColumns.DATE_SENT_MS); + mDateReceivedMsIndex = + mCursor.getColumnIndexOrThrow(MessageColumns.DATE_RECEIVED_MS); + mListInfoIndex = mCursor.getColumnIndexOrThrow(MessageColumns.LIST_INFO); + mPersonalLevelIndex = + mCursor.getColumnIndexOrThrow(MessageColumns.PERSONAL_LEVEL); + mBodyIndex = mCursor.getColumnIndexOrThrow(MessageColumns.BODY); + mBodyEmbedsExternalResourcesIndex = + mCursor.getColumnIndexOrThrow(MessageColumns.EMBEDS_EXTERNAL_RESOURCES); + mLabelIdsIndex = mCursor.getColumnIndexOrThrow(MessageColumns.LABEL_IDS); + mJoinedAttachmentInfosIndex = + mCursor.getColumnIndexOrThrow(MessageColumns.JOINED_ATTACHMENT_INFOS); + mErrorIndex = mCursor.getColumnIndexOrThrow(MessageColumns.ERROR); + + mInReplyToLocalMessageId = 0; + mPreserveAttachments = false; + } + + protected MessageCursor(ContentResolver cr, String account, long inReplyToMessageId, + boolean preserveAttachments) { + super(account, null); + mContentResolver = cr; + mInReplyToLocalMessageId = inReplyToMessageId; + mPreserveAttachments = preserveAttachments; + } + + @Override + protected void onCursorPositionChanged() { + super.onCursorPositionChanged(); + } + + public CursorStatus getStatus() { + Bundle extras = mCursor.getExtras(); + String stringStatus = extras.getString(EXTRA_STATUS); + return CursorStatus.valueOf(stringStatus); + } + + /** Retry a network request after errors. */ + public void retry() { + Bundle input = new Bundle(); + input.putString(RESPOND_INPUT_COMMAND, COMMAND_RETRY); + Bundle output = mCursor.respond(input); + String response = output.getString(RESPOND_OUTPUT_COMMAND_RESPONSE); + assert COMMAND_RESPONSE_OK.equals(response); + } + + /** + * Gets the message id of the current message. Note that this is an + * immutable local message (not, for example, GMail's message id, which + * is immutable). + * + * @return the message's id + */ + public long getMessageId() { + checkCursor(); + return mCursor.getLong(mIdIndex); + } + + /** + * Gets the message's conversation id. This must be immutable. (For + * example, with GMail this should be the original conversation id + * rather than the default notion of converation id.) + * + * @return the message's conversation id + */ + public long getConversationId() { + checkCursor(); + return mCursor.getLong(mConversationIdIndex); + } + + /** + * Gets the message's subject. + * + * @return the message's subject + */ + public String getSubject() { + return getStringInColumn(mSubjectIndex); + } + + /** + * Gets the message's snippet (the short piece of the body). The snippet + * is generated from the body and cannot be set directly. + * + * @return the message's snippet + */ + public String getSnippet() { + return getStringInColumn(mSnippetIndex); + } + + /** + * Gets the message's from address. + * + * @return the message's from address + */ + public String getFromAddress() { + return getStringInColumn(mFromIndex); + } + + /** + * Returns the addresses for the key, if it has been updated, or index otherwise. + */ + private String[] getAddresses(String key, int index) { + ContentValues updated = getUpdateValues(); + String addresses; + if (updated.containsKey(key)) { + addresses = (String)getUpdateValues().get(key); + } else { + addresses = getStringInColumn(index); + } + + return TextUtils.split(addresses, EMAIL_SEPARATOR_PATTERN); + } + + /** + * Gets the message's to addresses. + * @return the message's to addresses + */ + public String[] getToAddresses() { + return getAddresses(MessageColumns.TO, mToIndex); + } + + /** + * Gets the message's cc addresses. + * @return the message's cc addresses + */ + public String[] getCcAddresses() { + return getAddresses(MessageColumns.CC, mCcIndex); + } + + /** + * Gets the message's bcc addresses. + * @return the message's bcc addresses + */ + public String[] getBccAddresses() { + return getAddresses(MessageColumns.BCC, mBccIndex); + } + + /** + * Gets the message's replyTo address. + * + * @return the message's replyTo address + */ + public String[] getReplyToAddress() { + return TextUtils.split(getStringInColumn(mReplyToIndex), EMAIL_SEPARATOR_PATTERN); + } + + public long getDateSentMs() { + checkCursor(); + return mCursor.getLong(mDateSentMsIndex); + } + + public long getDateReceivedMs() { + checkCursor(); + return mCursor.getLong(mDateReceivedMsIndex); + } + + public String getListInfo() { + return getStringInColumn(mListInfoIndex); + } + + public PersonalLevel getPersonalLevel() { + checkCursor(); + int personalLevelInt = mCursor.getInt(mPersonalLevelIndex); + return PersonalLevel.fromInt(personalLevelInt); + } + + /** + * @deprecated + */ + public boolean getExpanded() { + return true; + } + + /** + * Gets the message's body. + * + * @return the message's body + */ + public String getBody() { + return getStringInColumn(mBodyIndex); + } + + /** + * @return whether the message's body contains embedded references to external resources. In + * that case the resources should only be displayed if the user explicitly asks for them to + * be + */ + public boolean getBodyEmbedsExternalResources() { + checkCursor(); + return mCursor.getInt(mBodyEmbedsExternalResourcesIndex) != 0; + } + + /** + * @return a copy of the set of label ids + */ + public Set<Long> getLabelIds() { + String labelNames = mCursor.getString(mLabelIdsIndex); + mLabelIdsSplitter.setString(labelNames); + return getLabelIdsFromLabelIdsString(mLabelIdsSplitter); + } + + /** + * @return a joined string of labels separated by spaces. + */ + public String getRawLabelIds() { + return mCursor.getString(mLabelIdsIndex); + } + + /** + * Adds a label to a message (if add is true) or removes it (if add is + * false). + * + * @param label the label to add or remove + * @param add whether to add or remove the label + */ + public void addOrRemoveLabel(String label, boolean add) { + addOrRemoveLabelOnMessage(mContentResolver, mAccount, getConversationId(), + getMessageId(), label, add); + } + + public ArrayList<Attachment> getAttachmentInfos() { + ArrayList<Attachment> attachments = Lists.newArrayList(); + + String joinedAttachmentInfos = mCursor.getString(mJoinedAttachmentInfosIndex); + if (joinedAttachmentInfos != null) { + for (String joinedAttachmentInfo : + TextUtils.split(joinedAttachmentInfos, ATTACHMENT_INFO_SEPARATOR_PATTERN)) { + + Attachment attachment = Attachment.parseJoinedString(joinedAttachmentInfo); + attachments.add(attachment); + } + } + return attachments; + } + + /** + * @return the error text for the message. Error text gets set if the server rejects a + * message that we try to save or send. If there is error text then the message is no longer + * scheduled to be saved or sent. Calling save() or send() will clear any error as well as + * scheduling another atempt to save or send the message. + */ + public String getErrorText() { + return mCursor.getString(mErrorIndex); + } + } + + /** + * A helper class for creating or updating messags. Use the putXxx methods to provide initial or + * new values for the message. Then save or send the message. To save or send an existing + * message without making other changes to it simply provide an emty ContentValues. + */ + public static class MessageModification { + + /** + * Sets the message's subject. Only valid for drafts. + * + * @param values the ContentValues that will be used to create or update the message + * @param subject the new subject + */ + public static void putSubject(ContentValues values, String subject) { + values.put(MessageColumns.SUBJECT, subject); + } + + /** + * Sets the message's to address. Only valid for drafts. + * + * @param values the ContentValues that will be used to create or update the message + * @param toAddresses the new to addresses + */ + public static void putToAddresses(ContentValues values, String[] toAddresses) { + values.put(MessageColumns.TO, TextUtils.join(EMAIL_SEPARATOR, toAddresses)); + } + + /** + * Sets the message's cc address. Only valid for drafts. + * + * @param values the ContentValues that will be used to create or update the message + * @param ccAddresses the new cc addresses + */ + public static void putCcAddresses(ContentValues values, String[] ccAddresses) { + values.put(MessageColumns.CC, TextUtils.join(EMAIL_SEPARATOR, ccAddresses)); + } + + /** + * Sets the message's bcc address. Only valid for drafts. + * + * @param values the ContentValues that will be used to create or update the message + * @param bccAddresses the new bcc addresses + */ + public static void putBccAddresses(ContentValues values, String[] bccAddresses) { + values.put(MessageColumns.BCC, TextUtils.join(EMAIL_SEPARATOR, bccAddresses)); + } + + /** + * Saves a new body for the message. Only valid for drafts. + * + * @param values the ContentValues that will be used to create or update the message + * @param body the new body of the message + */ + public static void putBody(ContentValues values, String body) { + values.put(MessageColumns.BODY, body); + } + + /** + * Sets the attachments on a message. Only valid for drafts. + * + * @param values the ContentValues that will be used to create or update the message + * @param attachments + */ + public static void putAttachments(ContentValues values, List<Attachment> attachments) { + values.put( + MessageColumns.JOINED_ATTACHMENT_INFOS, joinedAttachmentsString(attachments)); + } + + /** + * Create a new message and save it as a draft or send it. + * + * @param contentResolver the content resolver to use + * @param account the account to use + * @param values the values for the new message + * @param refMessageId the message that is being replied to or forwarded + * @param save whether to save or send the message + * @return the id of the new message + */ + public static long sendOrSaveNewMessage( + ContentResolver contentResolver, String account, + ContentValues values, long refMessageId, boolean save) { + values.put(MessageColumns.FAKE_SAVE, save); + values.put(MessageColumns.FAKE_REF_MESSAGE_ID, refMessageId); + Uri uri = Uri.parse(AUTHORITY_PLUS_MESSAGES + account + "/"); + Uri result = contentResolver.insert(uri, values); + return ContentUris.parseId(result); + } + + /** + * Update an existing draft and save it as a new draft or send it. + * + * @param contentResolver the content resolver to use + * @param account the account to use + * @param messageId the id of the message to update + * @param updateValues the values to change. Unspecified fields will not be altered + * @param save whether to resave the message as a draft or send it + */ + public static void sendOrSaveExistingMessage( + ContentResolver contentResolver, String account, long messageId, + ContentValues updateValues, boolean save) { + updateValues.put(MessageColumns.FAKE_SAVE, save); + updateValues.put(MessageColumns.FAKE_REF_MESSAGE_ID, 0); + Uri uri = Uri.parse( + AUTHORITY_PLUS_MESSAGES + account + "/" + messageId); + contentResolver.update(uri, updateValues, null, null); + } + + /** + * The string produced here is parsed by Gmail.MessageCursor#getAttachmentInfos. + */ + public static String joinedAttachmentsString(List<Gmail.Attachment> attachments) { + StringBuilder attachmentsSb = new StringBuilder(); + for (Gmail.Attachment attachment : attachments) { + if (attachmentsSb.length() != 0) { + attachmentsSb.append(Gmail.ATTACHMENT_INFO_SEPARATOR); + } + attachmentsSb.append(attachment.toJoinedString()); + } + return attachmentsSb.toString(); + } + + } + + /** + * A cursor over conversations. + * + * "Conversation" refers to the information needed to populate a list of + * conversations, not all of the messages in a conversation. + */ + public static final class ConversationCursor extends MailCursor { + + private LabelMap mLabelMap; + + private int mConversationIdIndex; + private int mSubjectIndex; + private int mSnippetIndex; + private int mFromIndex; + private int mDateIndex; + private int mPersonalLevelIndex; + private int mLabelIdsIndex; + private int mNumMessagesIndex; + private int mMaxMessageIdIndex; + private int mHasAttachmentsIndex; + private int mHasMessagesWithErrorsIndex; + private int mForceAllUnreadIndex; + + private TextUtils.StringSplitter mLabelIdsSplitter = newConversationLabelIdsSplitter(); + + private ConversationCursor(Gmail gmail, String account, Cursor cursor) { + super(account, cursor); + mLabelMap = gmail.getLabelMap(account); + + mConversationIdIndex = + mCursor.getColumnIndexOrThrow(ConversationColumns.ID); + mSubjectIndex = mCursor.getColumnIndexOrThrow(ConversationColumns.SUBJECT); + mSnippetIndex = mCursor.getColumnIndexOrThrow(ConversationColumns.SNIPPET); + mFromIndex = mCursor.getColumnIndexOrThrow(ConversationColumns.FROM); + mDateIndex = mCursor.getColumnIndexOrThrow(ConversationColumns.DATE); + mPersonalLevelIndex = + mCursor.getColumnIndexOrThrow(ConversationColumns.PERSONAL_LEVEL); + mLabelIdsIndex = + mCursor.getColumnIndexOrThrow(ConversationColumns.LABEL_IDS); + mNumMessagesIndex = mCursor.getColumnIndexOrThrow(ConversationColumns.NUM_MESSAGES); + mMaxMessageIdIndex = mCursor.getColumnIndexOrThrow(ConversationColumns.MAX_MESSAGE_ID); + mHasAttachmentsIndex = + mCursor.getColumnIndexOrThrow(ConversationColumns.HAS_ATTACHMENTS); + mHasMessagesWithErrorsIndex = + mCursor.getColumnIndexOrThrow(ConversationColumns.HAS_MESSAGES_WITH_ERRORS); + mForceAllUnreadIndex = + mCursor.getColumnIndexOrThrow(ConversationColumns.FORCE_ALL_UNREAD); + } + + @Override + protected void onCursorPositionChanged() { + super.onCursorPositionChanged(); + } + + public CursorStatus getStatus() { + Bundle extras = mCursor.getExtras(); + String stringStatus = extras.getString(EXTRA_STATUS); + return CursorStatus.valueOf(stringStatus); + } + + /** Retry a network request after errors. */ + public void retry() { + Bundle input = new Bundle(); + input.putString(RESPOND_INPUT_COMMAND, COMMAND_RETRY); + Bundle output = mCursor.respond(input); + String response = output.getString(RESPOND_OUTPUT_COMMAND_RESPONSE); + assert COMMAND_RESPONSE_OK.equals(response); + } + + /** + * When a conversation cursor is created it becomes the active network cursor, which means + * that it will fetch results from the network if it needs to in order to show all mail that + * matches its query. If you later want to requery an older cursor and would like that + * cursor to be the active cursor you need to call this method before requerying. + */ + public void becomeActiveNetworkCursor() { + Bundle input = new Bundle(); + input.putString(RESPOND_INPUT_COMMAND, COMMAND_ACTIVATE); + Bundle output = mCursor.respond(input); + String response = output.getString(RESPOND_OUTPUT_COMMAND_RESPONSE); + assert COMMAND_RESPONSE_OK.equals(response); + } + + /** + * Tells the cursor whether its contents are visible to the user. The cursor will + * automatically broadcast intents to remove any matching new-mail notifications when this + * cursor's results become visible and, if they are visible, when the cursor is requeried. + * + * Note that contents shown in an activity that is resumed but not focused + * (onWindowFocusChanged/hasWindowFocus) then results shown in that activity do not count + * as visible. (This happens when the activity is behind the lock screen or a dialog.) + * + * @param visible whether the contents of this cursor are visible to the user. + */ + public void setContentsVisibleToUser(boolean visible) { + Bundle input = new Bundle(); + input.putString(RESPOND_INPUT_COMMAND, COMMAND_SET_VISIBLE); + input.putBoolean(SET_VISIBLE_PARAM_VISIBLE, visible); + Bundle output = mCursor.respond(input); + String response = output.getString(RESPOND_OUTPUT_COMMAND_RESPONSE); + assert COMMAND_RESPONSE_OK.equals(response); + } + + /** + * Gets the conversation id. This is immutable. (The server calls it the original + * conversation id.) + * + * @return the conversation id + */ + public long getConversationId() { + return mCursor.getLong(mConversationIdIndex); + } + + /** + * Returns the instructions for building from snippets. Pass this to getFromSnippetHtml + * in order to actually build the snippets. + * @return snippet instructions for use by getFromSnippetHtml() + */ + public String getFromSnippetInstructions() { + return getStringInColumn(mFromIndex); + } + + /** + * Gets the conversation's subject. + * + * @return the subject + */ + public String getSubject() { + return getStringInColumn(mSubjectIndex); + } + + /** + * Gets the conversation's snippet. + * + * @return the snippet + */ + public String getSnippet() { + return getStringInColumn(mSnippetIndex); + } + + /** + * Get's the conversation's personal level. + * + * @return the personal level. + */ + public PersonalLevel getPersonalLevel() { + int personalLevelInt = mCursor.getInt(mPersonalLevelIndex); + return PersonalLevel.fromInt(personalLevelInt); + } + + /** + * @return a copy of the set of labels. To add or remove labels call + * MessageCursor.addOrRemoveLabel on each message in the conversation. + * @deprecated use getLabelIds + */ + public Set<String> getLabels() { + return getLabels(getRawLabelIds(), mLabelMap); + } + + /** + * @return a copy of the set of labels. To add or remove labels call + * MessageCursor.addOrRemoveLabel on each message in the conversation. + */ + public Set<Long> getLabelIds() { + mLabelIdsSplitter.setString(getRawLabelIds()); + return getLabelIdsFromLabelIdsString(mLabelIdsSplitter); + } + + /** + * Returns the set of labels using the raw labels from a previous getRawLabels() + * as input. + * @return a copy of the set of labels. To add or remove labels call + * MessageCursor.addOrRemoveLabel on each message in the conversation. + */ + public Set<String> getLabels(String rawLabelIds, LabelMap labelMap) { + mLabelIdsSplitter.setString(rawLabelIds); + return getCanonicalNamesFromLabelIdsString(labelMap, mLabelIdsSplitter); + } + + /** + * @return a joined string of labels separated by spaces. Use + * getLabels(rawLabels) to convert this to a Set of labels. + */ + public String getRawLabelIds() { + return mCursor.getString(mLabelIdsIndex); + } + + /** + * @return the number of messages in the conversation + */ + public int getNumMessages() { + return mCursor.getInt(mNumMessagesIndex); + } + + /** + * @return the max message id in the conversation + */ + public long getMaxServerMessageId() { + return mCursor.getLong(mMaxMessageIdIndex); + } + + public long getDateMs() { + return mCursor.getLong(mDateIndex); + } + + public boolean hasAttachments() { + return mCursor.getInt(mHasAttachmentsIndex) != 0; + } + + public boolean hasMessagesWithErrors() { + return mCursor.getInt(mHasMessagesWithErrorsIndex) != 0; + } + + public boolean getForceAllUnread() { + return !mCursor.isNull(mForceAllUnreadIndex) + && mCursor.getInt(mForceAllUnreadIndex) != 0; + } + } +} |