1 /** 2 * Copyright (c) 2011, Google Inc. 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.mail.utils; 18 19 import android.annotation.TargetApi; 20 import android.app.Activity; 21 import android.app.ActivityManager; 22 import android.app.Fragment; 23 import android.app.SearchManager; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.content.pm.PackageManager.NameNotFoundException; 27 import android.content.res.Resources; 28 import android.database.Cursor; 29 import android.graphics.Bitmap; 30 import android.graphics.Typeface; 31 import android.net.ConnectivityManager; 32 import android.net.NetworkInfo; 33 import android.net.Uri; 34 import android.os.AsyncTask; 35 import android.os.Build; 36 import android.os.Bundle; 37 import android.provider.Browser; 38 import android.text.Spannable; 39 import android.text.SpannableString; 40 import android.text.Spanned; 41 import android.text.TextUtils; 42 import android.text.style.CharacterStyle; 43 import android.text.style.StyleSpan; 44 import android.text.style.TextAppearanceSpan; 45 import android.view.Menu; 46 import android.view.MenuItem; 47 import android.view.View; 48 import android.view.View.MeasureSpec; 49 import android.view.ViewGroup; 50 import android.view.ViewGroup.MarginLayoutParams; 51 import android.view.Window; 52 import android.webkit.WebSettings; 53 import android.webkit.WebView; 54 55 import com.android.emailcommon.mail.Address; 56 import com.android.mail.R; 57 import com.android.mail.browse.ConversationCursor; 58 import com.android.mail.compose.ComposeActivity; 59 import com.android.mail.perf.SimpleTimer; 60 import com.android.mail.providers.Account; 61 import com.android.mail.providers.Conversation; 62 import com.android.mail.providers.Folder; 63 import com.android.mail.providers.UIProvider; 64 import com.android.mail.providers.UIProvider.EditSettingsExtras; 65 import com.android.mail.ui.HelpActivity; 66 import com.android.mail.ui.ViewMode; 67 import com.google.android.mail.common.html.parser.HtmlDocument; 68 import com.google.android.mail.common.html.parser.HtmlParser; 69 import com.google.android.mail.common.html.parser.HtmlTree; 70 import com.google.android.mail.common.html.parser.HtmlTreeBuilder; 71 72 import org.json.JSONObject; 73 74 import java.io.FileDescriptor; 75 import java.io.PrintWriter; 76 import java.io.StringWriter; 77 import java.util.Locale; 78 import java.util.Map; 79 80 public class Utils { 81 /** 82 * longest extension we recognize is 4 characters (e.g. "html", "docx") 83 */ 84 private static final int FILE_EXTENSION_MAX_CHARS = 4; 85 public static final String SENDER_LIST_TOKEN_ELIDED = "e"; 86 public static final String SENDER_LIST_TOKEN_NUM_MESSAGES = "n"; 87 public static final String SENDER_LIST_TOKEN_NUM_DRAFTS = "d"; 88 public static final String SENDER_LIST_TOKEN_LITERAL = "l"; 89 public static final String SENDER_LIST_TOKEN_SENDING = "s"; 90 public static final String SENDER_LIST_TOKEN_SEND_FAILED = "f"; 91 public static final Character SENDER_LIST_SEPARATOR = '\n'; 92 93 public static final String EXTRA_ACCOUNT = "account"; 94 public static final String EXTRA_ACCOUNT_URI = "accountUri"; 95 public static final String EXTRA_FOLDER_URI = "folderUri"; 96 public static final String EXTRA_FOLDER = "folder"; 97 public static final String EXTRA_COMPOSE_URI = "composeUri"; 98 public static final String EXTRA_CONVERSATION = "conversationUri"; 99 public static final String EXTRA_FROM_NOTIFICATION = "notification"; 100 public static final String EXTRA_IGNORE_INITIAL_CONVERSATION_LIMIT = 101 "ignore-initial-conversation-limit"; 102 103 private static final String MAILTO_SCHEME = "mailto"; 104 105 /** Extra tag for debugging the blank fragment problem. */ 106 public static final String VIEW_DEBUGGING_TAG = "MailBlankFragment"; 107 108 /* 109 * Notifies that changes happened. Certain UI components, e.g., widgets, can 110 * register for this {@link Intent} and update accordingly. However, this 111 * can be very broad and is NOT the preferred way of getting notification. 112 */ 113 // TODO: UI Provider has this notification URI? 114 public static final String ACTION_NOTIFY_DATASET_CHANGED = 115 "com.android.mail.ACTION_NOTIFY_DATASET_CHANGED"; 116 117 /** Parameter keys for context-aware help. */ 118 private static final String SMART_HELP_LINK_PARAMETER_NAME = "p"; 119 120 private static final String SMART_LINK_APP_VERSION = "version"; 121 private static String sVersionCode = null; 122 123 private static final int SCALED_SCREENSHOT_MAX_HEIGHT_WIDTH = 600; 124 125 private static final String APP_VERSION_QUERY_PARAMETER = "appVersion"; 126 private static final String FOLDER_URI_QUERY_PARAMETER = "folderUri"; 127 128 private static final String LOG_TAG = LogTag.getLogTag(); 129 130 public static final boolean ENABLE_CONV_LOAD_TIMER = false; 131 public static final SimpleTimer sConvLoadTimer = 132 new SimpleTimer(ENABLE_CONV_LOAD_TIMER).withSessionName("ConvLoadTimer"); 133 134 private static final int[] STYLE_ATTR = new int[] {android.R.attr.background}; 135 isRunningJellybeanOrLater()136 public static boolean isRunningJellybeanOrLater() { 137 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; 138 } 139 isRunningJBMR1OrLater()140 public static boolean isRunningJBMR1OrLater() { 141 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1; 142 } 143 isRunningKitkatOrLater()144 public static boolean isRunningKitkatOrLater() { 145 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; 146 } 147 isRunningLOrLater()148 public static boolean isRunningLOrLater() { 149 //TODO: Update this to the L SDK once defined. Right now it is fine to use the watch 150 // build version number, as this app woll not be running on watch devices 151 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH; 152 } 153 154 /** 155 * @return Whether we are running on a low memory device. This is used to disable certain 156 * memory intensive features in the app. 157 */ 158 @TargetApi(Build.VERSION_CODES.KITKAT) isLowRamDevice(Context context)159 public static boolean isLowRamDevice(Context context) { 160 if (isRunningKitkatOrLater()) { 161 final ActivityManager am = (ActivityManager) context.getSystemService( 162 Context.ACTIVITY_SERVICE); 163 // This will be null when running unit tests 164 return am != null && am.isLowRamDevice(); 165 } else { 166 return false; 167 } 168 } 169 170 /** 171 * Sets WebView in a restricted mode suitable for email use. 172 * 173 * @param webView The WebView to restrict 174 */ restrictWebView(WebView webView)175 public static void restrictWebView(WebView webView) { 176 WebSettings webSettings = webView.getSettings(); 177 webSettings.setSavePassword(false); 178 webSettings.setSaveFormData(false); 179 webSettings.setJavaScriptEnabled(false); 180 webSettings.setSupportZoom(false); 181 } 182 183 /** 184 * Sets custom user agent to WebView so we don't get GAIA interstitials b/13990689. 185 * 186 * @param webView The WebView to customize. 187 */ setCustomUserAgent(WebView webView, Context context)188 public static void setCustomUserAgent(WebView webView, Context context) { 189 final WebSettings settings = webView.getSettings(); 190 final String version = getVersionCode(context); 191 final String originalUserAgent = settings.getUserAgentString(); 192 final String userAgent = context.getResources().getString( 193 R.string.user_agent_format, originalUserAgent, version); 194 settings.setUserAgentString(userAgent); 195 } 196 197 /** 198 * Returns the version code for the package, or null if it cannot be retrieved. 199 */ getVersionCode(Context context)200 public static String getVersionCode(Context context) { 201 if (sVersionCode == null) { 202 try { 203 sVersionCode = String.valueOf(context.getPackageManager() 204 .getPackageInfo(context.getPackageName(), 0 /* flags */) 205 .versionCode); 206 } catch (NameNotFoundException e) { 207 LogUtils.e(Utils.LOG_TAG, "Error finding package %s", 208 context.getApplicationInfo().packageName); 209 } 210 } 211 return sVersionCode; 212 } 213 214 /** 215 * Format a plural string. 216 * 217 * @param resource The identity of the resource, which must be a R.plurals 218 * @param count The number of items. 219 */ formatPlural(Context context, int resource, int count)220 public static String formatPlural(Context context, int resource, int count) { 221 final CharSequence formatString = context.getResources().getQuantityText(resource, count); 222 return String.format(formatString.toString(), count); 223 } 224 225 /** 226 * @return an ellipsized String that's at most maxCharacters long. If the 227 * text passed is longer, it will be abbreviated. If it contains a 228 * suffix, the ellipses will be inserted in the middle and the 229 * suffix will be preserved. 230 */ ellipsize(String text, int maxCharacters)231 public static String ellipsize(String text, int maxCharacters) { 232 int length = text.length(); 233 if (length < maxCharacters) 234 return text; 235 236 int realMax = Math.min(maxCharacters, length); 237 // Preserve the suffix if any 238 int index = text.lastIndexOf("."); 239 String extension = "\u2026"; // "..."; 240 if (index >= 0) { 241 // Limit the suffix to dot + four characters 242 if (length - index <= FILE_EXTENSION_MAX_CHARS + 1) { 243 extension = extension + text.substring(index + 1); 244 } 245 } 246 realMax -= extension.length(); 247 if (realMax < 0) 248 realMax = 0; 249 return text.substring(0, realMax) + extension; 250 } 251 252 private static int sMaxUnreadCount = -1; 253 private static final CharacterStyle ACTION_BAR_UNREAD_STYLE = new StyleSpan(Typeface.BOLD); 254 private static String sUnreadText; 255 private static String sUnseenText; 256 private static String sLargeUnseenText; 257 private static int sDefaultFolderBackgroundColor = -1; 258 private static int sUseFolderListFragmentTransition = -1; 259 260 /** 261 * Returns a boolean indicating whether the table UI should be shown. 262 */ useTabletUI(Resources res)263 public static boolean useTabletUI(Resources res) { 264 return res.getBoolean(R.bool.use_tablet_ui); 265 } 266 267 /** 268 * @return <code>true</code> if the right edge effect should be displayed on list items 269 */ 270 @Deprecated 271 // TODO: remove this now that visual design no longer has right-edge caret (which made it so 272 // the hard right edge was drawn IN list items to ensure the caret didn't get an edge) getDisplayListRightEdgeEffect(final boolean tabletDevice, final boolean listCollapsible, final int viewMode)273 public static boolean getDisplayListRightEdgeEffect(final boolean tabletDevice, 274 final boolean listCollapsible, final int viewMode) { 275 return tabletDevice && !listCollapsible 276 && (ViewMode.isConversationMode(viewMode) || ViewMode.isAdMode(viewMode)); 277 } 278 279 /** 280 * Returns a boolean indicating whether or not we should animate in the 281 * folder list fragment. 282 */ useFolderListFragmentTransition(Context context)283 public static boolean useFolderListFragmentTransition(Context context) { 284 if (sUseFolderListFragmentTransition == -1) { 285 sUseFolderListFragmentTransition = context.getResources().getInteger( 286 R.integer.use_folder_list_fragment_transition); 287 } 288 return sUseFolderListFragmentTransition != 0; 289 } 290 291 /** 292 * Returns displayable text from the provided HTML string. 293 * @param htmlText HTML string 294 * @return Plain text string representation of the specified Html string 295 */ convertHtmlToPlainText(String htmlText)296 public static String convertHtmlToPlainText(String htmlText) { 297 if (TextUtils.isEmpty(htmlText)) { 298 return ""; 299 } 300 return getHtmlTree(htmlText, new HtmlParser(), new HtmlTreeBuilder()).getPlainText(); 301 } 302 convertHtmlToPlainText(String htmlText, HtmlParser parser, HtmlTreeBuilder builder)303 public static String convertHtmlToPlainText(String htmlText, HtmlParser parser, 304 HtmlTreeBuilder builder) { 305 if (TextUtils.isEmpty(htmlText)) { 306 return ""; 307 } 308 return getHtmlTree(htmlText, parser, builder).getPlainText(); 309 } 310 311 /** 312 * Returns a {@link HtmlTree} representation of the specified HTML string. 313 */ getHtmlTree(String htmlText)314 public static HtmlTree getHtmlTree(String htmlText) { 315 return getHtmlTree(htmlText, new HtmlParser(), new HtmlTreeBuilder()); 316 } 317 318 /** 319 * Returns a {@link HtmlTree} representation of the specified HTML string. 320 */ getHtmlTree(String htmlText, HtmlParser parser, HtmlTreeBuilder builder)321 private static HtmlTree getHtmlTree(String htmlText, HtmlParser parser, 322 HtmlTreeBuilder builder) { 323 final HtmlDocument doc = parser.parse(htmlText); 324 doc.accept(builder); 325 326 return builder.getTree(); 327 } 328 329 /** 330 * Perform a simulated measure pass on the given child view, assuming the 331 * child has a ViewGroup parent and that it should be laid out within that 332 * parent with a matching width but variable height. Code largely lifted 333 * from AnimatedAdapter.measureChildHeight(). 334 * 335 * @param child a child view that has already been placed within its parent 336 * ViewGroup 337 * @param parent the parent ViewGroup of child 338 * @return measured height of the child in px 339 */ measureViewHeight(View child, ViewGroup parent)340 public static int measureViewHeight(View child, ViewGroup parent) { 341 final ViewGroup.LayoutParams lp = child.getLayoutParams(); 342 final int childSideMargin; 343 if (lp instanceof MarginLayoutParams) { 344 final MarginLayoutParams mlp = (MarginLayoutParams) lp; 345 childSideMargin = mlp.leftMargin + mlp.rightMargin; 346 } else { 347 childSideMargin = 0; 348 } 349 350 final int parentWSpec = MeasureSpec.makeMeasureSpec(parent.getWidth(), MeasureSpec.EXACTLY); 351 final int wSpec = ViewGroup.getChildMeasureSpec(parentWSpec, 352 parent.getPaddingLeft() + parent.getPaddingRight() + childSideMargin, 353 ViewGroup.LayoutParams.MATCH_PARENT); 354 final int hSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 355 child.measure(wSpec, hSpec); 356 return child.getMeasuredHeight(); 357 } 358 359 /** 360 * Encode the string in HTML. 361 * 362 * @param removeEmptyDoubleQuotes If true, also remove any occurrence of "" 363 * found in the string 364 */ cleanUpString(String string, boolean removeEmptyDoubleQuotes)365 public static Object cleanUpString(String string, boolean removeEmptyDoubleQuotes) { 366 return !TextUtils.isEmpty(string) ? TextUtils.htmlEncode(removeEmptyDoubleQuotes ? string 367 .replace("\"\"", "") : string) : ""; 368 } 369 370 /** 371 * Get the correct display string for the unread count of a folder. 372 */ getUnreadCountString(Context context, int unreadCount)373 public static String getUnreadCountString(Context context, int unreadCount) { 374 final String unreadCountString; 375 final Resources resources = context.getResources(); 376 if (sMaxUnreadCount == -1) { 377 sMaxUnreadCount = resources.getInteger(R.integer.maxUnreadCount); 378 } 379 if (unreadCount > sMaxUnreadCount) { 380 if (sUnreadText == null) { 381 sUnreadText = resources.getString(R.string.widget_large_unread_count); 382 } 383 // Localize "99+" according to the device language 384 unreadCountString = String.format(sUnreadText, sMaxUnreadCount); 385 } else if (unreadCount <= 0) { 386 unreadCountString = ""; 387 } else { 388 // Localize unread count according to the device language 389 unreadCountString = String.format("%d", unreadCount); 390 } 391 return unreadCountString; 392 } 393 394 /** 395 * Get the correct display string for the unseen count of a folder. 396 */ getUnseenCountString(Context context, int unseenCount)397 public static String getUnseenCountString(Context context, int unseenCount) { 398 final String unseenCountString; 399 final Resources resources = context.getResources(); 400 if (sMaxUnreadCount == -1) { 401 sMaxUnreadCount = resources.getInteger(R.integer.maxUnreadCount); 402 } 403 if (unseenCount > sMaxUnreadCount) { 404 if (sLargeUnseenText == null) { 405 sLargeUnseenText = resources.getString(R.string.large_unseen_count); 406 } 407 // Localize "99+" according to the device language 408 unseenCountString = String.format(sLargeUnseenText, sMaxUnreadCount); 409 } else if (unseenCount <= 0) { 410 unseenCountString = ""; 411 } else { 412 if (sUnseenText == null) { 413 sUnseenText = resources.getString(R.string.unseen_count); 414 } 415 // Localize unseen count according to the device language 416 unseenCountString = String.format(sUnseenText, unseenCount); 417 } 418 return unseenCountString; 419 } 420 421 /** 422 * Get the correct display string for the unread count in the actionbar. 423 */ getUnreadMessageString(Context context, int unreadCount)424 public static CharSequence getUnreadMessageString(Context context, int unreadCount) { 425 final SpannableString message; 426 final Resources resources = context.getResources(); 427 if (sMaxUnreadCount == -1) { 428 sMaxUnreadCount = resources.getInteger(R.integer.maxUnreadCount); 429 } 430 if (unreadCount > sMaxUnreadCount) { 431 message = new SpannableString( 432 resources.getString(R.string.actionbar_large_unread_count, sMaxUnreadCount)); 433 } else { 434 message = new SpannableString(resources.getQuantityString( 435 R.plurals.actionbar_unread_messages, unreadCount, unreadCount)); 436 } 437 438 message.setSpan(CharacterStyle.wrap(ACTION_BAR_UNREAD_STYLE), 0, 439 message.toString().length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 440 441 return message; 442 } 443 444 /** 445 * Get text matching the last sync status. 446 */ getSyncStatusText(Context context, int packedStatus)447 public static CharSequence getSyncStatusText(Context context, int packedStatus) { 448 final String[] errors = context.getResources().getStringArray(R.array.sync_status); 449 final int status = packedStatus & 0x0f; 450 if (status >= errors.length) { 451 return ""; 452 } 453 return errors[status]; 454 } 455 456 /** 457 * Create an intent to show a conversation. 458 * @param conversation Conversation to open. 459 * @param folderUri 460 * @param account 461 * @return 462 */ createViewConversationIntent(final Context context, Conversation conversation, final Uri folderUri, Account account)463 public static Intent createViewConversationIntent(final Context context, 464 Conversation conversation, final Uri folderUri, Account account) { 465 final Intent intent = new Intent(Intent.ACTION_VIEW); 466 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK 467 | Intent.FLAG_ACTIVITY_TASK_ON_HOME); 468 final Uri versionedUri = appendVersionQueryParameter(context, conversation.uri); 469 // We need the URI to be unique, even if it's for the same message, so append the folder URI 470 final Uri uniqueUri = versionedUri.buildUpon().appendQueryParameter( 471 FOLDER_URI_QUERY_PARAMETER, folderUri.toString()).build(); 472 intent.setDataAndType(uniqueUri, account.mimeType); 473 intent.putExtra(Utils.EXTRA_ACCOUNT, account.serialize()); 474 intent.putExtra(Utils.EXTRA_FOLDER_URI, folderUri); 475 intent.putExtra(Utils.EXTRA_CONVERSATION, conversation); 476 return intent; 477 } 478 479 /** 480 * Create an intent to open a folder. 481 * 482 * @param folderUri Folder to open. 483 * @param account 484 * @return 485 */ createViewFolderIntent(final Context context, final Uri folderUri, Account account)486 public static Intent createViewFolderIntent(final Context context, final Uri folderUri, 487 Account account) { 488 if (folderUri == null || account == null) { 489 LogUtils.wtf(LOG_TAG, "Utils.createViewFolderIntent(%s,%s): Bad input", folderUri, 490 account); 491 return null; 492 } 493 final Intent intent = new Intent(Intent.ACTION_VIEW); 494 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK 495 | Intent.FLAG_ACTIVITY_TASK_ON_HOME); 496 intent.setDataAndType(appendVersionQueryParameter(context, folderUri), account.mimeType); 497 intent.putExtra(Utils.EXTRA_ACCOUNT, account.serialize()); 498 intent.putExtra(Utils.EXTRA_FOLDER_URI, folderUri); 499 return intent; 500 } 501 502 /** 503 * Creates an intent to open the default inbox for the given account. 504 * 505 * @param account 506 * @return 507 */ createViewInboxIntent(Account account)508 public static Intent createViewInboxIntent(Account account) { 509 if (account == null) { 510 LogUtils.wtf(LOG_TAG, "Utils.createViewInboxIntent(%s): Bad input", account); 511 return null; 512 } 513 final Intent intent = new Intent(Intent.ACTION_VIEW); 514 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK 515 | Intent.FLAG_ACTIVITY_TASK_ON_HOME); 516 intent.setDataAndType(account.settings.defaultInbox, account.mimeType); 517 intent.putExtra(Utils.EXTRA_ACCOUNT, account.serialize()); 518 return intent; 519 } 520 521 /** 522 * Helper method to show context-aware help. 523 * 524 * @param context Context to be used to open the help. 525 * @param account Account from which the help URI is extracted 526 * @param helpTopic Information about the activity the user was in 527 * when they requested help which specifies the help topic to display 528 */ showHelp(Context context, Account account, String helpTopic)529 public static void showHelp(Context context, Account account, String helpTopic) { 530 final String urlString = account.helpIntentUri != null ? 531 account.helpIntentUri.toString() : null; 532 if (TextUtils.isEmpty(urlString)) { 533 LogUtils.e(LOG_TAG, "unable to show help for account: %s", account); 534 return; 535 } 536 showHelp(context, account.helpIntentUri, helpTopic); 537 } 538 539 /** 540 * Helper method to show context-aware help. 541 * 542 * @param context Context to be used to open the help. 543 * @param helpIntentUri URI of the help content to display 544 * @param helpTopic Information about the activity the user was in 545 * when they requested help which specifies the help topic to display 546 */ showHelp(Context context, Uri helpIntentUri, String helpTopic)547 public static void showHelp(Context context, Uri helpIntentUri, String helpTopic) { 548 final String urlString = helpIntentUri == null ? null : helpIntentUri.toString(); 549 if (TextUtils.isEmpty(urlString)) { 550 LogUtils.e(LOG_TAG, "unable to show help for help URI: %s", helpIntentUri); 551 return; 552 } 553 554 // generate the full URL to the requested help section 555 final Uri helpUrl = HelpUrl.getHelpUrl(context, helpIntentUri, helpTopic); 556 557 final boolean useBrowser = context.getResources().getBoolean(R.bool.openHelpWithBrowser); 558 if (useBrowser) { 559 // open a browser with the full help URL 560 openUrl(context, helpUrl, null); 561 } else { 562 // start the help activity with the full help URL 563 final Intent intent = new Intent(context, HelpActivity.class); 564 intent.putExtra(HelpActivity.PARAM_HELP_URL, helpUrl); 565 context.startActivity(intent); 566 } 567 } 568 569 /** 570 * Helper method to open a link in a browser. 571 * 572 * @param context Context 573 * @param uri Uri to open. 574 */ openUrl(Context context, Uri uri, Bundle optionalExtras)575 private static void openUrl(Context context, Uri uri, Bundle optionalExtras) { 576 if(uri == null || TextUtils.isEmpty(uri.toString())) { 577 LogUtils.wtf(LOG_TAG, "invalid url in Utils.openUrl(): %s", uri); 578 return; 579 } 580 final Intent intent = new Intent(Intent.ACTION_VIEW, uri); 581 // Fill in any of extras that have been requested. 582 if (optionalExtras != null) { 583 intent.putExtras(optionalExtras); 584 } 585 intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); 586 intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 587 588 context.startActivity(intent); 589 } 590 591 /** 592 * Show the top level settings screen for the supplied account. 593 */ showSettings(Context context, Account account)594 public static void showSettings(Context context, Account account) { 595 if (account == null) { 596 LogUtils.e(LOG_TAG, "Invalid attempt to show setting screen with null account"); 597 return; 598 } 599 final Intent settingsIntent = new Intent(Intent.ACTION_EDIT, account.settingsIntentUri); 600 601 settingsIntent.setPackage(context.getPackageName()); 602 settingsIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 603 604 context.startActivity(settingsIntent); 605 } 606 607 /** 608 * Show the account level settings screen for the supplied account. 609 */ showAccountSettings(Context context, Account account)610 public static void showAccountSettings(Context context, Account account) { 611 if (account == null) { 612 LogUtils.e(LOG_TAG, "Invalid attempt to show setting screen with null account"); 613 return; 614 } 615 final Intent settingsIntent = new Intent(Intent.ACTION_EDIT, 616 appendVersionQueryParameter(context, account.settingsIntentUri)); 617 618 settingsIntent.setPackage(context.getPackageName()); 619 settingsIntent.putExtra(EditSettingsExtras.EXTRA_ACCOUNT, account); 620 settingsIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 621 622 context.startActivity(settingsIntent); 623 } 624 625 /** 626 * Show the feedback screen for the supplied account. 627 */ sendFeedback(Activity activity, Account account, boolean reportingProblem)628 public static void sendFeedback(Activity activity, Account account, boolean reportingProblem) { 629 if (activity != null && account != null) { 630 sendFeedback(activity, account.sendFeedbackIntentUri, reportingProblem); 631 } 632 } 633 sendFeedback(Activity activity, Uri feedbackIntentUri, boolean reportingProblem)634 public static void sendFeedback(Activity activity, Uri feedbackIntentUri, 635 boolean reportingProblem) { 636 if (activity != null && !isEmpty(feedbackIntentUri)) { 637 final Bundle optionalExtras = new Bundle(2); 638 optionalExtras.putBoolean( 639 UIProvider.SendFeedbackExtras.EXTRA_REPORTING_PROBLEM, reportingProblem); 640 final Bitmap screenBitmap = getReducedSizeBitmap(activity); 641 if (screenBitmap != null) { 642 optionalExtras.putParcelable( 643 UIProvider.SendFeedbackExtras.EXTRA_SCREEN_SHOT, screenBitmap); 644 } 645 openUrl(activity, feedbackIntentUri, optionalExtras); 646 } 647 } 648 getReducedSizeBitmap(Activity activity)649 private static Bitmap getReducedSizeBitmap(Activity activity) { 650 final Window activityWindow = activity.getWindow(); 651 final View currentView = activityWindow != null ? activityWindow.getDecorView() : null; 652 final View rootView = currentView != null ? currentView.getRootView() : null; 653 if (rootView != null) { 654 rootView.setDrawingCacheEnabled(true); 655 final Bitmap drawingCache = rootView.getDrawingCache(); 656 // Null check to avoid NPE discovered from monkey crash: 657 if (drawingCache != null) { 658 try { 659 final Bitmap originalBitmap = drawingCache.copy(Bitmap.Config.RGB_565, false); 660 double originalHeight = originalBitmap.getHeight(); 661 double originalWidth = originalBitmap.getWidth(); 662 int newHeight = SCALED_SCREENSHOT_MAX_HEIGHT_WIDTH; 663 int newWidth = SCALED_SCREENSHOT_MAX_HEIGHT_WIDTH; 664 double scaleX, scaleY; 665 scaleX = newWidth / originalWidth; 666 scaleY = newHeight / originalHeight; 667 final double scale = Math.min(scaleX, scaleY); 668 newWidth = (int)Math.round(originalWidth * scale); 669 newHeight = (int)Math.round(originalHeight * scale); 670 return Bitmap.createScaledBitmap(originalBitmap, newWidth, newHeight, true); 671 } catch (OutOfMemoryError e) { 672 LogUtils.e(LOG_TAG, e, "OOME when attempting to scale screenshot"); 673 } 674 } 675 } 676 return null; 677 } 678 679 /** 680 * Retrieves the mailbox search query associated with an intent (or null if not available), 681 * doing proper sanitizing (e.g. trims whitespace). 682 */ mailSearchQueryForIntent(Intent intent)683 public static String mailSearchQueryForIntent(Intent intent) { 684 String query = intent.getStringExtra(SearchManager.QUERY); 685 return TextUtils.isEmpty(query) ? null : query.trim(); 686 } 687 688 /** 689 * Split out a filename's extension and return it. 690 * @param filename a file name 691 * @return the file extension (max of 5 chars including period, like ".docx"), or null 692 */ getFileExtension(String filename)693 public static String getFileExtension(String filename) { 694 String extension = null; 695 int index = !TextUtils.isEmpty(filename) ? filename.lastIndexOf('.') : -1; 696 // Limit the suffix to dot + four characters 697 if (index >= 0 && filename.length() - index <= FILE_EXTENSION_MAX_CHARS + 1) { 698 extension = filename.substring(index); 699 } 700 return extension; 701 } 702 703 /** 704 * (copied from {@link Intent#normalizeMimeType(String)} for pre-J) 705 * 706 * Normalize a MIME data type. 707 * 708 * <p>A normalized MIME type has white-space trimmed, 709 * content-type parameters removed, and is lower-case. 710 * This aligns the type with Android best practices for 711 * intent filtering. 712 * 713 * <p>For example, "text/plain; charset=utf-8" becomes "text/plain". 714 * "text/x-vCard" becomes "text/x-vcard". 715 * 716 * <p>All MIME types received from outside Android (such as user input, 717 * or external sources like Bluetooth, NFC, or the Internet) should 718 * be normalized before they are used to create an Intent. 719 * 720 * @param type MIME data type to normalize 721 * @return normalized MIME data type, or null if the input was null 722 * @see {@link android.content.Intent#setType} 723 * @see {@link android.content.Intent#setTypeAndNormalize} 724 */ normalizeMimeType(String type)725 public static String normalizeMimeType(String type) { 726 if (type == null) { 727 return null; 728 } 729 730 type = type.trim().toLowerCase(Locale.US); 731 732 final int semicolonIndex = type.indexOf(';'); 733 if (semicolonIndex != -1) { 734 type = type.substring(0, semicolonIndex); 735 } 736 return type; 737 } 738 739 /** 740 * (copied from {@link android.net.Uri#normalizeScheme()} for pre-J) 741 * 742 * Return a normalized representation of this Uri. 743 * 744 * <p>A normalized Uri has a lowercase scheme component. 745 * This aligns the Uri with Android best practices for 746 * intent filtering. 747 * 748 * <p>For example, "HTTP://www.android.com" becomes 749 * "http://www.android.com" 750 * 751 * <p>All URIs received from outside Android (such as user input, 752 * or external sources like Bluetooth, NFC, or the Internet) should 753 * be normalized before they are used to create an Intent. 754 * 755 * <p class="note">This method does <em>not</em> validate bad URI's, 756 * or 'fix' poorly formatted URI's - so do not use it for input validation. 757 * A Uri will always be returned, even if the Uri is badly formatted to 758 * begin with and a scheme component cannot be found. 759 * 760 * @return normalized Uri (never null) 761 * @see {@link android.content.Intent#setData} 762 */ normalizeUri(Uri uri)763 public static Uri normalizeUri(Uri uri) { 764 String scheme = uri.getScheme(); 765 if (scheme == null) return uri; // give up 766 String lowerScheme = scheme.toLowerCase(Locale.US); 767 if (scheme.equals(lowerScheme)) return uri; // no change 768 769 return uri.buildUpon().scheme(lowerScheme).build(); 770 } 771 setIntentTypeAndNormalize(Intent intent, String type)772 public static Intent setIntentTypeAndNormalize(Intent intent, String type) { 773 return intent.setType(normalizeMimeType(type)); 774 } 775 setIntentDataAndTypeAndNormalize(Intent intent, Uri data, String type)776 public static Intent setIntentDataAndTypeAndNormalize(Intent intent, Uri data, String type) { 777 return intent.setDataAndType(normalizeUri(data), normalizeMimeType(type)); 778 } 779 getTransparentColor(int color)780 public static int getTransparentColor(int color) { 781 return 0x00ffffff & color; 782 } 783 setMenuItemVisibility(Menu menu, int itemId, boolean shouldShow)784 public static void setMenuItemVisibility(Menu menu, int itemId, boolean shouldShow) { 785 final MenuItem item = menu.findItem(itemId); 786 if (item == null) { 787 return; 788 } 789 item.setVisible(shouldShow); 790 } 791 792 /** 793 * Parse a string (possibly null or empty) into a URI. If the string is null 794 * or empty, null is returned back. Otherwise an empty URI is returned. 795 * 796 * @param uri 797 * @return a valid URI, possibly {@link android.net.Uri#EMPTY} 798 */ getValidUri(String uri)799 public static Uri getValidUri(String uri) { 800 if (TextUtils.isEmpty(uri) || uri == JSONObject.NULL) 801 return Uri.EMPTY; 802 return Uri.parse(uri); 803 } 804 isEmpty(Uri uri)805 public static boolean isEmpty(Uri uri) { 806 return uri == null || Uri.EMPTY.equals(uri); 807 } 808 dumpFragment(Fragment f)809 public static String dumpFragment(Fragment f) { 810 final StringWriter sw = new StringWriter(); 811 f.dump("", new FileDescriptor(), new PrintWriter(sw), new String[0]); 812 return sw.toString(); 813 } 814 dumpViewTree(ViewGroup root)815 public static void dumpViewTree(ViewGroup root) { 816 dumpViewTree(root, ""); 817 } 818 dumpViewTree(ViewGroup g, String prefix)819 private static void dumpViewTree(ViewGroup g, String prefix) { 820 LogUtils.i(LOG_TAG, "%sVIEWGROUP: %s childCount=%s", prefix, g, g.getChildCount()); 821 final String childPrefix = prefix + " "; 822 for (int i = 0; i < g.getChildCount(); i++) { 823 final View child = g.getChildAt(i); 824 if (child instanceof ViewGroup) { 825 dumpViewTree((ViewGroup) child, childPrefix); 826 } else { 827 LogUtils.i(LOG_TAG, "%sCHILD #%s: %s", childPrefix, i, child); 828 } 829 } 830 } 831 832 /** 833 * Executes an out-of-band command on the cursor. 834 * @param cursor 835 * @param request Bundle with all keys and values set for the command. 836 * @param key The string value against which we will check for success or failure 837 * @return true if the operation was a success. 838 */ executeConversationCursorCommand( Cursor cursor, Bundle request, String key)839 private static boolean executeConversationCursorCommand( 840 Cursor cursor, Bundle request, String key) { 841 final Bundle response = cursor.respond(request); 842 final String result = response.getString(key, 843 UIProvider.ConversationCursorCommand.COMMAND_RESPONSE_FAILED); 844 845 return UIProvider.ConversationCursorCommand.COMMAND_RESPONSE_OK.equals(result); 846 } 847 848 /** 849 * Commands a cursor representing a set of conversations to indicate that an item is being shown 850 * in the UI. 851 * 852 * @param cursor a conversation cursor 853 * @param position position of the item being shown. 854 */ notifyCursorUIPositionChange(Cursor cursor, int position)855 public static boolean notifyCursorUIPositionChange(Cursor cursor, int position) { 856 final Bundle request = new Bundle(); 857 final String key = 858 UIProvider.ConversationCursorCommand.COMMAND_NOTIFY_CURSOR_UI_POSITION_CHANGE; 859 request.putInt(key, position); 860 return executeConversationCursorCommand(cursor, request, key); 861 } 862 863 /** 864 * Commands a cursor representing a set of conversations to set its visibility state. 865 * 866 * @param cursor a conversation cursor 867 * @param visible true if the conversation list is visible, false otherwise. 868 * @param isFirstSeen true if you want to notify the cursor that this conversation list was seen 869 * for the first time: the user launched the app into it, or the user switched from some 870 * other folder into it. 871 */ setConversationCursorVisibility( Cursor cursor, boolean visible, boolean isFirstSeen)872 public static void setConversationCursorVisibility( 873 Cursor cursor, boolean visible, boolean isFirstSeen) { 874 new MarkConversationCursorVisibleTask(cursor, visible, isFirstSeen).execute(); 875 } 876 877 /** 878 * Async task for marking conversations "seen" and informing the cursor that the folder was 879 * seen for the first time by the UI. 880 */ 881 private static class MarkConversationCursorVisibleTask extends AsyncTask<Void, Void, Void> { 882 private final Cursor mCursor; 883 private final boolean mVisible; 884 private final boolean mIsFirstSeen; 885 886 /** 887 * Create a new task with the given cursor, with the given visibility and 888 * 889 * @param cursor 890 * @param isVisible true if the conversation list is visible, false otherwise. 891 * @param isFirstSeen true if the folder was shown for the first time: either the user has 892 * just switched to it, or the user started the app in this folder. 893 */ MarkConversationCursorVisibleTask( Cursor cursor, boolean isVisible, boolean isFirstSeen)894 public MarkConversationCursorVisibleTask( 895 Cursor cursor, boolean isVisible, boolean isFirstSeen) { 896 mCursor = cursor; 897 mVisible = isVisible; 898 mIsFirstSeen = isFirstSeen; 899 } 900 901 @Override doInBackground(Void... params)902 protected Void doInBackground(Void... params) { 903 if (mCursor == null) { 904 return null; 905 } 906 final Bundle request = new Bundle(); 907 if (mIsFirstSeen) { 908 request.putBoolean( 909 UIProvider.ConversationCursorCommand.COMMAND_KEY_ENTERED_FOLDER, true); 910 } 911 final String key = UIProvider.ConversationCursorCommand.COMMAND_KEY_SET_VISIBILITY; 912 request.putBoolean(key, mVisible); 913 executeConversationCursorCommand(mCursor, request, key); 914 return null; 915 } 916 } 917 918 919 /** 920 * This utility method returns the conversation ID at the current cursor position. 921 * @return the conversation id at the cursor. 922 */ getConversationId(ConversationCursor cursor)923 public static long getConversationId(ConversationCursor cursor) { 924 return cursor.getLong(UIProvider.CONVERSATION_ID_COLUMN); 925 } 926 927 /** 928 * This utility method returns the conversation Uri at the current cursor position. 929 * @return the conversation id at the cursor. 930 */ getConversationUri(ConversationCursor cursor)931 public static String getConversationUri(ConversationCursor cursor) { 932 return cursor.getString(UIProvider.CONVERSATION_URI_COLUMN); 933 } 934 935 /** 936 * @return whether to show two pane or single pane search results. 937 */ showTwoPaneSearchResults(Context context)938 public static boolean showTwoPaneSearchResults(Context context) { 939 return context.getResources().getBoolean(R.bool.show_two_pane_search_results); 940 } 941 942 /** 943 * Sets the layer type of a view to hardware if the view is attached and hardware acceleration 944 * is enabled. Does nothing otherwise. 945 */ enableHardwareLayer(View v)946 public static void enableHardwareLayer(View v) { 947 if (v != null && v.isHardwareAccelerated()) { 948 v.setLayerType(View.LAYER_TYPE_HARDWARE, null); 949 v.buildLayer(); 950 } 951 } 952 getDefaultFolderBackgroundColor(Context context)953 public static int getDefaultFolderBackgroundColor(Context context) { 954 if (sDefaultFolderBackgroundColor == -1) { 955 sDefaultFolderBackgroundColor = context.getResources().getColor( 956 R.color.default_folder_background_color); 957 } 958 return sDefaultFolderBackgroundColor; 959 } 960 961 /** 962 * Returns the count that should be shown for the specified folder. This method should be used 963 * when the UI wants to display an "unread" count. For most labels, the returned value will be 964 * the unread count, but for some folder types (outbox, drafts, trash) this will return the 965 * total count. 966 */ getFolderUnreadDisplayCount(final Folder folder)967 public static int getFolderUnreadDisplayCount(final Folder folder) { 968 if (folder != null) { 969 if (folder.supportsCapability(UIProvider.FolderCapabilities.UNSEEN_COUNT_ONLY)) { 970 return 0; 971 } else if (folder.isUnreadCountHidden()) { 972 return folder.totalCount; 973 } else { 974 return folder.unreadCount; 975 } 976 } 977 return 0; 978 } 979 980 /** 981 * @return an intent which, if launched, will reply to the conversation 982 */ createReplyIntent(final Context context, final Account account, final Uri messageUri, final boolean isReplyAll)983 public static Intent createReplyIntent(final Context context, final Account account, 984 final Uri messageUri, final boolean isReplyAll) { 985 final Intent intent = 986 ComposeActivity.createReplyIntent(context, account, messageUri, isReplyAll); 987 return intent; 988 } 989 990 /** 991 * @return an intent which, if launched, will forward the conversation 992 */ createForwardIntent( final Context context, final Account account, final Uri messageUri)993 public static Intent createForwardIntent( 994 final Context context, final Account account, final Uri messageUri) { 995 final Intent intent = ComposeActivity.createForwardIntent(context, account, messageUri); 996 return intent; 997 } 998 appendVersionQueryParameter(final Context context, final Uri uri)999 public static Uri appendVersionQueryParameter(final Context context, final Uri uri) { 1000 return uri.buildUpon().appendQueryParameter(APP_VERSION_QUERY_PARAMETER, 1001 getVersionCode(context)).build(); 1002 } 1003 1004 /** 1005 * Convenience method for diverting mailto: uris directly to our compose activity. Using this 1006 * method ensures that the Account object is not accidentally sent to a different process. 1007 * 1008 * @param context for sending the intent 1009 * @param uri mailto: or other uri 1010 * @param account desired account for potential compose activity 1011 * @return true if a compose activity was started, false if uri should be sent to a view intent 1012 */ divertMailtoUri(final Context context, final Uri uri, final Account account)1013 public static boolean divertMailtoUri(final Context context, final Uri uri, 1014 final Account account) { 1015 final String scheme = normalizeUri(uri).getScheme(); 1016 if (TextUtils.equals(MAILTO_SCHEME, scheme)) { 1017 ComposeActivity.composeMailto(context, account, uri); 1018 return true; 1019 } 1020 return false; 1021 } 1022 1023 /** 1024 * Gets the specified {@link Folder} object. 1025 * 1026 * @param folderUri The {@link Uri} for the folder 1027 * @param allowHidden <code>true</code> to allow a hidden folder to be returned, 1028 * <code>false</code> to return <code>null</code> instead 1029 * @return the specified {@link Folder} object, or <code>null</code> 1030 */ getFolder(final Context context, final Uri folderUri, final boolean allowHidden)1031 public static Folder getFolder(final Context context, final Uri folderUri, 1032 final boolean allowHidden) { 1033 final Uri uri = folderUri 1034 .buildUpon() 1035 .appendQueryParameter(UIProvider.ALLOW_HIDDEN_FOLDERS_QUERY_PARAM, 1036 Boolean.toString(allowHidden)) 1037 .build(); 1038 1039 final Cursor cursor = context.getContentResolver().query(uri, 1040 UIProvider.FOLDERS_PROJECTION, null, null, null); 1041 1042 if (cursor == null) { 1043 return null; 1044 } 1045 1046 try { 1047 if (cursor.moveToFirst()) { 1048 return new Folder(cursor); 1049 } else { 1050 return null; 1051 } 1052 } finally { 1053 cursor.close(); 1054 } 1055 } 1056 1057 /** 1058 * Begins systrace tracing for a given tag. No-op on unsupported platform versions. 1059 * 1060 * @param tag systrace tag to use 1061 * 1062 * @see android.os.Trace#beginSection(String) 1063 */ traceBeginSection(String tag)1064 public static void traceBeginSection(String tag) { 1065 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { 1066 android.os.Trace.beginSection(tag); 1067 } 1068 } 1069 1070 /** 1071 * Ends systrace tracing for the most recently begun section. No-op on unsupported platform 1072 * versions. 1073 * 1074 * @see android.os.Trace#endSection() 1075 */ traceEndSection()1076 public static void traceEndSection() { 1077 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { 1078 android.os.Trace.endSection(); 1079 } 1080 } 1081 1082 /** 1083 * Given a value and a set of upper-bounds to use as buckets, return the smallest upper-bound 1084 * that is greater than the value.<br> 1085 * <br> 1086 * Useful for turning a continuous value into one of a set of discrete ones. 1087 * 1088 * @param value a value to bucketize 1089 * @param upperBounds list of upper-bound buckets to clamp to, sorted from smallest-greatest 1090 * @return the smallest upper-bound larger than the value, or -1 if the value is larger than 1091 * all upper-bounds 1092 */ getUpperBound(long value, long[] upperBounds)1093 public static long getUpperBound(long value, long[] upperBounds) { 1094 for (long ub : upperBounds) { 1095 if (value < ub) { 1096 return ub; 1097 } 1098 } 1099 return -1; 1100 } 1101 getAddress(Map<String, Address> cache, String emailStr)1102 public static Address getAddress(Map<String, Address> cache, String emailStr) { 1103 Address addr; 1104 synchronized (cache) { 1105 addr = cache.get(emailStr); 1106 if (addr == null) { 1107 addr = Address.getEmailAddress(emailStr); 1108 if (addr != null) { 1109 cache.put(emailStr, addr); 1110 } 1111 } 1112 } 1113 return addr; 1114 } 1115 1116 /** 1117 * Applies the given appearance on the given subString, and inserts that as a parameter in the 1118 * given parentString. 1119 */ insertStringWithStyle(Context context, String entireString, String subString, int appearance)1120 public static Spanned insertStringWithStyle(Context context, 1121 String entireString, String subString, int appearance) { 1122 final Resources resources = context.getResources(); 1123 final int index = entireString.indexOf(subString); 1124 final SpannableString descriptionText = new SpannableString(entireString); 1125 descriptionText.setSpan( 1126 new TextAppearanceSpan(context, appearance), 1127 index, 1128 index + subString.length(), 1129 0); 1130 return descriptionText; 1131 } 1132 1133 /** 1134 * Email addresses are supposed to be treated as case-insensitive for the host-part and 1135 * case-sensitive for the local-part, but nobody really wants email addresses to match 1136 * case-sensitive on the local-part, so just smash everything to lower case. 1137 * @param email Hello@Example.COM 1138 * @return hello@example.com 1139 */ normalizeEmailAddress(String email)1140 public static String normalizeEmailAddress(String email) { 1141 /* 1142 // The RFC5321 version 1143 if (TextUtils.isEmpty(email)) { 1144 return email; 1145 } 1146 String[] parts = email.split("@"); 1147 if (parts.length != 2) { 1148 LogUtils.d(LOG_TAG, "Tried to normalize a malformed email address: ", email); 1149 return email; 1150 } 1151 1152 return parts[0] + "@" + parts[1].toLowerCase(Locale.US); 1153 */ 1154 if (TextUtils.isEmpty(email)) { 1155 return email; 1156 } else { 1157 // Doing this for other locales might really screw things up, so do US-version only 1158 return email.toLowerCase(Locale.US); 1159 } 1160 } 1161 1162 /** 1163 * Returns whether the device currently has network connection. This does not guarantee that 1164 * the connection is reliable. 1165 */ isConnected(final Context context)1166 public static boolean isConnected(final Context context) { 1167 final ConnectivityManager connectivityManager = 1168 ((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); 1169 final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); 1170 return (networkInfo != null) && networkInfo.isConnected(); 1171 } 1172 } 1173