| /* |
| * 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.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 { |
| // Set to true to enable extra debugging. |
| private static final boolean DEBUG = false; |
| |
| 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 (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) { |
| Integer unreadConversations = |
| getLabelIdValues(labelId).getAsInteger(LabelColumns.NUM_UNREAD_CONVERSATIONS); |
| // There seems to be a race condition here that can get the label maps into a bad |
| // state and lose state on a particular label. |
| if (unreadConversations == null) { |
| return 0; |
| } else { |
| return unreadConversations; |
| } |
| } |
| |
| /** |
| * @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; |
| } |
| } |
| } |