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 ? 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 */ 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 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 */ generateMessageId()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 */ parseDateTimeToMillis(String date)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 */ parseDateTimeToCalendar(String date)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 * 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> updateLastNotifiedMessageKey( 900 final Context context, final long mailboxId) { 901 return EmailAsyncTask.runAsyncParallel(new Runnable() { 902 private void updateLastSeenMessageKeyForMailbox(long mailboxId) { 903 ContentResolver resolver = context.getContentResolver(); 904 if (mailboxId == Mailbox.QUERY_ALL_INBOXES) { 905 Cursor c = resolver.query( 906 Mailbox.CONTENT_URI, EmailContent.ID_PROJECTION, Mailbox.TYPE + "=?", 907 new String[] { Integer.toString(Mailbox.TYPE_INBOX) }, 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 updateLastSeenMessageKeyForMailbox(id); 913 } 914 } finally { 915 c.close(); 916 } 917 } else if (mailboxId > 0L) { 918 Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); 919 // mailbox has been removed 920 if (mailbox == null) { 921 return; 922 } 923 // We use the highest _id for the account the mailbox table as the "last seen 924 // message key". We don't care if the message has been read or not. We only 925 // need a point at which we can compare against in the future. By setting this 926 // value, we are claiming that every message before this has potentially been 927 // seen by the user. 928 long mostRecentMessageId = Utility.getFirstRowLong(context, 929 ContentUris.withAppendedId( 930 EmailContent.MAILBOX_MOST_RECENT_MESSAGE_URI, mailboxId), 931 Message.ID_COLUMN_PROJECTION, null, null, null, 932 Message.ID_MAILBOX_COLUMN_ID, -1L); 933 long lastNotifiedMessageId = mailbox.mLastNotifiedMessageKey; 934 // Only update the db if the value has changed 935 if (mostRecentMessageId != lastNotifiedMessageId) { 936 Log.d(Logging.LOG_TAG, "Most recent = " + mostRecentMessageId + 937 ", last notified: " + lastNotifiedMessageId + 938 "; updating last notified"); 939 ContentValues values = mailbox.toContentValues(); 940 values.put(MailboxColumns.LAST_NOTIFIED_MESSAGE_KEY, mostRecentMessageId); 941 resolver.update( 942 Mailbox.CONTENT_URI, 943 values, 944 EmailContent.ID_SELECTION, 945 new String[] { Long.toString(mailbox.mId) }); 946 } else { 947 Log.d(Logging.LOG_TAG, "Most recent = last notified; no change"); 948 } 949 } 950 } 951 952 @Override 953 public void run() { 954 updateLastSeenMessageKeyForMailbox(mailboxId); 955 } 956 }); 957 } 958 959 public static long[] toPrimitiveLongArray(Collection<Long> collection) { 960 // Need to do this manually because we're converting to a primitive long array, not 961 // a Long array. 962 final int size = collection.size(); 963 final long[] ret = new long[size]; 964 // Collection doesn't have get(i). (Iterable doesn't have size()) 965 int i = 0; 966 for (Long value : collection) { 967 ret[i++] = value; 968 } 969 return ret; 970 } 971 972 public static Set<Long> toLongSet(long[] array) { 973 // Need to do this manually because we're converting from a primitive long array, not 974 // a Long array. 975 final int size = array.length; 976 HashSet<Long> ret = new HashSet<Long>(size); 977 for (int i = 0; i < size; i++) { 978 ret.add(array[i]); 979 } 980 return ret; 981 } 982 983 /** 984 * Workaround for the {@link ListView#smoothScrollToPosition} randomly scroll the view bug 985 * if it's called right after {@link ListView#setAdapter}. 986 */ 987 public static void listViewSmoothScrollToPosition(final Activity activity, 988 final ListView listView, final int position) { 989 // Workarond: delay-call smoothScrollToPosition() 990 new Handler().post(new Runnable() { 991 @Override 992 public void run() { 993 if (activity.isFinishing()) { 994 return; // Activity being destroyed 995 } 996 listView.smoothScrollToPosition(position); 997 } 998 }); 999 } 1000 1001 private static final String[] ATTACHMENT_META_NAME_PROJECTION = { 1002 OpenableColumns.DISPLAY_NAME 1003 }; 1004 private static final int ATTACHMENT_META_NAME_COLUMN_DISPLAY_NAME = 0; 1005 1006 /** 1007 * @return Filename of a content of {@code contentUri}. If the provider doesn't provide the 1008 * filename, returns the last path segment of the URI. 1009 */ 1010 public static String getContentFileName(Context context, Uri contentUri) { 1011 String name = getFirstRowString(context, contentUri, ATTACHMENT_META_NAME_PROJECTION, null, 1012 null, null, ATTACHMENT_META_NAME_COLUMN_DISPLAY_NAME); 1013 if (name == null) { 1014 name = contentUri.getLastPathSegment(); 1015 } 1016 return name; 1017 } 1018 1019 /** 1020 * Append a bold span to a {@link SpannableStringBuilder}. 1021 */ 1022 public static SpannableStringBuilder appendBold(SpannableStringBuilder ssb, String text) { 1023 if (!TextUtils.isEmpty(text)) { 1024 SpannableString ss = new SpannableString(text); 1025 ss.setSpan(new StyleSpan(Typeface.BOLD), 0, ss.length(), 1026 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1027 ssb.append(ss); 1028 } 1029 1030 return ssb; 1031 } 1032 1033 /** 1034 * Stringify a cursor for logging purpose. 1035 */ 1036 public static String dumpCursor(Cursor c) { 1037 StringBuilder sb = new StringBuilder(); 1038 sb.append("["); 1039 while (c != null) { 1040 sb.append(c.getClass()); // Class name may not be available if toString() is overridden 1041 sb.append("/"); 1042 sb.append(c.toString()); 1043 if (c.isClosed()) { 1044 sb.append(" (closed)"); 1045 } 1046 if (c instanceof CursorWrapper) { 1047 c = ((CursorWrapper) c).getWrappedCursor(); 1048 sb.append(", "); 1049 } else { 1050 break; 1051 } 1052 } 1053 sb.append("]"); 1054 return sb.toString(); 1055 } 1056 1057 /** 1058 * Cursor wrapper that remembers where it was closed. 1059 * 1060 * Use {@link #get} to create a wrapped cursor. 1061 * USe {@link #getTraceIfAvailable} to get the stack trace. 1062 * Use {@link #log} to log if/where it was closed. 1063 */ 1064 public static class CloseTraceCursorWrapper extends CursorWrapper { 1065 private static final boolean TRACE_ENABLED = false; 1066 1067 private Exception mTrace; 1068 1069 private CloseTraceCursorWrapper(Cursor cursor) { 1070 super(cursor); 1071 } 1072 1073 @Override 1074 public void close() { 1075 mTrace = new Exception("STACK TRACE"); 1076 super.close(); 1077 } 1078 1079 public static Exception getTraceIfAvailable(Cursor c) { 1080 if (c instanceof CloseTraceCursorWrapper) { 1081 return ((CloseTraceCursorWrapper) c).mTrace; 1082 } else { 1083 return null; 1084 } 1085 } 1086 1087 public static void log(Cursor c) { 1088 if (c == null) { 1089 return; 1090 } 1091 if (c.isClosed()) { 1092 Log.w(Logging.LOG_TAG, "Cursor was closed here: Cursor=" + c, 1093 getTraceIfAvailable(c)); 1094 } else { 1095 Log.w(Logging.LOG_TAG, "Cursor not closed. Cursor=" + c); 1096 } 1097 } 1098 1099 public static Cursor get(Cursor original) { 1100 return TRACE_ENABLED ? new CloseTraceCursorWrapper(original) : original; 1101 } 1102 1103 /* package */ static CloseTraceCursorWrapper alwaysCreateForTest(Cursor original) { 1104 return new CloseTraceCursorWrapper(original); 1105 } 1106 } 1107 1108 /** 1109 * Test that the given strings are equal in a null-pointer safe fashion. 1110 */ 1111 public static boolean areStringsEqual(String s1, String s2) { 1112 return (s1 != null && s1.equals(s2)) || (s1 == null && s2 == null); 1113 } 1114 1115 public static void enableStrictMode(boolean enabled) { 1116 StrictMode.setThreadPolicy(enabled 1117 ? new StrictMode.ThreadPolicy.Builder().detectAll().build() 1118 : StrictMode.ThreadPolicy.LAX); 1119 StrictMode.setVmPolicy(enabled 1120 ? new StrictMode.VmPolicy.Builder().detectAll().build() 1121 : StrictMode.VmPolicy.LAX); 1122 } 1123 1124 public static String dumpFragment(Fragment f) { 1125 StringWriter sw = new StringWriter(); 1126 PrintWriter w = new PrintWriter(sw); 1127 f.dump("", new FileDescriptor(), w, new String[0]); 1128 return sw.toString(); 1129 } 1130 1131 /** 1132 * Builds an "in" expression for SQLite. 1133 * 1134 * e.g. "ID" + 1,2,3 -> "ID in (1,2,3)". If {@code values} is empty or null, it returns an 1135 * empty string. 1136 */ 1137 public static String buildInSelection(String columnName, Collection<? extends Number> values) { 1138 if ((values == null) || (values.size() == 0)) { 1139 return ""; 1140 } 1141 StringBuilder sb = new StringBuilder(); 1142 sb.append(columnName); 1143 sb.append(" in ("); 1144 String sep = ""; 1145 for (Number n : values) { 1146 sb.append(sep); 1147 sb.append(n.toString()); 1148 sep = ","; 1149 } 1150 sb.append(')'); 1151 return sb.toString(); 1152 } 1153 1154 /** 1155 * Updates the last seen message key in the mailbox data base for the INBOX of the currently 1156 * shown account. If the account is {@link Account#ACCOUNT_ID_COMBINED_VIEW}, the INBOX for 1157 * all accounts are updated. 1158 * @return an {@link EmailAsyncTask} for test only. 1159 */ 1160 public static EmailAsyncTask<Void, Void, Void> updateLastSeenMessageKey(final Context context, 1161 final long accountId) { 1162 return EmailAsyncTask.runAsyncParallel(new Runnable() { 1163 private void updateLastSeenMessageKeyForAccount(long accountId) { 1164 ContentResolver resolver = context.getContentResolver(); 1165 if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 1166 Cursor c = resolver.query( 1167 Account.CONTENT_URI, EmailContent.ID_PROJECTION, null, null, null); 1168 if (c == null) throw new ProviderUnavailableException(); 1169 try { 1170 while (c.moveToNext()) { 1171 final long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN); 1172 updateLastSeenMessageKeyForAccount(id); 1173 } 1174 } finally { 1175 c.close(); 1176 } 1177 } else if (accountId > 0L) { 1178 Mailbox mailbox = 1179 Mailbox.restoreMailboxOfType(context, accountId, Mailbox.TYPE_INBOX); 1180 1181 // mailbox has been removed 1182 if (mailbox == null) { 1183 return; 1184 } 1185 // We use the highest _id for the account the mailbox table as the "last seen 1186 // message key". We don't care if the message has been read or not. We only 1187 // need a point at which we can compare against in the future. By setting this 1188 // value, we are claiming that every message before this has potentially been 1189 // seen by the user. 1190 long messageId = Utility.getFirstRowLong( 1191 context, 1192 Message.CONTENT_URI, 1193 EmailContent.ID_PROJECTION, 1194 MessageColumns.MAILBOX_KEY + "=?", 1195 new String[] { Long.toString(mailbox.mId) }, 1196 MessageColumns.ID + " DESC", 1197 EmailContent.ID_PROJECTION_COLUMN, 0L); 1198 long oldLastSeenMessageId = Utility.getFirstRowLong( 1199 context, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mailbox.mId), 1200 new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY }, 1201 null, null, null, 0, 0L); 1202 // Only update the db if the value has changed 1203 if (messageId != oldLastSeenMessageId) { 1204 ContentValues values = mailbox.toContentValues(); 1205 values.put(MailboxColumns.LAST_SEEN_MESSAGE_KEY, messageId); 1206 resolver.update( 1207 Mailbox.CONTENT_URI, 1208 values, 1209 EmailContent.ID_SELECTION, 1210 new String[] { Long.toString(mailbox.mId) }); 1211 } 1212 } 1213 } 1214 1215 @Override 1216 public void run() { 1217 updateLastSeenMessageKeyForAccount(accountId); 1218 } 1219 }); 1220 } 1221 } 1222