diff options
Diffstat (limited to 'src/com/android/emailcommon/utility/Utility.java')
-rw-r--r-- | src/com/android/emailcommon/utility/Utility.java | 1131 |
1 files changed, 1131 insertions, 0 deletions
diff --git a/src/com/android/emailcommon/utility/Utility.java b/src/com/android/emailcommon/utility/Utility.java new file mode 100644 index 000000000..f3dc599c2 --- /dev/null +++ b/src/com/android/emailcommon/utility/Utility.java @@ -0,0 +1,1131 @@ +/* + * 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 com.android.emailcommon.Logging; +import com.android.emailcommon.provider.EmailContent; +import com.android.emailcommon.provider.EmailContent.Account; +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.HostAuth; +import com.android.emailcommon.provider.EmailContent.HostAuthColumns; +import com.android.emailcommon.provider.EmailContent.Mailbox; +import com.android.emailcommon.provider.EmailContent.MailboxColumns; +import com.android.emailcommon.provider.EmailContent.Message; +import com.android.emailcommon.provider.EmailContent.MessageColumns; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +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.Parcel; +import android.os.Parcelable; +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.AbsListView; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +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.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})$"); + + 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) { + for (int i = 0, count = a.length; i < count; i++) { + if (a[i].equals(o)) { + return true; + } + } + return false; + } + + /** + * 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; + } + + /** + * 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; + } + } + + /** + * Apply quoting rules per IMAP RFC, + * quoted = DQUOTE *QUOTED-CHAR DQUOTE + * QUOTED-CHAR = <any TEXT-CHAR except quoted-specials> / "\" quoted-specials + * quoted-specials = DQUOTE / "\" + * + * This is used primarily for IMAP login, but might be useful elsewhere. + * + * NOTE: Not very efficient - you may wish to preflight this, or perhaps it should check + * for trouble chars before calling the replace functions. + * + * @param s The string to be quoted. + * @return A copy of the string, having undergone quoting as described above + */ + public static String imapQuoted(String s) { + + // First, quote any backslashes by replacing \ with \\ + // regex Pattern: \\ (Java string const = \\\\) + // Substitute: \\\\ (Java string const = \\\\\\\\) + String result = s.replaceAll("\\\\", "\\\\\\\\"); + + // Then, quote any double-quotes by replacing " with \" + // regex Pattern: " (Java string const = \") + // Substitute: \\" (Java string const = \\\\\") + result = result.replaceAll("\"", "\\\\\""); + + // return string with quotes around it + return "\"" + result + "\""; + } + + /** + * 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; + } + } + + /** + * Returns the where clause for a message list selection. + * + * TODO This method needs to be rewritten to use the _SELECTION constants defined in + * EmailContent.Message. + * + * MUST NOT be called on the UI thread. + */ + public static String buildMailboxIdSelection(Context context, long mailboxId) { + final ContentResolver resolver = context.getContentResolver(); + final StringBuilder selection = new StringBuilder(); + + // We don't check "flagLoaded" for messages in Outbox. + boolean testFlagLoaded = true; + + if (mailboxId == Mailbox.QUERY_ALL_INBOXES + || mailboxId == Mailbox.QUERY_ALL_DRAFTS + || mailboxId == Mailbox.QUERY_ALL_OUTBOX) { + // query for all mailboxes of type INBOX, DRAFTS, or OUTBOX + int type; + if (mailboxId == Mailbox.QUERY_ALL_INBOXES) { + type = Mailbox.TYPE_INBOX; + } else if (mailboxId == Mailbox.QUERY_ALL_DRAFTS) { + type = Mailbox.TYPE_DRAFTS; + } else { + type = Mailbox.TYPE_OUTBOX; + testFlagLoaded = false; + } + StringBuilder inboxes = new StringBuilder(); + Cursor c = resolver.query(Mailbox.CONTENT_URI, + EmailContent.ID_PROJECTION, + MailboxColumns.TYPE + "=? AND " + MailboxColumns.FLAG_VISIBLE + "=1", + new String[] { Integer.toString(type) }, null); + // build an IN (mailboxId, ...) list + while (c.moveToNext()) { + if (inboxes.length() != 0) { + inboxes.append(","); + } + inboxes.append(c.getLong(EmailContent.ID_PROJECTION_COLUMN)); + } + c.close(); + selection.append(MessageColumns.MAILBOX_KEY + " IN "); + selection.append("(").append(inboxes).append(")"); + } else if (mailboxId == Mailbox.QUERY_ALL_UNREAD) { + selection.append(Message.FLAG_READ + "=0"); + } else if (mailboxId == Mailbox.QUERY_ALL_FAVORITES) { + selection.append(Message.ALL_FAVORITE_SELECTION); + } else { + selection.append(MessageColumns.MAILBOX_KEY + "=" + mailboxId); + if (Mailbox.getMailboxType(context, mailboxId) == Mailbox.TYPE_OUTBOX) { + testFlagLoaded = false; + } + } + + if (testFlagLoaded) { + // POP messages at the initial stage have very little information. (Server UID only) + // This makes sure they're not visible in the message list. + // This means unread counts on the mailbox list can be different from the + // number of messages in the message list, but it should be transient... + selection.append(" AND ").append(Message.FLAG_LOADED_SELECTION); + } + + return selection.toString(); + } + private final static String HOSTAUTH_WHERE_CREDENTIALS = HostAuthColumns.ADDRESS + " like ?" + + " and " + HostAuthColumns.LOGIN + " like ?" + + " 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(); + Cursor c = resolver.query(HostAuth.CONTENT_URI, HostAuth.ID_PROJECTION, + HOSTAUTH_WHERE_CREDENTIALS, new String[] { hostName, userLogin }, null); + 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 AsyncTask}. + * + * @param mayInterruptIfRunning <tt>true</tt> 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. This method uses {@link Activity#runOnUiThread}, so it + * can be called on any thread. + * + * @param activity Parent activity. + * @param resId Resource ID of the message string. + */ + public static void showToast(Activity activity, int resId) { + showToast(activity, activity.getResources().getString(resId)); + } + + /** + * A thread safe way to show a Toast. This method uses {@link Activity#runOnUiThread}, so it + * can be called on any thread. + * + * @param activity Parent activity. + * @param message Message to show. + */ + public static void showToast(final Activity activity, final String message) { + activity.runOnUiThread(new Runnable() { + public void run() { + Toast.makeText(activity, 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 + */ + public static AsyncTask<Void, Void, Void> runAsync(final Runnable r) { + return new AsyncTask<Void, Void, Void>() { + @Override protected Void doInBackground(Void... params) { + r.run(); + return null; + } + }.execute(); + } + + /** + * 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> { + T get(Cursor cursor, int column); + } + + private static final CursorGetter<Long> LONG_GETTER = new CursorGetter<Long>() { + public Long get(Cursor cursor, int column) { + return cursor.getLong(column); + } + }; + + private static final CursorGetter<Integer> INT_GETTER = new CursorGetter<Integer>() { + public Integer get(Cursor cursor, int column) { + return cursor.getInt(column); + } + }; + + private static final CursorGetter<String> STRING_GETTER = new CursorGetter<String>() { + public String get(Cursor cursor, int column) { + return cursor.getString(column); + } + }; + + private static final CursorGetter<byte[]> BLOB_GETTER = new CursorGetter<byte[]>() { + 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 extends Object> T getFirstRowColumn(Context context, Uri uri, + String[] projection, String selection, String[] selectionArgs, String sortOrder, + int column, T defaultValue, CursorGetter<T> 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); + } + + /** + * A class used to restore ListView state (e.g. scroll position) when changing adapter. + */ + public static class ListStateSaver implements Parcelable { + private final Parcelable mState; + + private ListStateSaver(Parcel p) { + mState = p.readParcelable(getClass().getClassLoader()); + } + + public ListStateSaver(AbsListView lv) { + mState = lv.onSaveInstanceState(); + } + + public void restore(AbsListView lv) { + lv.onRestoreInstanceState(mState); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(mState, flags); + } + + public static final Parcelable.Creator<ListStateSaver> CREATOR + = new Parcelable.Creator<ListStateSaver>() { + public ListStateSaver createFromParcel(Parcel in) { + return new ListStateSaver(in); + } + + public ListStateSaver[] newArray(int size) { + return new ListStateSaver[size]; + } + }; + } + + 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<Void, Void, Long[]> { + private final Context mContext; + + public ForEachAccount(Context context) { + mContext = context; + } + + @Override + protected final Long[] doInBackground(Void... params) { + ArrayList<Long> ids = new ArrayList<Long>(); + Cursor c = mContext.getContentResolver().query(EmailContent.Account.CONTENT_URI, + EmailContent.Account.ID_PROJECTION, null, null, null); + try { + while (c.moveToNext()) { + ids.add(c.getLong(EmailContent.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() { + } + } + + public static long[] toPrimitiveLongArray(Collection<Long> collection) { + 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; + } + + /** + * 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); + } + } + + /** + * Create an {@link Intent} to launch an activity as the main entry point. Existing activities + * will all be closed. + */ + public static Intent createRestartAppIntent(Context context, Class<? extends Activity> clazz) { + Intent i = new Intent(context, clazz); + i.setAction(Intent.ACTION_MAIN); + i.addCategory(Intent.CATEGORY_LAUNCHER); + i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return i; + } + + /** + * Legacy URI parser. Used in one of three different scenarios: + * 1. Backup / Restore of account + * 2. Parsing template from provider.xml + * 3. Forcefully creating URI for test + */ + public static void setHostAuthFromString(HostAuth auth, String uriString) + throws URISyntaxException { + URI uri = new URI(uriString); + String path = uri.getPath(); + String domain = null; + if (path != null && path.length() > 0) { + domain = path.substring(1); + } + auth.mDomain = domain; + auth.setLogin(uri.getUserInfo()); + auth.setConnection(uri.getScheme(), uri.getHost(), uri.getPort()); + } + + /** + * 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); + } +} |