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 
isRunningJellybeanOrLater()131     public static boolean isRunningJellybeanOrLater() {
132         return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN;
133     }
134 
isRunningJBMR1OrLater()135     public static boolean isRunningJBMR1OrLater() {
136         return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1;
137     }
138 
isRunningKitkatOrLater()139     public static boolean isRunningKitkatOrLater() {
140         return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
141     }
142 
isRunningLOrLater()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)
isLowRamDevice(Context context)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      */
restrictWebView(WebView webView)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      */
setCustomUserAgent(WebView webView, Context context)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      */
getVersionCode(Context context)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      */
formatPlural(Context context, int resource, int count)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      */
ellipsize(String text, int maxCharacters)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
onConfigurationChanged(Configuration configuration)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
onLowMemory()270         public void onLowMemory() {}
271     }
272 
getStaticResources(Context context)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 
getMaxUnreadCount(Context context)292     private static int getMaxUnreadCount(Context context) {
293         synchronized (sStaticResourcesLock) {
294             getStaticResources(context);
295             return sMaxUnreadCount;
296         }
297     }
298 
getUnreadText(Context context)299     private static String getUnreadText(Context context) {
300         synchronized (sStaticResourcesLock) {
301             getStaticResources(context);
302             return sUnreadText;
303         }
304     }
305 
getUnseenText(Context context)306     private static String getUnseenText(Context context) {
307         synchronized (sStaticResourcesLock) {
308             getStaticResources(context);
309             return sUnseenText;
310         }
311     }
312 
getLargeUnseenText(Context context)313     private static String getLargeUnseenText(Context context) {
314         synchronized (sStaticResourcesLock) {
315             getStaticResources(context);
316             return sLargeUnseenText;
317         }
318     }
319 
getDefaultFolderBackgroundColor(Context context)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      */
useTabletUI(Resources res)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      */
convertHtmlToPlainText(String htmlText)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 
convertHtmlToPlainText(String htmlText, HtmlParser parser, HtmlTreeBuilder builder)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      */
getHtmlTree(String htmlText)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      */
getHtmlTree(String htmlText, HtmlParser parser, HtmlTreeBuilder builder)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      */
measureViewHeight(View child, ViewGroup parent)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      */
cleanUpString(String string, boolean removeEmptyDoubleQuotes)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      */
getUnreadCountString(Context context, int unreadCount)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      */
getUnseenCountString(Context context, int unseenCount)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      */
getSyncStatusText(Context context, int packedStatus)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      */
createViewConversationIntent(final Context context, Conversation conversation, final Uri folderUri, Account account)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      */
createViewFolderIntent(final Context context, final Uri folderUri, Account account)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      */
createViewInboxIntent(Account account)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      */
showHelp(Context context, Account account, String helpTopic)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      */
showHelp(Context context, Uri helpIntentUri, String helpTopic)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      */
openUrl(Context context, Uri uri, Bundle optionalExtras)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      */
showSettings(Context context, Account account)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      */
showAccountSettings(Context context, Account account)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      */
sendFeedback(Activity activity, Account account, boolean reportingProblem)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 
sendFeedback(Activity activity, Uri feedbackIntentUri, boolean reportingProblem)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 
getReducedSizeBitmap(Activity activity)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      */
getFileExtension(String filename)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     */
normalizeMimeType(String type)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     */
normalizeUri(Uri uri)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 
setIntentTypeAndNormalize(Intent intent, String type)770    public static Intent setIntentTypeAndNormalize(Intent intent, String type) {
771        return intent.setType(normalizeMimeType(type));
772    }
773 
setIntentDataAndTypeAndNormalize(Intent intent, Uri data, String type)774    public static Intent setIntentDataAndTypeAndNormalize(Intent intent, Uri data, String type) {
775        return intent.setDataAndType(normalizeUri(data), normalizeMimeType(type));
776    }
777 
getTransparentColor(int color)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      */
setMenuItemPresent(Menu menu, int itemId, boolean shouldShow)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      */
setMenuItemPresent(MenuItem item, boolean shouldShow)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      */
getValidUri(String uri)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 
isEmpty(Uri uri)815     public static boolean isEmpty(Uri uri) {
816         return uri == null || Uri.EMPTY.equals(uri);
817     }
818 
dumpFragment(Fragment f)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      */
executeConversationCursorCommand( Cursor cursor, Bundle request, String key)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      */
notifyCursorUIPositionChange(Cursor cursor, int position)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      */
setConversationCursorVisibility( Cursor cursor, boolean visible, boolean isFirstSeen)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          */
MarkConversationCursorVisibleTask( Cursor cursor, boolean isVisible, boolean isFirstSeen)887         public MarkConversationCursorVisibleTask(
888                 Cursor cursor, boolean isVisible, boolean isFirstSeen) {
889             mCursor = cursor;
890             mVisible = isVisible;
891             mIsFirstSeen = isFirstSeen;
892         }
893 
894         @Override
doInBackground(Void... params)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      */
getConversationId(ConversationCursor cursor)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      */
enableHardwareLayer(View v)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      */
getFolderUnreadDisplayCount(final Folder folder)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 
appendVersionQueryParameter(final Context context, final Uri uri)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      */
divertMailtoUri(final Context context, final Uri uri, final Account account)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      */
getFolder(final Context context, final Uri folderUri, final boolean allowHidden)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      */
traceBeginSection(String tag)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      */
traceEndSection()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      */
getUpperBound(long value, long[] upperBounds)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 
getAddress(Map<String, Address> cache, String emailStr)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      */
insertStringWithStyle(Context context, String entireString, String subString, int appearance)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@Example.COM
1091      * @return hello@example.com
1092      */
normalizeEmailAddress(String email)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      */
isConnected(final Context context)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