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