/*
* Copyright (C) 2008 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 com.android.emailcommon.utility;
import android.app.Activity;
import android.app.Fragment;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.CursorWrapper;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.StrictMode;
import android.provider.OpenableColumns;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import android.util.Base64;
import android.util.Log;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import com.android.emailcommon.Logging;
import com.android.emailcommon.provider.Account;
import com.android.emailcommon.provider.EmailContent;
import com.android.emailcommon.provider.EmailContent.AccountColumns;
import com.android.emailcommon.provider.EmailContent.Attachment;
import com.android.emailcommon.provider.EmailContent.AttachmentColumns;
import com.android.emailcommon.provider.EmailContent.HostAuthColumns;
import com.android.emailcommon.provider.EmailContent.MailboxColumns;
import com.android.emailcommon.provider.EmailContent.Message;
import com.android.emailcommon.provider.EmailContent.MessageColumns;
import com.android.emailcommon.provider.HostAuth;
import com.android.emailcommon.provider.Mailbox;
import com.android.emailcommon.provider.ProviderUnavailableException;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.GregorianCalendar;
import java.util.HashSet;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Pattern;
public class Utility {
public static final Charset UTF_8 = Charset.forName("UTF-8");
public static final Charset ASCII = Charset.forName("US-ASCII");
public static final String[] EMPTY_STRINGS = new String[0];
public static final Long[] EMPTY_LONGS = new Long[0];
// "GMT" + "+" or "-" + 4 digits
private static final Pattern DATE_CLEANUP_PATTERN_WRONG_TIMEZONE =
Pattern.compile("GMT([-+]\\d{4})$");
private static Handler sMainThreadHandler;
/**
* @return a {@link Handler} tied to the main thread.
*/
public static Handler getMainThreadHandler() {
if (sMainThreadHandler == null) {
// No need to synchronize -- it's okay to create an extra Handler, which will be used
// only once and then thrown away.
sMainThreadHandler = new Handler(Looper.getMainLooper());
}
return sMainThreadHandler;
}
public final static String readInputStream(InputStream in, String encoding) throws IOException {
InputStreamReader reader = new InputStreamReader(in, encoding);
StringBuffer sb = new StringBuffer();
int count;
char[] buf = new char[512];
while ((count = reader.read(buf)) != -1) {
sb.append(buf, 0, count);
}
return sb.toString();
}
public final static boolean arrayContains(Object[] a, Object o) {
int index = arrayIndex(a, o);
return (index >= 0);
}
public final static int arrayIndex(Object[] a, Object o) {
for (int i = 0, count = a.length; i < count; i++) {
if (a[i].equals(o)) {
return i;
}
}
return -1;
}
/**
* Returns a concatenated string containing the output of every Object's
* toString() method, each separated by the given separator character.
*/
public static String combine(Object[] parts, char separator) {
if (parts == null) {
return null;
}
StringBuffer sb = new StringBuffer();
for (int i = 0; i < parts.length; i++) {
sb.append(parts[i].toString());
if (i < parts.length - 1) {
sb.append(separator);
}
}
return sb.toString();
}
public static String base64Decode(String encoded) {
if (encoded == null) {
return null;
}
byte[] decoded = Base64.decode(encoded, Base64.DEFAULT);
return new String(decoded);
}
public static String base64Encode(String s) {
if (s == null) {
return s;
}
return Base64.encodeToString(s.getBytes(), Base64.NO_WRAP);
}
public static boolean isTextViewNotEmpty(TextView view) {
return !TextUtils.isEmpty(view.getText());
}
public static boolean isPortFieldValid(TextView view) {
CharSequence chars = view.getText();
if (TextUtils.isEmpty(chars)) return false;
Integer port;
// In theory, we can't get an illegal value here, since the field is monitored for valid
// numeric input. But this might be used elsewhere without such a check.
try {
port = Integer.parseInt(chars.toString());
} catch (NumberFormatException e) {
return false;
}
return port > 0 && port < 65536;
}
/**
* Validate a hostname name field.
*
* Because we just use the {@link URI} class for validation, it'll accept some invalid
* host names, but it works well enough...
*/
public static boolean isServerNameValid(TextView view) {
return isServerNameValid(view.getText().toString());
}
public static boolean isServerNameValid(String serverName) {
serverName = serverName.trim();
if (TextUtils.isEmpty(serverName)) {
return false;
}
try {
URI uri = new URI(
"http",
null,
serverName,
-1,
null, // path
null, // query
null);
return true;
} catch (URISyntaxException e) {
return false;
}
}
/**
* Ensures that the given string starts and ends with the double quote character. The string is
* not modified in any way except to add the double quote character to start and end if it's not
* already there.
*
* TODO: Rename this, because "quoteString()" can mean so many different things.
*
* sample -> "sample"
* "sample" -> "sample"
* ""sample"" -> "sample"
* "sample"" -> "sample"
* sa"mp"le -> "sa"mp"le"
* "sa"mp"le" -> "sa"mp"le"
* (empty string) -> ""
* " -> ""
*/
public static String quoteString(String s) {
if (s == null) {
return null;
}
if (!s.matches("^\".*\"$")) {
return "\"" + s + "\"";
}
else {
return s;
}
}
/**
* A fast version of URLDecoder.decode() that works only with UTF-8 and does only two
* allocations. This version is around 3x as fast as the standard one and I'm using it
* hundreds of times in places that slow down the UI, so it helps.
*/
public static String fastUrlDecode(String s) {
try {
byte[] bytes = s.getBytes("UTF-8");
byte ch;
int length = 0;
for (int i = 0, count = bytes.length; i < count; i++) {
ch = bytes[i];
if (ch == '%') {
int h = (bytes[i + 1] - '0');
int l = (bytes[i + 2] - '0');
if (h > 9) {
h -= 7;
}
if (l > 9) {
l -= 7;
}
bytes[length] = (byte) ((h << 4) | l);
i += 2;
}
else if (ch == '+') {
bytes[length] = ' ';
}
else {
bytes[length] = bytes[i];
}
length++;
}
return new String(bytes, 0, length, "UTF-8");
}
catch (UnsupportedEncodingException uee) {
return null;
}
}
private final static String HOSTAUTH_WHERE_CREDENTIALS = HostAuthColumns.ADDRESS + " like ?"
+ " and " + HostAuthColumns.LOGIN + " like ? ESCAPE '\\'"
+ " and " + HostAuthColumns.PROTOCOL + " not like \"smtp\"";
private final static String ACCOUNT_WHERE_HOSTAUTH = AccountColumns.HOST_AUTH_KEY_RECV + "=?";
/**
* Look for an existing account with the same username & server
*
* @param context a system context
* @param allowAccountId this account Id will not trigger (when editing an existing account)
* @param hostName the server's address
* @param userLogin the user's login string
* @result null = no matching account found. Account = matching account
*/
public static Account findExistingAccount(Context context, long allowAccountId,
String hostName, String userLogin) {
ContentResolver resolver = context.getContentResolver();
String userName = userLogin.replace("_", "\\_");
Cursor c = resolver.query(HostAuth.CONTENT_URI, HostAuth.ID_PROJECTION,
HOSTAUTH_WHERE_CREDENTIALS, new String[] { hostName, userName }, null);
if (c == null) throw new ProviderUnavailableException();
try {
while (c.moveToNext()) {
long hostAuthId = c.getLong(HostAuth.ID_PROJECTION_COLUMN);
// Find account with matching hostauthrecv key, and return it
Cursor c2 = resolver.query(Account.CONTENT_URI, Account.ID_PROJECTION,
ACCOUNT_WHERE_HOSTAUTH, new String[] { Long.toString(hostAuthId) }, null);
try {
while (c2.moveToNext()) {
long accountId = c2.getLong(Account.ID_PROJECTION_COLUMN);
if (accountId != allowAccountId) {
Account account = Account.restoreAccountWithId(context, accountId);
if (account != null) {
return account;
}
}
}
} finally {
c2.close();
}
}
} finally {
c.close();
}
return null;
}
/**
* Generate a random message-id header for locally-generated messages.
*/
public static String generateMessageId() {
StringBuffer sb = new StringBuffer();
sb.append("<");
for (int i = 0; i < 24; i++) {
sb.append(Integer.toString((int)(Math.random() * 35), 36));
}
sb.append(".");
sb.append(Long.toString(System.currentTimeMillis()));
sb.append("@email.android.com>");
return sb.toString();
}
/**
* Generate a time in milliseconds from a date string that represents a date/time in GMT
* @param date string in format 20090211T180303Z (rfc2445, iCalendar).
* @return the time in milliseconds (since Jan 1, 1970)
*/
public static long parseDateTimeToMillis(String date) {
GregorianCalendar cal = parseDateTimeToCalendar(date);
return cal.getTimeInMillis();
}
/**
* Generate a GregorianCalendar from a date string that represents a date/time in GMT
* @param date string in format 20090211T180303Z (rfc2445, iCalendar).
* @return the GregorianCalendar
*/
public static GregorianCalendar parseDateTimeToCalendar(String date) {
GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
Integer.parseInt(date.substring(4, 6)) - 1, Integer.parseInt(date.substring(6, 8)),
Integer.parseInt(date.substring(9, 11)), Integer.parseInt(date.substring(11, 13)),
Integer.parseInt(date.substring(13, 15)));
cal.setTimeZone(TimeZone.getTimeZone("GMT"));
return cal;
}
/**
* Generate a time in milliseconds from an email date string that represents a date/time in GMT
* @param date string in format 2010-02-23T16:00:00.000Z (ISO 8601, rfc3339)
* @return the time in milliseconds (since Jan 1, 1970)
*/
public static long parseEmailDateTimeToMillis(String date) {
GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
Integer.parseInt(date.substring(5, 7)) - 1, Integer.parseInt(date.substring(8, 10)),
Integer.parseInt(date.substring(11, 13)), Integer.parseInt(date.substring(14, 16)),
Integer.parseInt(date.substring(17, 19)));
cal.setTimeZone(TimeZone.getTimeZone("GMT"));
return cal.getTimeInMillis();
}
private static byte[] encode(Charset charset, String s) {
if (s == null) {
return null;
}
final ByteBuffer buffer = charset.encode(CharBuffer.wrap(s));
final byte[] bytes = new byte[buffer.limit()];
buffer.get(bytes);
return bytes;
}
private static String decode(Charset charset, byte[] b) {
if (b == null) {
return null;
}
final CharBuffer cb = charset.decode(ByteBuffer.wrap(b));
return new String(cb.array(), 0, cb.length());
}
/** Converts a String to UTF-8 */
public static byte[] toUtf8(String s) {
return encode(UTF_8, s);
}
/** Builds a String from UTF-8 bytes */
public static String fromUtf8(byte[] b) {
return decode(UTF_8, b);
}
/** Converts a String to ASCII bytes */
public static byte[] toAscii(String s) {
return encode(ASCII, s);
}
/** Builds a String from ASCII bytes */
public static String fromAscii(byte[] b) {
return decode(ASCII, b);
}
/**
* @return true if the input is the first (or only) byte in a UTF-8 character
*/
public static boolean isFirstUtf8Byte(byte b) {
// If the top 2 bits is '10', it's not a first byte.
return (b & 0xc0) != 0x80;
}
public static String byteToHex(int b) {
return byteToHex(new StringBuilder(), b).toString();
}
public static StringBuilder byteToHex(StringBuilder sb, int b) {
b &= 0xFF;
sb.append("0123456789ABCDEF".charAt(b >> 4));
sb.append("0123456789ABCDEF".charAt(b & 0xF));
return sb;
}
public static String replaceBareLfWithCrlf(String str) {
return str.replace("\r", "").replace("\n", "\r\n");
}
/**
* Cancel an {@link AsyncTask}. If it's already running, it'll be interrupted.
*/
public static void cancelTaskInterrupt(AsyncTask, ?, ?> task) {
cancelTask(task, true);
}
/**
* Cancel an {@link EmailAsyncTask}. If it's already running, it'll be interrupted.
*/
public static void cancelTaskInterrupt(EmailAsyncTask, ?, ?> task) {
if (task != null) {
task.cancel(true);
}
}
/**
* Cancel an {@link AsyncTask}.
*
* @param mayInterruptIfRunning true if the thread executing this
* task should be interrupted; otherwise, in-progress tasks are allowed
* to complete.
*/
public static void cancelTask(AsyncTask, ?, ?> task, boolean mayInterruptIfRunning) {
if (task != null && task.getStatus() != AsyncTask.Status.FINISHED) {
task.cancel(mayInterruptIfRunning);
}
}
public static String getSmallHash(final String value) {
final MessageDigest sha;
try {
sha = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException impossible) {
return null;
}
sha.update(Utility.toUtf8(value));
final int hash = getSmallHashFromSha1(sha.digest());
return Integer.toString(hash);
}
/**
* @return a non-negative integer generated from 20 byte SHA-1 hash.
*/
/* package for testing */ static int getSmallHashFromSha1(byte[] sha1) {
final int offset = sha1[19] & 0xf; // SHA1 is 20 bytes.
return ((sha1[offset] & 0x7f) << 24)
| ((sha1[offset + 1] & 0xff) << 16)
| ((sha1[offset + 2] & 0xff) << 8)
| ((sha1[offset + 3] & 0xff));
}
/**
* Try to make a date MIME(RFC 2822/5322)-compliant.
*
* It fixes:
* - "Thu, 10 Dec 09 15:08:08 GMT-0700" to "Thu, 10 Dec 09 15:08:08 -0700"
* (4 digit zone value can't be preceded by "GMT")
* We got a report saying eBay sends a date in this format
*/
public static String cleanUpMimeDate(String date) {
if (TextUtils.isEmpty(date)) {
return date;
}
date = DATE_CLEANUP_PATTERN_WRONG_TIMEZONE.matcher(date).replaceFirst("$1");
return date;
}
public static ByteArrayInputStream streamFromAsciiString(String ascii) {
return new ByteArrayInputStream(toAscii(ascii));
}
/**
* A thread safe way to show a Toast. Can be called from any thread.
*
* @param context context
* @param resId Resource ID of the message string.
*/
public static void showToast(Context context, int resId) {
showToast(context, context.getResources().getString(resId));
}
/**
* A thread safe way to show a Toast. Can be called from any thread.
*
* @param context context
* @param message Message to show.
*/
public static void showToast(final Context context, final String message) {
getMainThreadHandler().post(new Runnable() {
@Override
public void run() {
Toast.makeText(context, message, Toast.LENGTH_LONG).show();
}
});
}
/**
* Run {@code r} on a worker thread, returning the AsyncTask
* @return the AsyncTask; this is primarily for use by unit tests, which require the
* result of the task
*
* @deprecated use {@link EmailAsyncTask#runAsyncParallel} or
* {@link EmailAsyncTask#runAsyncSerial}
*/
@Deprecated
public static AsyncTask runAsync(final Runnable r) {
return new AsyncTask() {
@Override protected Void doInBackground(Void... params) {
r.run();
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Interface used in {@link #createUniqueFile} instead of {@link File#createNewFile()} to make
* it testable.
*/
/* package */ interface NewFileCreator {
public static final NewFileCreator DEFAULT = new NewFileCreator() {
@Override public boolean createNewFile(File f) throws IOException {
return f.createNewFile();
}
};
public boolean createNewFile(File f) throws IOException ;
}
/**
* Creates a new empty file with a unique name in the given directory by appending a hyphen and
* a number to the given filename.
*
* @return a new File object, or null if one could not be created
*/
public static File createUniqueFile(File directory, String filename) throws IOException {
return createUniqueFileInternal(NewFileCreator.DEFAULT, directory, filename);
}
/* package */ static File createUniqueFileInternal(NewFileCreator nfc,
File directory, String filename) throws IOException {
File file = new File(directory, filename);
if (nfc.createNewFile(file)) {
return file;
}
// Get the extension of the file, if any.
int index = filename.lastIndexOf('.');
String format;
if (index != -1) {
String name = filename.substring(0, index);
String extension = filename.substring(index);
format = name + "-%d" + extension;
} else {
format = filename + "-%d";
}
for (int i = 2; i < Integer.MAX_VALUE; i++) {
file = new File(directory, String.format(format, i));
if (nfc.createNewFile(file)) {
return file;
}
}
return null;
}
public interface CursorGetter {
T get(Cursor cursor, int column);
}
private static final CursorGetter LONG_GETTER = new CursorGetter() {
@Override
public Long get(Cursor cursor, int column) {
return cursor.getLong(column);
}
};
private static final CursorGetter INT_GETTER = new CursorGetter() {
@Override
public Integer get(Cursor cursor, int column) {
return cursor.getInt(column);
}
};
private static final CursorGetter STRING_GETTER = new CursorGetter() {
@Override
public String get(Cursor cursor, int column) {
return cursor.getString(column);
}
};
private static final CursorGetter BLOB_GETTER = new CursorGetter() {
@Override
public byte[] get(Cursor cursor, int column) {
return cursor.getBlob(column);
}
};
/**
* @return if {@code original} is to the EmailProvider, add "?limit=1". Otherwise just returns
* {@code original}.
*
* Other providers don't support the limit param. Also, changing URI passed from other apps
* can cause permission errors.
*/
/* package */ static Uri buildLimitOneUri(Uri original) {
if ("content".equals(original.getScheme()) &&
EmailContent.AUTHORITY.equals(original.getAuthority())) {
return EmailContent.uriWithLimit(original, 1);
}
return original;
}
/**
* @return a generic in column {@code column} of the first result row, if the query returns at
* least 1 row. Otherwise returns {@code defaultValue}.
*/
public static T getFirstRowColumn(Context context, Uri uri,
String[] projection, String selection, String[] selectionArgs, String sortOrder,
int column, T defaultValue, CursorGetter getter) {
// Use PARAMETER_LIMIT to restrict the query to the single row we need
uri = buildLimitOneUri(uri);
Cursor c = context.getContentResolver().query(uri, projection, selection, selectionArgs,
sortOrder);
if (c != null) {
try {
if (c.moveToFirst()) {
return getter.get(c, column);
}
} finally {
c.close();
}
}
return defaultValue;
}
/**
* {@link #getFirstRowColumn} for a Long with null as a default value.
*/
public static Long getFirstRowLong(Context context, Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder, int column) {
return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
sortOrder, column, null, LONG_GETTER);
}
/**
* {@link #getFirstRowColumn} for a Long with a provided default value.
*/
public static Long getFirstRowLong(Context context, Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder, int column,
Long defaultValue) {
return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
sortOrder, column, defaultValue, LONG_GETTER);
}
/**
* {@link #getFirstRowColumn} for an Integer with null as a default value.
*/
public static Integer getFirstRowInt(Context context, Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder, int column) {
return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
sortOrder, column, null, INT_GETTER);
}
/**
* {@link #getFirstRowColumn} for an Integer with a provided default value.
*/
public static Integer getFirstRowInt(Context context, Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder, int column,
Integer defaultValue) {
return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
sortOrder, column, defaultValue, INT_GETTER);
}
/**
* {@link #getFirstRowColumn} for a String with null as a default value.
*/
public static String getFirstRowString(Context context, Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder, int column) {
return getFirstRowString(context, uri, projection, selection, selectionArgs, sortOrder,
column, null);
}
/**
* {@link #getFirstRowColumn} for a String with a provided default value.
*/
public static String getFirstRowString(Context context, Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder, int column,
String defaultValue) {
return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
sortOrder, column, defaultValue, STRING_GETTER);
}
/**
* {@link #getFirstRowColumn} for a byte array with a provided default value.
*/
public static byte[] getFirstRowBlob(Context context, Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder, int column,
byte[] defaultValue) {
return getFirstRowColumn(context, uri, projection, selection, selectionArgs, sortOrder,
column, defaultValue, BLOB_GETTER);
}
public static boolean attachmentExists(Context context, Attachment attachment) {
if (attachment == null) {
return false;
} else if (attachment.mContentBytes != null) {
return true;
} else if (TextUtils.isEmpty(attachment.mContentUri)) {
return false;
}
try {
Uri fileUri = Uri.parse(attachment.mContentUri);
try {
InputStream inStream = context.getContentResolver().openInputStream(fileUri);
try {
inStream.close();
} catch (IOException e) {
// Nothing to be done if can't close the stream
}
return true;
} catch (FileNotFoundException e) {
return false;
}
} catch (RuntimeException re) {
Log.w(Logging.LOG_TAG, "attachmentExists RuntimeException=" + re);
return false;
}
}
/**
* Check whether the message with a given id has unloaded attachments. If the message is
* a forwarded message, we look instead at the messages's source for the attachments. If the
* message or forward source can't be found, we return false
* @param context the caller's context
* @param messageId the id of the message
* @return whether or not the message has unloaded attachments
*/
public static boolean hasUnloadedAttachments(Context context, long messageId) {
Message msg = Message.restoreMessageWithId(context, messageId);
if (msg == null) return false;
Attachment[] atts = Attachment.restoreAttachmentsWithMessageId(context, messageId);
for (Attachment att: atts) {
if (!attachmentExists(context, att)) {
// If the attachment doesn't exist and isn't marked for download, we're in trouble
// since the outbound message will be stuck indefinitely in the Outbox. Instead,
// we'll just delete the attachment and continue; this is far better than the
// alternative. In theory, this situation shouldn't be possible.
if ((att.mFlags & (Attachment.FLAG_DOWNLOAD_FORWARD |
Attachment.FLAG_DOWNLOAD_USER_REQUEST)) == 0) {
Log.d(Logging.LOG_TAG, "Unloaded attachment isn't marked for download: " +
att.mFileName + ", #" + att.mId);
Attachment.delete(context, Attachment.CONTENT_URI, att.mId);
} else if (att.mContentUri != null) {
// In this case, the attachment file is gone from the cache; let's clear the
// contentUri; this should be a very unusual case
ContentValues cv = new ContentValues();
cv.putNull(AttachmentColumns.CONTENT_URI);
Attachment.update(context, Attachment.CONTENT_URI, att.mId, cv);
}
return true;
}
}
return false;
}
/**
* Convenience method wrapping calls to retrieve columns from a single row, via EmailProvider.
* The arguments are exactly the same as to contentResolver.query(). Results are returned in
* an array of Strings corresponding to the columns in the projection. If the cursor has no
* rows, null is returned.
*/
public static String[] getRowColumns(Context context, Uri contentUri, String[] projection,
String selection, String[] selectionArgs) {
String[] values = new String[projection.length];
ContentResolver cr = context.getContentResolver();
Cursor c = cr.query(contentUri, projection, selection, selectionArgs, null);
try {
if (c.moveToFirst()) {
for (int i = 0; i < projection.length; i++) {
values[i] = c.getString(i);
}
} else {
return null;
}
} finally {
c.close();
}
return values;
}
/**
* Convenience method for retrieving columns from a particular row in EmailProvider.
* Passed in here are a base uri (e.g. Message.CONTENT_URI), the unique id of a row, and
* a projection. This method calls the previous one with the appropriate URI.
*/
public static String[] getRowColumns(Context context, Uri baseUri, long id,
String ... projection) {
return getRowColumns(context, ContentUris.withAppendedId(baseUri, id), projection, null,
null);
}
public static boolean isExternalStorageMounted() {
return Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED);
}
/**
* Class that supports running any operation for each account.
*/
public abstract static class ForEachAccount extends AsyncTask {
private final Context mContext;
public ForEachAccount(Context context) {
mContext = context;
}
@Override
protected final Long[] doInBackground(Void... params) {
ArrayList ids = new ArrayList();
Cursor c = mContext.getContentResolver().query(Account.CONTENT_URI,
Account.ID_PROJECTION, null, null, null);
try {
while (c.moveToNext()) {
ids.add(c.getLong(Account.ID_PROJECTION_COLUMN));
}
} finally {
c.close();
}
return ids.toArray(EMPTY_LONGS);
}
@Override
protected final void onPostExecute(Long[] ids) {
if (ids != null && !isCancelled()) {
for (long id : ids) {
performAction(id);
}
}
onFinished();
}
/**
* This method will be called for each account.
*/
protected abstract void performAction(long accountId);
/**
* Called when the iteration is finished.
*/
protected void onFinished() {
}
}
/**
* Updates the last seen message key in the mailbox data base for the INBOX of the currently
* shown account. If the account is {@link Account#ACCOUNT_ID_COMBINED_VIEW}, the INBOX for
* all accounts are updated.
* @return an {@link EmailAsyncTask} for test only.
*/
public static EmailAsyncTask updateLastNotifiedMessageKey(
final Context context, final long mailboxId) {
return EmailAsyncTask.runAsyncParallel(new Runnable() {
private void updateLastSeenMessageKeyForMailbox(long mailboxId) {
ContentResolver resolver = context.getContentResolver();
if (mailboxId == Mailbox.QUERY_ALL_INBOXES) {
Cursor c = resolver.query(
Mailbox.CONTENT_URI, EmailContent.ID_PROJECTION, Mailbox.TYPE + "=?",
new String[] { Integer.toString(Mailbox.TYPE_INBOX) }, null);
if (c == null) throw new ProviderUnavailableException();
try {
while (c.moveToNext()) {
final long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
updateLastSeenMessageKeyForMailbox(id);
}
} finally {
c.close();
}
} else if (mailboxId > 0L) {
Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId);
// mailbox has been removed
if (mailbox == null) {
return;
}
// We use the highest _id for the account the mailbox table as the "last seen
// message key". We don't care if the message has been read or not. We only
// need a point at which we can compare against in the future. By setting this
// value, we are claiming that every message before this has potentially been
// seen by the user.
long mostRecentMessageId = Utility.getFirstRowLong(context,
ContentUris.withAppendedId(
EmailContent.MAILBOX_MOST_RECENT_MESSAGE_URI, mailboxId),
Message.ID_COLUMN_PROJECTION, null, null, null,
Message.ID_MAILBOX_COLUMN_ID, -1L);
long lastNotifiedMessageId = mailbox.mLastNotifiedMessageKey;
// Only update the db if the value has changed
if (mostRecentMessageId != lastNotifiedMessageId) {
Log.d(Logging.LOG_TAG, "Most recent = " + mostRecentMessageId +
", last notified: " + lastNotifiedMessageId +
"; updating last notified");
ContentValues values = mailbox.toContentValues();
values.put(MailboxColumns.LAST_NOTIFIED_MESSAGE_KEY, mostRecentMessageId);
resolver.update(
Mailbox.CONTENT_URI,
values,
EmailContent.ID_SELECTION,
new String[] { Long.toString(mailbox.mId) });
} else {
Log.d(Logging.LOG_TAG, "Most recent = last notified; no change");
}
}
}
@Override
public void run() {
updateLastSeenMessageKeyForMailbox(mailboxId);
}
});
}
public static long[] toPrimitiveLongArray(Collection collection) {
// Need to do this manually because we're converting to a primitive long array, not
// a Long array.
final int size = collection.size();
final long[] ret = new long[size];
// Collection doesn't have get(i). (Iterable doesn't have size())
int i = 0;
for (Long value : collection) {
ret[i++] = value;
}
return ret;
}
public static Set toLongSet(long[] array) {
// Need to do this manually because we're converting from a primitive long array, not
// a Long array.
final int size = array.length;
HashSet ret = new HashSet(size);
for (int i = 0; i < size; i++) {
ret.add(array[i]);
}
return ret;
}
/**
* Workaround for the {@link ListView#smoothScrollToPosition} randomly scroll the view bug
* if it's called right after {@link ListView#setAdapter}.
*/
public static void listViewSmoothScrollToPosition(final Activity activity,
final ListView listView, final int position) {
// Workarond: delay-call smoothScrollToPosition()
new Handler().post(new Runnable() {
@Override
public void run() {
if (activity.isFinishing()) {
return; // Activity being destroyed
}
listView.smoothScrollToPosition(position);
}
});
}
private static final String[] ATTACHMENT_META_NAME_PROJECTION = {
OpenableColumns.DISPLAY_NAME
};
private static final int ATTACHMENT_META_NAME_COLUMN_DISPLAY_NAME = 0;
/**
* @return Filename of a content of {@code contentUri}. If the provider doesn't provide the
* filename, returns the last path segment of the URI.
*/
public static String getContentFileName(Context context, Uri contentUri) {
String name = getFirstRowString(context, contentUri, ATTACHMENT_META_NAME_PROJECTION, null,
null, null, ATTACHMENT_META_NAME_COLUMN_DISPLAY_NAME);
if (name == null) {
name = contentUri.getLastPathSegment();
}
return name;
}
/**
* Append a bold span to a {@link SpannableStringBuilder}.
*/
public static SpannableStringBuilder appendBold(SpannableStringBuilder ssb, String text) {
if (!TextUtils.isEmpty(text)) {
SpannableString ss = new SpannableString(text);
ss.setSpan(new StyleSpan(Typeface.BOLD), 0, ss.length(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
ssb.append(ss);
}
return ssb;
}
/**
* Stringify a cursor for logging purpose.
*/
public static String dumpCursor(Cursor c) {
StringBuilder sb = new StringBuilder();
sb.append("[");
while (c != null) {
sb.append(c.getClass()); // Class name may not be available if toString() is overridden
sb.append("/");
sb.append(c.toString());
if (c.isClosed()) {
sb.append(" (closed)");
}
if (c instanceof CursorWrapper) {
c = ((CursorWrapper) c).getWrappedCursor();
sb.append(", ");
} else {
break;
}
}
sb.append("]");
return sb.toString();
}
/**
* Cursor wrapper that remembers where it was closed.
*
* Use {@link #get} to create a wrapped cursor.
* USe {@link #getTraceIfAvailable} to get the stack trace.
* Use {@link #log} to log if/where it was closed.
*/
public static class CloseTraceCursorWrapper extends CursorWrapper {
private static final boolean TRACE_ENABLED = false;
private Exception mTrace;
private CloseTraceCursorWrapper(Cursor cursor) {
super(cursor);
}
@Override
public void close() {
mTrace = new Exception("STACK TRACE");
super.close();
}
public static Exception getTraceIfAvailable(Cursor c) {
if (c instanceof CloseTraceCursorWrapper) {
return ((CloseTraceCursorWrapper) c).mTrace;
} else {
return null;
}
}
public static void log(Cursor c) {
if (c == null) {
return;
}
if (c.isClosed()) {
Log.w(Logging.LOG_TAG, "Cursor was closed here: Cursor=" + c,
getTraceIfAvailable(c));
} else {
Log.w(Logging.LOG_TAG, "Cursor not closed. Cursor=" + c);
}
}
public static Cursor get(Cursor original) {
return TRACE_ENABLED ? new CloseTraceCursorWrapper(original) : original;
}
/* package */ static CloseTraceCursorWrapper alwaysCreateForTest(Cursor original) {
return new CloseTraceCursorWrapper(original);
}
}
/**
* Test that the given strings are equal in a null-pointer safe fashion.
*/
public static boolean areStringsEqual(String s1, String s2) {
return (s1 != null && s1.equals(s2)) || (s1 == null && s2 == null);
}
public static void enableStrictMode(boolean enabled) {
StrictMode.setThreadPolicy(enabled
? new StrictMode.ThreadPolicy.Builder().detectAll().build()
: StrictMode.ThreadPolicy.LAX);
StrictMode.setVmPolicy(enabled
? new StrictMode.VmPolicy.Builder().detectAll().build()
: StrictMode.VmPolicy.LAX);
}
public static String dumpFragment(Fragment f) {
StringWriter sw = new StringWriter();
PrintWriter w = new PrintWriter(sw);
f.dump("", new FileDescriptor(), w, new String[0]);
return sw.toString();
}
/**
* Builds an "in" expression for SQLite.
*
* e.g. "ID" + 1,2,3 -> "ID in (1,2,3)". If {@code values} is empty or null, it returns an
* empty string.
*/
public static String buildInSelection(String columnName, Collection extends Number> values) {
if ((values == null) || (values.size() == 0)) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append(columnName);
sb.append(" in (");
String sep = "";
for (Number n : values) {
sb.append(sep);
sb.append(n.toString());
sep = ",";
}
sb.append(')');
return sb.toString();
}
/**
* Updates the last seen message key in the mailbox data base for the INBOX of the currently
* shown account. If the account is {@link Account#ACCOUNT_ID_COMBINED_VIEW}, the INBOX for
* all accounts are updated.
* @return an {@link EmailAsyncTask} for test only.
*/
public static EmailAsyncTask updateLastSeenMessageKey(final Context context,
final long accountId) {
return EmailAsyncTask.runAsyncParallel(new Runnable() {
private void updateLastSeenMessageKeyForAccount(long accountId) {
ContentResolver resolver = context.getContentResolver();
if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
Cursor c = resolver.query(
Account.CONTENT_URI, EmailContent.ID_PROJECTION, null, null, null);
if (c == null) throw new ProviderUnavailableException();
try {
while (c.moveToNext()) {
final long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
updateLastSeenMessageKeyForAccount(id);
}
} finally {
c.close();
}
} else if (accountId > 0L) {
Mailbox mailbox =
Mailbox.restoreMailboxOfType(context, accountId, Mailbox.TYPE_INBOX);
// mailbox has been removed
if (mailbox == null) {
return;
}
// We use the highest _id for the account the mailbox table as the "last seen
// message key". We don't care if the message has been read or not. We only
// need a point at which we can compare against in the future. By setting this
// value, we are claiming that every message before this has potentially been
// seen by the user.
long messageId = Utility.getFirstRowLong(
context,
Message.CONTENT_URI,
EmailContent.ID_PROJECTION,
MessageColumns.MAILBOX_KEY + "=?",
new String[] { Long.toString(mailbox.mId) },
MessageColumns.ID + " DESC",
EmailContent.ID_PROJECTION_COLUMN, 0L);
long oldLastSeenMessageId = Utility.getFirstRowLong(
context, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mailbox.mId),
new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY },
null, null, null, 0, 0L);
// Only update the db if the value has changed
if (messageId != oldLastSeenMessageId) {
ContentValues values = mailbox.toContentValues();
values.put(MailboxColumns.LAST_SEEN_MESSAGE_KEY, messageId);
resolver.update(
Mailbox.CONTENT_URI,
values,
EmailContent.ID_SELECTION,
new String[] { Long.toString(mailbox.mId) });
}
}
}
@Override
public void run() {
updateLastSeenMessageKeyForAccount(accountId);
}
});
}
}