/* * 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.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; 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.text.TextUtils; import android.widget.TextView; import android.widget.Toast; 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.Message; import com.android.emailcommon.provider.HostAuth; import com.android.emailcommon.provider.ProviderUnavailableException; import com.android.mail.utils.LogUtils; import com.google.common.annotations.VisibleForTesting; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.lang.ThreadLocal; 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.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; 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]; // "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 static boolean arrayContains(Object[] a, Object o) { int index = arrayIndex(a, o); return (index >= 0); } public 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; } StringBuilder sb = new StringBuilder(); 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 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 { new URI( "http", null, serverName, -1, null, // path null, // query null); return true; } catch (URISyntaxException e) { return false; } } 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 * @return 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; } private static class ThreadLocalDateFormat extends ThreadLocal<SimpleDateFormat> { private final String mFormatStr; public ThreadLocalDateFormat(String formatStr) { mFormatStr = formatStr; } @Override protected SimpleDateFormat initialValue() { final SimpleDateFormat format = new SimpleDateFormat(mFormatStr); final GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT")); format.setCalendar(cal); return format; } public Date parse(String date) throws ParseException { return super.get().parse(date); } } /** * 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) throws ParseException { return parseDateTimeToCalendar(date).getTimeInMillis(); } private static final ThreadLocalDateFormat mFullDateTimeFormat = new ThreadLocalDateFormat("yyyyMMdd'T'HHmmss'Z'"); private static final ThreadLocalDateFormat mAbbrevDateTimeFormat = new ThreadLocalDateFormat("yyyyMMdd"); /** * Generate a GregorianCalendar from a date string that represents a date/time in GMT * @param date string in format 20090211T180303Z (rfc2445, iCalendar), or * in abbreviated format 20090211. * @return the GregorianCalendar */ @VisibleForTesting public static GregorianCalendar parseDateTimeToCalendar(String date) throws ParseException { final GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT")); if (date.length() <= 8) { cal.setTime(mAbbrevDateTimeFormat.parse(date)); } else { cal.setTime(mFullDateTimeFormat.parse(date)); } return cal; } private static final ThreadLocalDateFormat mAbbrevEmailDateTimeFormat = new ThreadLocalDateFormat("yyyy-MM-dd"); private static final ThreadLocalDateFormat mEmailDateTimeFormat = new ThreadLocalDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); private static final ThreadLocalDateFormat mEmailDateTimeFormatWithMillis = new ThreadLocalDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); /** * 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) */ @VisibleForTesting public static long parseEmailDateTimeToMillis(String date) throws ParseException { final GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT")); if (date.length() <= 10) { cal.setTime(mAbbrevEmailDateTimeFormat.parse(date)); } else if (date.length() <= 20) { cal.setTime(mEmailDateTimeFormat.parse(date)); } else { cal.setTime(mEmailDateTimeFormatWithMillis.parse(date)); } 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. 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<Void, Void, Void> runAsync(final Runnable r) { return new AsyncTask<Void, Void, Void>() { @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(final NewFileCreator nfc, final File directory, final String filename) throws IOException { final File file = new File(directory, filename); if (nfc.createNewFile(file)) { return file; } // Get the extension of the file, if any. final int index = filename.lastIndexOf('.'); final String name; final String extension; if (index != -1) { name = filename.substring(0, index); extension = filename.substring(index); } else { name = filename; extension = ""; } for (int i = 2; i < Integer.MAX_VALUE; i++) { final File numberedFile = new File(directory, name + "-" + Integer.toString(i) + extension); if (nfc.createNewFile(numberedFile)) { return numberedFile; } } return null; } public interface CursorGetter<T> { T get(Cursor cursor, int column); } private static final CursorGetter<Long> LONG_GETTER = new CursorGetter<Long>() { @Override public Long get(Cursor cursor, int column) { return cursor.getLong(column); } }; private static final CursorGetter<Integer> INT_GETTER = new CursorGetter<Integer>() { @Override public Integer get(Cursor cursor, int column) { return cursor.getInt(column); } }; private static final CursorGetter<String> STRING_GETTER = new CursorGetter<String>() { @Override public String get(Cursor cursor, int column) { return cursor.getString(column); } }; private static final CursorGetter<byte[]> BLOB_GETTER = new CursorGetter<byte[]>() { @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> 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); } public static boolean attachmentExists(Context context, Attachment attachment) { if (attachment == null) { return false; } else if (attachment.mContentBytes != null) { return true; } else { final String cachedFile = attachment.getCachedFileUri(); // Try the cached file first if (!TextUtils.isEmpty(cachedFile)) { final Uri cachedFileUri = Uri.parse(cachedFile); try { final InputStream inStream = context.getContentResolver().openInputStream(cachedFileUri); try { inStream.close(); } catch (IOException e) { // Nothing to be done if can't close the stream } return true; } catch (FileNotFoundException e) { // We weren't able to open the file, try the content uri below LogUtils.e(LogUtils.TAG, e, "not able to open cached file"); } } final String contentUri = attachment.getContentUri(); if (TextUtils.isEmpty(contentUri)) { return false; } try { final Uri fileUri = Uri.parse(contentUri); try { final 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) { LogUtils.w(LogUtils.TAG, re, "attachmentExists RuntimeException"); 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) { LogUtils.d(LogUtils.TAG, "Unloaded attachment isn't marked for download: %s" + ", #%d", att.mFileName, att.mId); Account acct = Account.restoreAccountWithId(context, msg.mAccountKey); if (acct == null) return true; // If smart forward is set and the message is a forward, we'll act as though // the attachment has been loaded // In Email1 this test wasn't necessary, as the UI handled it... if ((msg.mFlags & Message.FLAG_TYPE_FORWARD) != 0) { if ((acct.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) != 0) { continue; } } Attachment.delete(context, Attachment.CONTENT_URI, att.mId); } else if (att.getContentUri() != 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); } 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); } }