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