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