• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.support.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                         .versionCode);
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