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