/* * 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) { 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; } } }