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