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