/* * 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"; 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 ,. * *

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,', ','). * *

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 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 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 getLabelIdsFromLabelIdsString( TextUtils.StringSplitter splitter) { Set 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 getCanonicalNamesFromLabelIdsString( LabelMap labelMap, TextUtils.StringSplitter splitter) { Set 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 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:

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/. * *

The goal is to support the following user experience: