1/*
2 * Copyright (C) 2008 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.emailcommon.utility;
18
19import android.app.Activity;
20import android.app.Fragment;
21import android.content.ContentResolver;
22import android.content.ContentUris;
23import android.content.ContentValues;
24import android.content.Context;
25import android.database.Cursor;
26import android.database.CursorWrapper;
27import android.graphics.Typeface;
28import android.net.Uri;
29import android.os.AsyncTask;
30import android.os.Environment;
31import android.os.Handler;
32import android.os.Looper;
33import android.os.StrictMode;
34import android.provider.OpenableColumns;
35import android.text.Spannable;
36import android.text.SpannableString;
37import android.text.SpannableStringBuilder;
38import android.text.TextUtils;
39import android.text.style.StyleSpan;
40import android.util.Base64;
41import android.util.Log;
42import android.widget.ListView;
43import android.widget.TextView;
44import android.widget.Toast;
45
46import com.android.emailcommon.Logging;
47import com.android.emailcommon.provider.Account;
48import com.android.emailcommon.provider.EmailContent;
49import com.android.emailcommon.provider.EmailContent.AccountColumns;
50import com.android.emailcommon.provider.EmailContent.Attachment;
51import com.android.emailcommon.provider.EmailContent.AttachmentColumns;
52import com.android.emailcommon.provider.EmailContent.HostAuthColumns;
53import com.android.emailcommon.provider.EmailContent.MailboxColumns;
54import com.android.emailcommon.provider.EmailContent.Message;
55import com.android.emailcommon.provider.EmailContent.MessageColumns;
56import com.android.emailcommon.provider.HostAuth;
57import com.android.emailcommon.provider.Mailbox;
58import com.android.emailcommon.provider.ProviderUnavailableException;
59
60import java.io.ByteArrayInputStream;
61import java.io.File;
62import java.io.FileDescriptor;
63import java.io.FileNotFoundException;
64import java.io.IOException;
65import java.io.InputStream;
66import java.io.InputStreamReader;
67import java.io.PrintWriter;
68import java.io.StringWriter;
69import java.io.UnsupportedEncodingException;
70import java.net.URI;
71import java.net.URISyntaxException;
72import java.nio.ByteBuffer;
73import java.nio.CharBuffer;
74import java.nio.charset.Charset;
75import java.security.MessageDigest;
76import java.security.NoSuchAlgorithmException;
77import java.util.ArrayList;
78import java.util.Collection;
79import java.util.GregorianCalendar;
80import java.util.HashSet;
81import java.util.Set;
82import java.util.TimeZone;
83import java.util.regex.Pattern;
84
85public class Utility {
86    public static final Charset UTF_8 = Charset.forName("UTF-8");
87    public static final Charset ASCII = Charset.forName("US-ASCII");
88
89    public static final String[] EMPTY_STRINGS = new String[0];
90    public static final Long[] EMPTY_LONGS = new Long[0];
91
92    // "GMT" + "+" or "-" + 4 digits
93    private static final Pattern DATE_CLEANUP_PATTERN_WRONG_TIMEZONE =
94            Pattern.compile("GMT([-+]\\d{4})$");
95
96    private static Handler sMainThreadHandler;
97
98    /**
99     * @return a {@link Handler} tied to the main thread.
100     */
101    public static Handler getMainThreadHandler() {
102        if (sMainThreadHandler == null) {
103            // No need to synchronize -- it's okay to create an extra Handler, which will be used
104            // only once and then thrown away.
105            sMainThreadHandler = new Handler(Looper.getMainLooper());
106        }
107        return sMainThreadHandler;
108    }
109
110    public final static String readInputStream(InputStream in, String encoding) throws IOException {
111        InputStreamReader reader = new InputStreamReader(in, encoding);
112        StringBuffer sb = new StringBuffer();
113        int count;
114        char[] buf = new char[512];
115        while ((count = reader.read(buf)) != -1) {
116            sb.append(buf, 0, count);
117        }
118        return sb.toString();
119    }
120
121    public final static boolean arrayContains(Object[] a, Object o) {
122        int index = arrayIndex(a, o);
123        return (index >= 0);
124    }
125
126    public final static int arrayIndex(Object[] a, Object o) {
127        for (int i = 0, count = a.length; i < count; i++) {
128            if (a[i].equals(o)) {
129                return i;
130            }
131        }
132        return -1;
133    }
134
135    /**
136     * Returns a concatenated string containing the output of every Object's
137     * toString() method, each separated by the given separator character.
138     */
139    public static String combine(Object[] parts, char separator) {
140        if (parts == null) {
141            return null;
142        }
143        StringBuffer sb = new StringBuffer();
144        for (int i = 0; i < parts.length; i++) {
145            sb.append(parts[i].toString());
146            if (i < parts.length - 1) {
147                sb.append(separator);
148            }
149        }
150        return sb.toString();
151    }
152    public static String base64Decode(String encoded) {
153        if (encoded == null) {
154            return null;
155        }
156        byte[] decoded = Base64.decode(encoded, Base64.DEFAULT);
157        return new String(decoded);
158    }
159
160    public static String base64Encode(String s) {
161        if (s == null) {
162            return s;
163        }
164        return Base64.encodeToString(s.getBytes(), Base64.NO_WRAP);
165    }
166
167    public static boolean isTextViewNotEmpty(TextView view) {
168        return !TextUtils.isEmpty(view.getText());
169    }
170
171    public static boolean isPortFieldValid(TextView view) {
172        CharSequence chars = view.getText();
173        if (TextUtils.isEmpty(chars)) return false;
174        Integer port;
175        // In theory, we can't get an illegal value here, since the field is monitored for valid
176        // numeric input. But this might be used elsewhere without such a check.
177        try {
178            port = Integer.parseInt(chars.toString());
179        } catch (NumberFormatException e) {
180            return false;
181        }
182        return port > 0 && port < 65536;
183    }
184
185    /**
186     * Validate a hostname name field.
187     *
188     * Because we just use the {@link URI} class for validation, it'll accept some invalid
189     * host names, but it works well enough...
190     */
191    public static boolean isServerNameValid(TextView view) {
192        return isServerNameValid(view.getText().toString());
193    }
194
195    public static boolean isServerNameValid(String serverName) {
196        serverName = serverName.trim();
197        if (TextUtils.isEmpty(serverName)) {
198            return false;
199        }
200        try {
201            URI uri = new URI(
202                    "http",
203                    null,
204                    serverName,
205                    -1,
206                    null, // path
207                    null, // query
208                    null);
209            return true;
210        } catch (URISyntaxException e) {
211            return false;
212        }
213    }
214
215    /**
216     * Ensures that the given string starts and ends with the double quote character. The string is
217     * not modified in any way except to add the double quote character to start and end if it's not
218     * already there.
219     *
220     * TODO: Rename this, because "quoteString()" can mean so many different things.
221     *
222     * sample -> "sample"
223     * "sample" -> "sample"
224     * ""sample"" -> "sample"
225     * "sample"" -> "sample"
226     * sa"mp"le -> "sa"mp"le"
227     * "sa"mp"le" -> "sa"mp"le"
228     * (empty string) -> ""
229     * " -> ""
230     */
231    public static String quoteString(String s) {
232        if (s == null) {
233            return null;
234        }
235        if (!s.matches("^\".*\"$")) {
236            return "\"" + s + "\"";
237        }
238        else {
239            return s;
240        }
241    }
242
243    /**
244     * A fast version of  URLDecoder.decode() that works only with UTF-8 and does only two
245     * allocations. This version is around 3x as fast as the standard one and I'm using it
246     * hundreds of times in places that slow down the UI, so it helps.
247     */
248    public static String fastUrlDecode(String s) {
249        try {
250            byte[] bytes = s.getBytes("UTF-8");
251            byte ch;
252            int length = 0;
253            for (int i = 0, count = bytes.length; i < count; i++) {
254                ch = bytes[i];
255                if (ch == '%') {
256                    int h = (bytes[i + 1] - '0');
257                    int l = (bytes[i + 2] - '0');
258                    if (h > 9) {
259                        h -= 7;
260                    }
261                    if (l > 9) {
262                        l -= 7;
263                    }
264                    bytes[length] = (byte) ((h << 4) | l);
265                    i += 2;
266                }
267                else if (ch == '+') {
268                    bytes[length] = ' ';
269                }
270                else {
271                    bytes[length] = bytes[i];
272                }
273                length++;
274            }
275            return new String(bytes, 0, length, "UTF-8");
276        }
277        catch (UnsupportedEncodingException uee) {
278            return null;
279        }
280    }
281    private final static String HOSTAUTH_WHERE_CREDENTIALS = HostAuthColumns.ADDRESS + " like ?"
282            + " and " + HostAuthColumns.LOGIN + " like ?  ESCAPE '\\'"
283            + " and " + HostAuthColumns.PROTOCOL + " not like \"smtp\"";
284    private final static String ACCOUNT_WHERE_HOSTAUTH = AccountColumns.HOST_AUTH_KEY_RECV + "=?";
285
286    /**
287     * Look for an existing account with the same username & server
288     *
289     * @param context a system context
290     * @param allowAccountId this account Id will not trigger (when editing an existing account)
291     * @param hostName the server's address
292     * @param userLogin the user's login string
293     * @result null = no matching account found.  Account = matching account
294     */
295    public static Account findExistingAccount(Context context, long allowAccountId,
296            String hostName, String userLogin) {
297        ContentResolver resolver = context.getContentResolver();
298        String userName = userLogin.replace("_", "\\_");
299        Cursor c = resolver.query(HostAuth.CONTENT_URI, HostAuth.ID_PROJECTION,
300                HOSTAUTH_WHERE_CREDENTIALS, new String[] { hostName, userName }, null);
301        if (c == null) throw new ProviderUnavailableException();
302        try {
303            while (c.moveToNext()) {
304                long hostAuthId = c.getLong(HostAuth.ID_PROJECTION_COLUMN);
305                // Find account with matching hostauthrecv key, and return it
306                Cursor c2 = resolver.query(Account.CONTENT_URI, Account.ID_PROJECTION,
307                        ACCOUNT_WHERE_HOSTAUTH, new String[] { Long.toString(hostAuthId) }, null);
308                try {
309                    while (c2.moveToNext()) {
310                        long accountId = c2.getLong(Account.ID_PROJECTION_COLUMN);
311                        if (accountId != allowAccountId) {
312                            Account account = Account.restoreAccountWithId(context, accountId);
313                            if (account != null) {
314                                return account;
315                            }
316                        }
317                    }
318                } finally {
319                    c2.close();
320                }
321            }
322        } finally {
323            c.close();
324        }
325
326        return null;
327    }
328
329    /**
330     * Generate a random message-id header for locally-generated messages.
331     */
332    public static String generateMessageId() {
333        StringBuffer sb = new StringBuffer();
334        sb.append("<");
335        for (int i = 0; i < 24; i++) {
336            sb.append(Integer.toString((int)(Math.random() * 35), 36));
337        }
338        sb.append(".");
339        sb.append(Long.toString(System.currentTimeMillis()));
340        sb.append("@email.android.com>");
341        return sb.toString();
342    }
343
344    /**
345     * Generate a time in milliseconds from a date string that represents a date/time in GMT
346     * @param date string in format 20090211T180303Z (rfc2445, iCalendar).
347     * @return the time in milliseconds (since Jan 1, 1970)
348     */
349    public static long parseDateTimeToMillis(String date) {
350        GregorianCalendar cal = parseDateTimeToCalendar(date);
351        return cal.getTimeInMillis();
352    }
353
354    /**
355     * Generate a GregorianCalendar from a date string that represents a date/time in GMT
356     * @param date string in format 20090211T180303Z (rfc2445, iCalendar).
357     * @return the GregorianCalendar
358     */
359    public static GregorianCalendar parseDateTimeToCalendar(String date) {
360        GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
361                Integer.parseInt(date.substring(4, 6)) - 1, Integer.parseInt(date.substring(6, 8)),
362                Integer.parseInt(date.substring(9, 11)), Integer.parseInt(date.substring(11, 13)),
363                Integer.parseInt(date.substring(13, 15)));
364        cal.setTimeZone(TimeZone.getTimeZone("GMT"));
365        return cal;
366    }
367
368    /**
369     * Generate a time in milliseconds from an email date string that represents a date/time in GMT
370     * @param date string in format 2010-02-23T16:00:00.000Z (ISO 8601, rfc3339)
371     * @return the time in milliseconds (since Jan 1, 1970)
372     */
373    public static long parseEmailDateTimeToMillis(String date) {
374        GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
375                Integer.parseInt(date.substring(5, 7)) - 1, Integer.parseInt(date.substring(8, 10)),
376                Integer.parseInt(date.substring(11, 13)), Integer.parseInt(date.substring(14, 16)),
377                Integer.parseInt(date.substring(17, 19)));
378        cal.setTimeZone(TimeZone.getTimeZone("GMT"));
379        return cal.getTimeInMillis();
380    }
381
382    private static byte[] encode(Charset charset, String s) {
383        if (s == null) {
384            return null;
385        }
386        final ByteBuffer buffer = charset.encode(CharBuffer.wrap(s));
387        final byte[] bytes = new byte[buffer.limit()];
388        buffer.get(bytes);
389        return bytes;
390    }
391
392    private static String decode(Charset charset, byte[] b) {
393        if (b == null) {
394            return null;
395        }
396        final CharBuffer cb = charset.decode(ByteBuffer.wrap(b));
397        return new String(cb.array(), 0, cb.length());
398    }
399
400    /** Converts a String to UTF-8 */
401    public static byte[] toUtf8(String s) {
402        return encode(UTF_8, s);
403    }
404
405    /** Builds a String from UTF-8 bytes */
406    public static String fromUtf8(byte[] b) {
407        return decode(UTF_8, b);
408    }
409
410    /** Converts a String to ASCII bytes */
411    public static byte[] toAscii(String s) {
412        return encode(ASCII, s);
413    }
414
415    /** Builds a String from ASCII bytes */
416    public static String fromAscii(byte[] b) {
417        return decode(ASCII, b);
418    }
419
420    /**
421     * @return true if the input is the first (or only) byte in a UTF-8 character
422     */
423    public static boolean isFirstUtf8Byte(byte b) {
424        // If the top 2 bits is '10', it's not a first byte.
425        return (b & 0xc0) != 0x80;
426    }
427
428    public static String byteToHex(int b) {
429        return byteToHex(new StringBuilder(), b).toString();
430    }
431
432    public static StringBuilder byteToHex(StringBuilder sb, int b) {
433        b &= 0xFF;
434        sb.append("0123456789ABCDEF".charAt(b >> 4));
435        sb.append("0123456789ABCDEF".charAt(b & 0xF));
436        return sb;
437    }
438
439    public static String replaceBareLfWithCrlf(String str) {
440        return str.replace("\r", "").replace("\n", "\r\n");
441    }
442
443    /**
444     * Cancel an {@link AsyncTask}.  If it's already running, it'll be interrupted.
445     */
446    public static void cancelTaskInterrupt(AsyncTask<?, ?, ?> task) {
447        cancelTask(task, true);
448    }
449
450    /**
451     * Cancel an {@link EmailAsyncTask}.  If it's already running, it'll be interrupted.
452     */
453    public static void cancelTaskInterrupt(EmailAsyncTask<?, ?, ?> task) {
454        if (task != null) {
455            task.cancel(true);
456        }
457    }
458
459    /**
460     * Cancel an {@link AsyncTask}.
461     *
462     * @param mayInterruptIfRunning <tt>true</tt> if the thread executing this
463     *        task should be interrupted; otherwise, in-progress tasks are allowed
464     *        to complete.
465     */
466    public static void cancelTask(AsyncTask<?, ?, ?> task, boolean mayInterruptIfRunning) {
467        if (task != null && task.getStatus() != AsyncTask.Status.FINISHED) {
468            task.cancel(mayInterruptIfRunning);
469        }
470    }
471
472    public static String getSmallHash(final String value) {
473        final MessageDigest sha;
474        try {
475            sha = MessageDigest.getInstance("SHA-1");
476        } catch (NoSuchAlgorithmException impossible) {
477            return null;
478        }
479        sha.update(Utility.toUtf8(value));
480        final int hash = getSmallHashFromSha1(sha.digest());
481        return Integer.toString(hash);
482    }
483
484    /**
485     * @return a non-negative integer generated from 20 byte SHA-1 hash.
486     */
487    /* package for testing */ static int getSmallHashFromSha1(byte[] sha1) {
488        final int offset = sha1[19] & 0xf; // SHA1 is 20 bytes.
489        return ((sha1[offset]  & 0x7f) << 24)
490                | ((sha1[offset + 1] & 0xff) << 16)
491                | ((sha1[offset + 2] & 0xff) << 8)
492                | ((sha1[offset + 3] & 0xff));
493    }
494
495    /**
496     * Try to make a date MIME(RFC 2822/5322)-compliant.
497     *
498     * It fixes:
499     * - "Thu, 10 Dec 09 15:08:08 GMT-0700" to "Thu, 10 Dec 09 15:08:08 -0700"
500     *   (4 digit zone value can't be preceded by "GMT")
501     *   We got a report saying eBay sends a date in this format
502     */
503    public static String cleanUpMimeDate(String date) {
504        if (TextUtils.isEmpty(date)) {
505            return date;
506        }
507        date = DATE_CLEANUP_PATTERN_WRONG_TIMEZONE.matcher(date).replaceFirst("$1");
508        return date;
509    }
510
511    public static ByteArrayInputStream streamFromAsciiString(String ascii) {
512        return new ByteArrayInputStream(toAscii(ascii));
513    }
514
515    /**
516     * A thread safe way to show a Toast.  Can be called from any thread.
517     *
518     * @param context context
519     * @param resId Resource ID of the message string.
520     */
521    public static void showToast(Context context, int resId) {
522        showToast(context, context.getResources().getString(resId));
523    }
524
525    /**
526     * A thread safe way to show a Toast.  Can be called from any thread.
527     *
528     * @param context context
529     * @param message Message to show.
530     */
531    public static void showToast(final Context context, final String message) {
532        getMainThreadHandler().post(new Runnable() {
533            @Override
534            public void run() {
535                Toast.makeText(context, message, Toast.LENGTH_LONG).show();
536            }
537        });
538    }
539
540    /**
541     * Run {@code r} on a worker thread, returning the AsyncTask
542     * @return the AsyncTask; this is primarily for use by unit tests, which require the
543     * result of the task
544     *
545     * @deprecated use {@link EmailAsyncTask#runAsyncParallel} or
546     *     {@link EmailAsyncTask#runAsyncSerial}
547     */
548    @Deprecated
549    public static AsyncTask<Void, Void, Void> runAsync(final Runnable r) {
550        return new AsyncTask<Void, Void, Void>() {
551            @Override protected Void doInBackground(Void... params) {
552                r.run();
553                return null;
554            }
555        }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
556    }
557
558    /**
559     * Interface used in {@link #createUniqueFile} instead of {@link File#createNewFile()} to make
560     * it testable.
561     */
562    /* package */ interface NewFileCreator {
563        public static final NewFileCreator DEFAULT = new NewFileCreator() {
564                    @Override public boolean createNewFile(File f) throws IOException {
565                        return f.createNewFile();
566                    }
567        };
568        public boolean createNewFile(File f) throws IOException ;
569    }
570
571    /**
572     * Creates a new empty file with a unique name in the given directory by appending a hyphen and
573     * a number to the given filename.
574     *
575     * @return a new File object, or null if one could not be created
576     */
577    public static File createUniqueFile(File directory, String filename) throws IOException {
578        return createUniqueFileInternal(NewFileCreator.DEFAULT, directory, filename);
579    }
580
581    /* package */ static File createUniqueFileInternal(NewFileCreator nfc,
582            File directory, String filename) throws IOException {
583        File file = new File(directory, filename);
584        if (nfc.createNewFile(file)) {
585            return file;
586        }
587        // Get the extension of the file, if any.
588        int index = filename.lastIndexOf('.');
589        String format;
590        if (index != -1) {
591            String name = filename.substring(0, index);
592            String extension = filename.substring(index);
593            format = name + "-%d" + extension;
594        } else {
595            format = filename + "-%d";
596        }
597
598        for (int i = 2; i < Integer.MAX_VALUE; i++) {
599            file = new File(directory, String.format(format, i));
600            if (nfc.createNewFile(file)) {
601                return file;
602            }
603        }
604        return null;
605    }
606
607    public interface CursorGetter<T> {
608        T get(Cursor cursor, int column);
609    }
610
611    private static final CursorGetter<Long> LONG_GETTER = new CursorGetter<Long>() {
612        @Override
613        public Long get(Cursor cursor, int column) {
614            return cursor.getLong(column);
615        }
616    };
617
618    private static final CursorGetter<Integer> INT_GETTER = new CursorGetter<Integer>() {
619        @Override
620        public Integer get(Cursor cursor, int column) {
621            return cursor.getInt(column);
622        }
623    };
624
625    private static final CursorGetter<String> STRING_GETTER = new CursorGetter<String>() {
626        @Override
627        public String get(Cursor cursor, int column) {
628            return cursor.getString(column);
629        }
630    };
631
632    private static final CursorGetter<byte[]> BLOB_GETTER = new CursorGetter<byte[]>() {
633        @Override
634        public byte[] get(Cursor cursor, int column) {
635            return cursor.getBlob(column);
636        }
637    };
638
639    /**
640     * @return if {@code original} is to the EmailProvider, add "?limit=1".  Otherwise just returns
641     * {@code original}.
642     *
643     * Other providers don't support the limit param.  Also, changing URI passed from other apps
644     * can cause permission errors.
645     */
646    /* package */ static Uri buildLimitOneUri(Uri original) {
647        if ("content".equals(original.getScheme()) &&
648                EmailContent.AUTHORITY.equals(original.getAuthority())) {
649            return EmailContent.uriWithLimit(original, 1);
650        }
651        return original;
652    }
653
654    /**
655     * @return a generic in column {@code column} of the first result row, if the query returns at
656     * least 1 row.  Otherwise returns {@code defaultValue}.
657     */
658    public static <T extends Object> T getFirstRowColumn(Context context, Uri uri,
659            String[] projection, String selection, String[] selectionArgs, String sortOrder,
660            int column, T defaultValue, CursorGetter<T> getter) {
661        // Use PARAMETER_LIMIT to restrict the query to the single row we need
662        uri = buildLimitOneUri(uri);
663        Cursor c = context.getContentResolver().query(uri, projection, selection, selectionArgs,
664                sortOrder);
665        if (c != null) {
666            try {
667                if (c.moveToFirst()) {
668                    return getter.get(c, column);
669                }
670            } finally {
671                c.close();
672            }
673        }
674        return defaultValue;
675    }
676
677    /**
678     * {@link #getFirstRowColumn} for a Long with null as a default value.
679     */
680    public static Long getFirstRowLong(Context context, Uri uri, String[] projection,
681            String selection, String[] selectionArgs, String sortOrder, int column) {
682        return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
683                sortOrder, column, null, LONG_GETTER);
684    }
685
686    /**
687     * {@link #getFirstRowColumn} for a Long with a provided default value.
688     */
689    public static Long getFirstRowLong(Context context, Uri uri, String[] projection,
690            String selection, String[] selectionArgs, String sortOrder, int column,
691            Long defaultValue) {
692        return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
693                sortOrder, column, defaultValue, LONG_GETTER);
694    }
695
696    /**
697     * {@link #getFirstRowColumn} for an Integer with null as a default value.
698     */
699    public static Integer getFirstRowInt(Context context, Uri uri, String[] projection,
700            String selection, String[] selectionArgs, String sortOrder, int column) {
701        return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
702                sortOrder, column, null, INT_GETTER);
703    }
704
705    /**
706     * {@link #getFirstRowColumn} for an Integer with a provided default value.
707     */
708    public static Integer getFirstRowInt(Context context, Uri uri, String[] projection,
709            String selection, String[] selectionArgs, String sortOrder, int column,
710            Integer defaultValue) {
711        return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
712                sortOrder, column, defaultValue, INT_GETTER);
713    }
714
715    /**
716     * {@link #getFirstRowColumn} for a String with null as a default value.
717     */
718    public static String getFirstRowString(Context context, Uri uri, String[] projection,
719            String selection, String[] selectionArgs, String sortOrder, int column) {
720        return getFirstRowString(context, uri, projection, selection, selectionArgs, sortOrder,
721                column, null);
722    }
723
724    /**
725     * {@link #getFirstRowColumn} for a String with a provided default value.
726     */
727    public static String getFirstRowString(Context context, Uri uri, String[] projection,
728            String selection, String[] selectionArgs, String sortOrder, int column,
729            String defaultValue) {
730        return getFirstRowColumn(context, uri, projection, selection, selectionArgs,
731                sortOrder, column, defaultValue, STRING_GETTER);
732    }
733
734    /**
735     * {@link #getFirstRowColumn} for a byte array with a provided default value.
736     */
737    public static byte[] getFirstRowBlob(Context context, Uri uri, String[] projection,
738            String selection, String[] selectionArgs, String sortOrder, int column,
739            byte[] defaultValue) {
740        return getFirstRowColumn(context, uri, projection, selection, selectionArgs, sortOrder,
741                column, defaultValue, BLOB_GETTER);
742    }
743
744    public static boolean attachmentExists(Context context, Attachment attachment) {
745        if (attachment == null) {
746            return false;
747        } else if (attachment.mContentBytes != null) {
748            return true;
749        } else if (TextUtils.isEmpty(attachment.mContentUri)) {
750            return false;
751        }
752        try {
753            Uri fileUri = Uri.parse(attachment.mContentUri);
754            try {
755                InputStream inStream = context.getContentResolver().openInputStream(fileUri);
756                try {
757                    inStream.close();
758                } catch (IOException e) {
759                    // Nothing to be done if can't close the stream
760                }
761                return true;
762            } catch (FileNotFoundException e) {
763                return false;
764            }
765        } catch (RuntimeException re) {
766            Log.w(Logging.LOG_TAG, "attachmentExists RuntimeException=" + re);
767            return false;
768        }
769    }
770
771    /**
772     * Check whether the message with a given id has unloaded attachments.  If the message is
773     * a forwarded message, we look instead at the messages's source for the attachments.  If the
774     * message or forward source can't be found, we return false
775     * @param context the caller's context
776     * @param messageId the id of the message
777     * @return whether or not the message has unloaded attachments
778     */
779    public static boolean hasUnloadedAttachments(Context context, long messageId) {
780        Message msg = Message.restoreMessageWithId(context, messageId);
781        if (msg == null) return false;
782        Attachment[] atts = Attachment.restoreAttachmentsWithMessageId(context, messageId);
783        for (Attachment att: atts) {
784            if (!attachmentExists(context, att)) {
785                // If the attachment doesn't exist and isn't marked for download, we're in trouble
786                // since the outbound message will be stuck indefinitely in the Outbox.  Instead,
787                // we'll just delete the attachment and continue; this is far better than the
788                // alternative.  In theory, this situation shouldn't be possible.
789                if ((att.mFlags & (Attachment.FLAG_DOWNLOAD_FORWARD |
790                        Attachment.FLAG_DOWNLOAD_USER_REQUEST)) == 0) {
791                    Log.d(Logging.LOG_TAG, "Unloaded attachment isn't marked for download: " +
792                            att.mFileName + ", #" + att.mId);
793                    Attachment.delete(context, Attachment.CONTENT_URI, att.mId);
794                } else if (att.mContentUri != null) {
795                    // In this case, the attachment file is gone from the cache; let's clear the
796                    // contentUri; this should be a very unusual case
797                    ContentValues cv = new ContentValues();
798                    cv.putNull(AttachmentColumns.CONTENT_URI);
799                    Attachment.update(context, Attachment.CONTENT_URI, att.mId, cv);
800                }
801                return true;
802            }
803        }
804        return false;
805    }
806
807    /**
808     * Convenience method wrapping calls to retrieve columns from a single row, via EmailProvider.
809     * The arguments are exactly the same as to contentResolver.query().  Results are returned in
810     * an array of Strings corresponding to the columns in the projection.  If the cursor has no
811     * rows, null is returned.
812     */
813    public static String[] getRowColumns(Context context, Uri contentUri, String[] projection,
814            String selection, String[] selectionArgs) {
815        String[] values = new String[projection.length];
816        ContentResolver cr = context.getContentResolver();
817        Cursor c = cr.query(contentUri, projection, selection, selectionArgs, null);
818        try {
819            if (c.moveToFirst()) {
820                for (int i = 0; i < projection.length; i++) {
821                    values[i] = c.getString(i);
822                }
823            } else {
824                return null;
825            }
826        } finally {
827            c.close();
828        }
829        return values;
830    }
831
832    /**
833     * Convenience method for retrieving columns from a particular row in EmailProvider.
834     * Passed in here are a base uri (e.g. Message.CONTENT_URI), the unique id of a row, and
835     * a projection.  This method calls the previous one with the appropriate URI.
836     */
837    public static String[] getRowColumns(Context context, Uri baseUri, long id,
838            String ... projection) {
839        return getRowColumns(context, ContentUris.withAppendedId(baseUri, id), projection, null,
840                null);
841    }
842
843    public static boolean isExternalStorageMounted() {
844        return Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED);
845    }
846
847    /**
848     * Class that supports running any operation for each account.
849     */
850    public abstract static class ForEachAccount extends AsyncTask<Void, Void, Long[]> {
851        private final Context mContext;
852
853        public ForEachAccount(Context context) {
854            mContext = context;
855        }
856
857        @Override
858        protected final Long[] doInBackground(Void... params) {
859            ArrayList<Long> ids = new ArrayList<Long>();
860            Cursor c = mContext.getContentResolver().query(Account.CONTENT_URI,
861                    Account.ID_PROJECTION, null, null, null);
862            try {
863                while (c.moveToNext()) {
864                    ids.add(c.getLong(Account.ID_PROJECTION_COLUMN));
865                }
866            } finally {
867                c.close();
868            }
869            return ids.toArray(EMPTY_LONGS);
870        }
871
872        @Override
873        protected final void onPostExecute(Long[] ids) {
874            if (ids != null && !isCancelled()) {
875                for (long id : ids) {
876                    performAction(id);
877                }
878            }
879            onFinished();
880        }
881
882        /**
883         * This method will be called for each account.
884         */
885        protected abstract void performAction(long accountId);
886
887        /**
888         * Called when the iteration is finished.
889         */
890        protected void onFinished() {
891        }
892    }
893
894    /**
895     * Updates the last seen message key in the mailbox data base for the INBOX of the currently
896     * shown account. If the account is {@link Account#ACCOUNT_ID_COMBINED_VIEW}, the INBOX for
897     * all accounts are updated.
898     * @return an {@link EmailAsyncTask} for test only.
899     */
900    public static EmailAsyncTask<Void, Void, Void> updateLastSeenMessageKey(final Context context,
901            final long accountId) {
902        return EmailAsyncTask.runAsyncParallel(new Runnable() {
903            private void updateLastSeenMessageKeyForAccount(long accountId) {
904                ContentResolver resolver = context.getContentResolver();
905                if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
906                    Cursor c = resolver.query(
907                            Account.CONTENT_URI, EmailContent.ID_PROJECTION, null, null, null);
908                    if (c == null) throw new ProviderUnavailableException();
909                    try {
910                        while (c.moveToNext()) {
911                            final long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
912                            updateLastSeenMessageKeyForAccount(id);
913                        }
914                    } finally {
915                        c.close();
916                    }
917                } else if (accountId > 0L) {
918                    Mailbox mailbox =
919                        Mailbox.restoreMailboxOfType(context, accountId, Mailbox.TYPE_INBOX);
920
921                    // mailbox has been removed
922                    if (mailbox == null) {
923                        return;
924                    }
925                    // We use the highest _id for the account the mailbox table as the "last seen
926                    // message key". We don't care if the message has been read or not. We only
927                    // need a point at which we can compare against in the future. By setting this
928                    // value, we are claiming that every message before this has potentially been
929                    // seen by the user.
930                    long messageId = Utility.getFirstRowLong(
931                            context,
932                            Message.CONTENT_URI,
933                            EmailContent.ID_PROJECTION,
934                            MessageColumns.MAILBOX_KEY + "=?",
935                            new String[] { Long.toString(mailbox.mId) },
936                            MessageColumns.ID + " DESC",
937                            EmailContent.ID_PROJECTION_COLUMN, 0L);
938                    long oldLastSeenMessageId = Utility.getFirstRowLong(
939                            context, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mailbox.mId),
940                            new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY },
941                            null, null, null, 0, 0L);
942                    // Only update the db if the value has changed
943                    if (messageId != oldLastSeenMessageId) {
944                        ContentValues values = mailbox.toContentValues();
945                        values.put(MailboxColumns.LAST_SEEN_MESSAGE_KEY, messageId);
946                        resolver.update(
947                                Mailbox.CONTENT_URI,
948                                values,
949                                EmailContent.ID_SELECTION,
950                                new String[] { Long.toString(mailbox.mId) });
951                    }
952                }
953            }
954
955            @Override
956            public void run() {
957                updateLastSeenMessageKeyForAccount(accountId);
958            }
959        });
960    }
961
962    public static long[] toPrimitiveLongArray(Collection<Long> collection) {
963        // Need to do this manually because we're converting to a primitive long array, not
964        // a Long array.
965        final int size = collection.size();
966        final long[] ret = new long[size];
967        // Collection doesn't have get(i).  (Iterable doesn't have size())
968        int i = 0;
969        for (Long value : collection) {
970            ret[i++] = value;
971        }
972        return ret;
973    }
974
975    public static Set<Long> toLongSet(long[] array) {
976        // Need to do this manually because we're converting from a primitive long array, not
977        // a Long array.
978        final int size = array.length;
979        HashSet<Long> ret = new HashSet<Long>(size);
980        for (int i = 0; i < size; i++) {
981            ret.add(array[i]);
982        }
983        return ret;
984    }
985
986    /**
987     * Workaround for the {@link ListView#smoothScrollToPosition} randomly scroll the view bug
988     * if it's called right after {@link ListView#setAdapter}.
989     */
990    public static void listViewSmoothScrollToPosition(final Activity activity,
991            final ListView listView, final int position) {
992        // Workarond: delay-call smoothScrollToPosition()
993        new Handler().post(new Runnable() {
994            @Override
995            public void run() {
996                if (activity.isFinishing()) {
997                    return; // Activity being destroyed
998                }
999                listView.smoothScrollToPosition(position);
1000            }
1001        });
1002    }
1003
1004    private static final String[] ATTACHMENT_META_NAME_PROJECTION = {
1005        OpenableColumns.DISPLAY_NAME
1006    };
1007    private static final int ATTACHMENT_META_NAME_COLUMN_DISPLAY_NAME = 0;
1008
1009    /**
1010     * @return Filename of a content of {@code contentUri}.  If the provider doesn't provide the
1011     * filename, returns the last path segment of the URI.
1012     */
1013    public static String getContentFileName(Context context, Uri contentUri) {
1014        String name = getFirstRowString(context, contentUri, ATTACHMENT_META_NAME_PROJECTION, null,
1015                null, null, ATTACHMENT_META_NAME_COLUMN_DISPLAY_NAME);
1016        if (name == null) {
1017            name = contentUri.getLastPathSegment();
1018        }
1019        return name;
1020    }
1021
1022    /**
1023     * Append a bold span to a {@link SpannableStringBuilder}.
1024     */
1025    public static SpannableStringBuilder appendBold(SpannableStringBuilder ssb, String text) {
1026        if (!TextUtils.isEmpty(text)) {
1027            SpannableString ss = new SpannableString(text);
1028            ss.setSpan(new StyleSpan(Typeface.BOLD), 0, ss.length(),
1029                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1030            ssb.append(ss);
1031        }
1032
1033        return ssb;
1034    }
1035
1036    /**
1037     * Stringify a cursor for logging purpose.
1038     */
1039    public static String dumpCursor(Cursor c) {
1040        StringBuilder sb = new StringBuilder();
1041        sb.append("[");
1042        while (c != null) {
1043            sb.append(c.getClass()); // Class name may not be available if toString() is overridden
1044            sb.append("/");
1045            sb.append(c.toString());
1046            if (c.isClosed()) {
1047                sb.append(" (closed)");
1048            }
1049            if (c instanceof CursorWrapper) {
1050                c = ((CursorWrapper) c).getWrappedCursor();
1051                sb.append(", ");
1052            } else {
1053                break;
1054            }
1055        }
1056        sb.append("]");
1057        return sb.toString();
1058    }
1059
1060    /**
1061     * Cursor wrapper that remembers where it was closed.
1062     *
1063     * Use {@link #get} to create a wrapped cursor.
1064     * USe {@link #getTraceIfAvailable} to get the stack trace.
1065     * Use {@link #log} to log if/where it was closed.
1066     */
1067    public static class CloseTraceCursorWrapper extends CursorWrapper {
1068        private static final boolean TRACE_ENABLED = false;
1069
1070        private Exception mTrace;
1071
1072        private CloseTraceCursorWrapper(Cursor cursor) {
1073            super(cursor);
1074        }
1075
1076        @Override
1077        public void close() {
1078            mTrace = new Exception("STACK TRACE");
1079            super.close();
1080        }
1081
1082        public static Exception getTraceIfAvailable(Cursor c) {
1083            if (c instanceof CloseTraceCursorWrapper) {
1084                return ((CloseTraceCursorWrapper) c).mTrace;
1085            } else {
1086                return null;
1087            }
1088        }
1089
1090        public static void log(Cursor c) {
1091            if (c == null) {
1092                return;
1093            }
1094            if (c.isClosed()) {
1095                Log.w(Logging.LOG_TAG, "Cursor was closed here: Cursor=" + c,
1096                        getTraceIfAvailable(c));
1097            } else {
1098                Log.w(Logging.LOG_TAG, "Cursor not closed.  Cursor=" + c);
1099            }
1100        }
1101
1102        public static Cursor get(Cursor original) {
1103            return TRACE_ENABLED ? new CloseTraceCursorWrapper(original) : original;
1104        }
1105
1106        /* package */ static CloseTraceCursorWrapper alwaysCreateForTest(Cursor original) {
1107            return new CloseTraceCursorWrapper(original);
1108        }
1109    }
1110
1111    /**
1112     * Test that the given strings are equal in a null-pointer safe fashion.
1113     */
1114    public static boolean areStringsEqual(String s1, String s2) {
1115        return (s1 != null && s1.equals(s2)) || (s1 == null && s2 == null);
1116    }
1117
1118    public static void enableStrictMode(boolean enabled) {
1119        StrictMode.setThreadPolicy(enabled
1120                ? new StrictMode.ThreadPolicy.Builder().detectAll().build()
1121                : StrictMode.ThreadPolicy.LAX);
1122        StrictMode.setVmPolicy(enabled
1123                ? new StrictMode.VmPolicy.Builder().detectAll().build()
1124                : StrictMode.VmPolicy.LAX);
1125    }
1126
1127    public static String dumpFragment(Fragment f) {
1128        StringWriter sw = new StringWriter();
1129        PrintWriter w = new PrintWriter(sw);
1130        f.dump("", new FileDescriptor(), w, new String[0]);
1131        return sw.toString();
1132    }
1133
1134    /**
1135     * Builds an "in" expression for SQLite.
1136     *
1137     * e.g. "ID" + 1,2,3 -> "ID in (1,2,3)".  If {@code values} is empty or null, it returns an
1138     * empty string.
1139     */
1140    public static String buildInSelection(String columnName, Collection<? extends Number> values) {
1141        if ((values == null) || (values.size() == 0)) {
1142            return "";
1143        }
1144        StringBuilder sb = new StringBuilder();
1145        sb.append(columnName);
1146        sb.append(" in (");
1147        String sep = "";
1148        for (Number n : values) {
1149            sb.append(sep);
1150            sb.append(n.toString());
1151            sep = ",";
1152        }
1153        sb.append(')');
1154        return sb.toString();
1155    }
1156}
1157