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