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