| /** |
| * Copyright (c) 2011, Google Inc. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.mail.utils; |
| |
| import android.annotation.TargetApi; |
| import android.app.Activity; |
| import android.app.ActivityManager; |
| import android.app.Fragment; |
| import android.app.SearchManager; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.pm.PackageManager.NameNotFoundException; |
| import android.content.res.Resources; |
| import android.content.res.TypedArray; |
| import android.database.Cursor; |
| import android.graphics.Bitmap; |
| import android.graphics.Typeface; |
| import android.net.ConnectivityManager; |
| import android.net.NetworkInfo; |
| import android.net.Uri; |
| import android.os.AsyncTask; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.provider.Browser; |
| import android.text.Spannable; |
| import android.text.SpannableString; |
| import android.text.Spanned; |
| import android.text.TextUtils; |
| import android.text.style.CharacterStyle; |
| import android.text.style.StyleSpan; |
| import android.text.style.TextAppearanceSpan; |
| import android.util.TypedValue; |
| import android.view.Menu; |
| import android.view.MenuItem; |
| import android.view.View; |
| import android.view.View.MeasureSpec; |
| import android.view.ViewGroup; |
| import android.view.ViewGroup.MarginLayoutParams; |
| import android.view.Window; |
| import android.webkit.WebSettings; |
| import android.webkit.WebView; |
| |
| import com.android.emailcommon.mail.Address; |
| import com.android.mail.R; |
| import com.android.mail.browse.ConversationCursor; |
| import com.android.mail.compose.ComposeActivity; |
| import com.android.mail.perf.SimpleTimer; |
| import com.android.mail.providers.Account; |
| import com.android.mail.providers.Conversation; |
| import com.android.mail.providers.Folder; |
| import com.android.mail.providers.UIProvider; |
| import com.android.mail.providers.UIProvider.EditSettingsExtras; |
| import com.android.mail.ui.HelpActivity; |
| import com.android.mail.ui.ViewMode; |
| import com.google.android.mail.common.html.parser.HtmlDocument; |
| import com.google.android.mail.common.html.parser.HtmlParser; |
| import com.google.android.mail.common.html.parser.HtmlTree; |
| import com.google.android.mail.common.html.parser.HtmlTreeBuilder; |
| |
| import org.json.JSONObject; |
| |
| import java.io.FileDescriptor; |
| import java.io.PrintWriter; |
| import java.io.StringWriter; |
| import java.util.Locale; |
| import java.util.Map; |
| |
| public class Utils { |
| /** |
| * longest extension we recognize is 4 characters (e.g. "html", "docx") |
| */ |
| private static final int FILE_EXTENSION_MAX_CHARS = 4; |
| public static final String SENDER_LIST_TOKEN_ELIDED = "e"; |
| public static final String SENDER_LIST_TOKEN_NUM_MESSAGES = "n"; |
| public static final String SENDER_LIST_TOKEN_NUM_DRAFTS = "d"; |
| public static final String SENDER_LIST_TOKEN_LITERAL = "l"; |
| public static final String SENDER_LIST_TOKEN_SENDING = "s"; |
| public static final String SENDER_LIST_TOKEN_SEND_FAILED = "f"; |
| public static final Character SENDER_LIST_SEPARATOR = '\n'; |
| |
| public static final String EXTRA_ACCOUNT = "account"; |
| public static final String EXTRA_ACCOUNT_URI = "accountUri"; |
| public static final String EXTRA_FOLDER_URI = "folderUri"; |
| public static final String EXTRA_FOLDER = "folder"; |
| public static final String EXTRA_COMPOSE_URI = "composeUri"; |
| public static final String EXTRA_CONVERSATION = "conversationUri"; |
| public static final String EXTRA_FROM_NOTIFICATION = "notification"; |
| public static final String EXTRA_IGNORE_INITIAL_CONVERSATION_LIMIT = |
| "ignore-initial-conversation-limit"; |
| |
| private static final String MAILTO_SCHEME = "mailto"; |
| |
| /** Extra tag for debugging the blank fragment problem. */ |
| public static final String VIEW_DEBUGGING_TAG = "MailBlankFragment"; |
| |
| /* |
| * Notifies that changes happened. Certain UI components, e.g., widgets, can |
| * register for this {@link Intent} and update accordingly. However, this |
| * can be very broad and is NOT the preferred way of getting notification. |
| */ |
| // TODO: UI Provider has this notification URI? |
| public static final String ACTION_NOTIFY_DATASET_CHANGED = |
| "com.android.mail.ACTION_NOTIFY_DATASET_CHANGED"; |
| |
| /** Parameter keys for context-aware help. */ |
| private static final String SMART_HELP_LINK_PARAMETER_NAME = "p"; |
| |
| private static final String SMART_LINK_APP_VERSION = "version"; |
| private static String sVersionCode = null; |
| |
| private static final int SCALED_SCREENSHOT_MAX_HEIGHT_WIDTH = 600; |
| |
| private static final String APP_VERSION_QUERY_PARAMETER = "appVersion"; |
| private static final String FOLDER_URI_QUERY_PARAMETER = "folderUri"; |
| |
| private static final String LOG_TAG = LogTag.getLogTag(); |
| |
| public static final boolean ENABLE_CONV_LOAD_TIMER = false; |
| public static final SimpleTimer sConvLoadTimer = |
| new SimpleTimer(ENABLE_CONV_LOAD_TIMER).withSessionName("ConvLoadTimer"); |
| |
| private static final int[] STYLE_ATTR = new int[] {android.R.attr.background}; |
| |
| public static boolean isRunningJellybeanOrLater() { |
| return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; |
| } |
| |
| public static boolean isRunningJBMR1OrLater() { |
| return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1; |
| } |
| |
| public static boolean isRunningKitkatOrLater() { |
| return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; |
| } |
| |
| public static boolean isRunningLOrLater() { |
| //TODO: Update this to the L SDK once defined. Right now it is fine to use the watch |
| // build version number, as this app woll not be running on watch devices |
| return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH; |
| } |
| |
| /** |
| * @return Whether we are running on a low memory device. This is used to disable certain |
| * memory intensive features in the app. |
| */ |
| @TargetApi(Build.VERSION_CODES.KITKAT) |
| public static boolean isLowRamDevice(Context context) { |
| if (isRunningKitkatOrLater()) { |
| final ActivityManager am = (ActivityManager) context.getSystemService( |
| Context.ACTIVITY_SERVICE); |
| // This will be null when running unit tests |
| return am != null && am.isLowRamDevice(); |
| } else { |
| return false; |
| } |
| } |
| |
| /** |
| * Sets WebView in a restricted mode suitable for email use. |
| * |
| * @param webView The WebView to restrict |
| */ |
| public static void restrictWebView(WebView webView) { |
| WebSettings webSettings = webView.getSettings(); |
| webSettings.setSavePassword(false); |
| webSettings.setSaveFormData(false); |
| webSettings.setJavaScriptEnabled(false); |
| webSettings.setSupportZoom(false); |
| } |
| |
| /** |
| * Sets custom user agent to WebView so we don't get GAIA interstitials b/13990689. |
| * |
| * @param webView The WebView to customize. |
| */ |
| public static void setCustomUserAgent(WebView webView, Context context) { |
| final WebSettings settings = webView.getSettings(); |
| final String version = getVersionCode(context); |
| final String originalUserAgent = settings.getUserAgentString(); |
| final String userAgent = context.getResources().getString( |
| R.string.user_agent_format, originalUserAgent, version); |
| settings.setUserAgentString(userAgent); |
| } |
| |
| /** |
| * Returns the version code for the package, or null if it cannot be retrieved. |
| */ |
| public static String getVersionCode(Context context) { |
| if (sVersionCode == null) { |
| try { |
| sVersionCode = String.valueOf(context.getPackageManager() |
| .getPackageInfo(context.getPackageName(), 0 /* flags */) |
| .versionCode); |
| } catch (NameNotFoundException e) { |
| LogUtils.e(Utils.LOG_TAG, "Error finding package %s", |
| context.getApplicationInfo().packageName); |
| } |
| } |
| return sVersionCode; |
| } |
| |
| /** |
| * Format a plural string. |
| * |
| * @param resource The identity of the resource, which must be a R.plurals |
| * @param count The number of items. |
| */ |
| public static String formatPlural(Context context, int resource, int count) { |
| final CharSequence formatString = context.getResources().getQuantityText(resource, count); |
| return String.format(formatString.toString(), count); |
| } |
| |
| /** |
| * @return an ellipsized String that's at most maxCharacters long. If the |
| * text passed is longer, it will be abbreviated. If it contains a |
| * suffix, the ellipses will be inserted in the middle and the |
| * suffix will be preserved. |
| */ |
| public static String ellipsize(String text, int maxCharacters) { |
| int length = text.length(); |
| if (length < maxCharacters) |
| return text; |
| |
| int realMax = Math.min(maxCharacters, length); |
| // Preserve the suffix if any |
| int index = text.lastIndexOf("."); |
| String extension = "\u2026"; // "..."; |
| if (index >= 0) { |
| // Limit the suffix to dot + four characters |
| if (length - index <= FILE_EXTENSION_MAX_CHARS + 1) { |
| extension = extension + text.substring(index + 1); |
| } |
| } |
| realMax -= extension.length(); |
| if (realMax < 0) |
| realMax = 0; |
| return text.substring(0, realMax) + extension; |
| } |
| |
| private static int sMaxUnreadCount = -1; |
| private static final CharacterStyle ACTION_BAR_UNREAD_STYLE = new StyleSpan(Typeface.BOLD); |
| private static String sUnreadText; |
| private static String sUnseenText; |
| private static String sLargeUnseenText; |
| private static int sDefaultFolderBackgroundColor = -1; |
| private static int sUseFolderListFragmentTransition = -1; |
| |
| /** |
| * Returns a boolean indicating whether the table UI should be shown. |
| */ |
| public static boolean useTabletUI(Resources res) { |
| return res.getBoolean(R.bool.use_tablet_ui); |
| } |
| |
| /** |
| * @return <code>true</code> if the right edge effect should be displayed on list items |
| */ |
| public static boolean getDisplayListRightEdgeEffect(final boolean tabletDevice, |
| final boolean listCollapsible, final int viewMode) { |
| return tabletDevice && !listCollapsible |
| && (ViewMode.isConversationMode(viewMode) || ViewMode.isAdMode(viewMode)); |
| } |
| |
| /** |
| * Returns a boolean indicating whether or not we should animate in the |
| * folder list fragment. |
| */ |
| public static boolean useFolderListFragmentTransition(Context context) { |
| if (sUseFolderListFragmentTransition == -1) { |
| sUseFolderListFragmentTransition = context.getResources().getInteger( |
| R.integer.use_folder_list_fragment_transition); |
| } |
| return sUseFolderListFragmentTransition != 0; |
| } |
| |
| /** |
| * Returns displayable text from the provided HTML string. |
| * @param htmlText HTML string |
| * @return Plain text string representation of the specified Html string |
| */ |
| public static String convertHtmlToPlainText(String htmlText) { |
| if (TextUtils.isEmpty(htmlText)) { |
| return ""; |
| } |
| return getHtmlTree(htmlText, new HtmlParser(), new HtmlTreeBuilder()).getPlainText(); |
| } |
| |
| public static String convertHtmlToPlainText(String htmlText, HtmlParser parser, |
| HtmlTreeBuilder builder) { |
| if (TextUtils.isEmpty(htmlText)) { |
| return ""; |
| } |
| return getHtmlTree(htmlText, parser, builder).getPlainText(); |
| } |
| |
| /** |
| * Returns a {@link HtmlTree} representation of the specified HTML string. |
| */ |
| public static HtmlTree getHtmlTree(String htmlText) { |
| return getHtmlTree(htmlText, new HtmlParser(), new HtmlTreeBuilder()); |
| } |
| |
| /** |
| * Returns a {@link HtmlTree} representation of the specified HTML string. |
| */ |
| private static HtmlTree getHtmlTree(String htmlText, HtmlParser parser, |
| HtmlTreeBuilder builder) { |
| final HtmlDocument doc = parser.parse(htmlText); |
| doc.accept(builder); |
| |
| return builder.getTree(); |
| } |
| |
| /** |
| * Perform a simulated measure pass on the given child view, assuming the |
| * child has a ViewGroup parent and that it should be laid out within that |
| * parent with a matching width but variable height. Code largely lifted |
| * from AnimatedAdapter.measureChildHeight(). |
| * |
| * @param child a child view that has already been placed within its parent |
| * ViewGroup |
| * @param parent the parent ViewGroup of child |
| * @return measured height of the child in px |
| */ |
| public static int measureViewHeight(View child, ViewGroup parent) { |
| final ViewGroup.LayoutParams lp = child.getLayoutParams(); |
| final int childSideMargin; |
| if (lp instanceof MarginLayoutParams) { |
| final MarginLayoutParams mlp = (MarginLayoutParams) lp; |
| childSideMargin = mlp.leftMargin + mlp.rightMargin; |
| } else { |
| childSideMargin = 0; |
| } |
| |
| final int parentWSpec = MeasureSpec.makeMeasureSpec(parent.getWidth(), MeasureSpec.EXACTLY); |
| final int wSpec = ViewGroup.getChildMeasureSpec(parentWSpec, |
| parent.getPaddingLeft() + parent.getPaddingRight() + childSideMargin, |
| ViewGroup.LayoutParams.MATCH_PARENT); |
| final int hSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); |
| child.measure(wSpec, hSpec); |
| return child.getMeasuredHeight(); |
| } |
| |
| /** |
| * Encode the string in HTML. |
| * |
| * @param removeEmptyDoubleQuotes If true, also remove any occurrence of "" |
| * found in the string |
| */ |
| public static Object cleanUpString(String string, boolean removeEmptyDoubleQuotes) { |
| return !TextUtils.isEmpty(string) ? TextUtils.htmlEncode(removeEmptyDoubleQuotes ? string |
| .replace("\"\"", "") : string) : ""; |
| } |
| |
| /** |
| * Get the correct display string for the unread count of a folder. |
| */ |
| public static String getUnreadCountString(Context context, int unreadCount) { |
| final String unreadCountString; |
| final Resources resources = context.getResources(); |
| if (sMaxUnreadCount == -1) { |
| sMaxUnreadCount = resources.getInteger(R.integer.maxUnreadCount); |
| } |
| if (unreadCount > sMaxUnreadCount) { |
| if (sUnreadText == null) { |
| sUnreadText = resources.getString(R.string.widget_large_unread_count); |
| } |
| // Localize "99+" according to the device language |
| unreadCountString = String.format(sUnreadText, sMaxUnreadCount); |
| } else if (unreadCount <= 0) { |
| unreadCountString = ""; |
| } else { |
| // Localize unread count according to the device language |
| unreadCountString = String.format("%d", unreadCount); |
| } |
| return unreadCountString; |
| } |
| |
| /** |
| * Get the correct display string for the unseen count of a folder. |
| */ |
| public static String getUnseenCountString(Context context, int unseenCount) { |
| final String unseenCountString; |
| final Resources resources = context.getResources(); |
| if (sMaxUnreadCount == -1) { |
| sMaxUnreadCount = resources.getInteger(R.integer.maxUnreadCount); |
| } |
| if (unseenCount > sMaxUnreadCount) { |
| if (sLargeUnseenText == null) { |
| sLargeUnseenText = resources.getString(R.string.large_unseen_count); |
| } |
| // Localize "99+" according to the device language |
| unseenCountString = String.format(sLargeUnseenText, sMaxUnreadCount); |
| } else if (unseenCount <= 0) { |
| unseenCountString = ""; |
| } else { |
| if (sUnseenText == null) { |
| sUnseenText = resources.getString(R.string.unseen_count); |
| } |
| // Localize unseen count according to the device language |
| unseenCountString = String.format(sUnseenText, unseenCount); |
| } |
| return unseenCountString; |
| } |
| |
| /** |
| * Get the correct display string for the unread count in the actionbar. |
| */ |
| public static CharSequence getUnreadMessageString(Context context, int unreadCount) { |
| final SpannableString message; |
| final Resources resources = context.getResources(); |
| if (sMaxUnreadCount == -1) { |
| sMaxUnreadCount = resources.getInteger(R.integer.maxUnreadCount); |
| } |
| if (unreadCount > sMaxUnreadCount) { |
| message = new SpannableString( |
| resources.getString(R.string.actionbar_large_unread_count, sMaxUnreadCount)); |
| } else { |
| message = new SpannableString(resources.getQuantityString( |
| R.plurals.actionbar_unread_messages, unreadCount, unreadCount)); |
| } |
| |
| message.setSpan(CharacterStyle.wrap(ACTION_BAR_UNREAD_STYLE), 0, |
| message.toString().length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| |
| return message; |
| } |
| |
| /** |
| * Get text matching the last sync status. |
| */ |
| public static CharSequence getSyncStatusText(Context context, int packedStatus) { |
| final String[] errors = context.getResources().getStringArray(R.array.sync_status); |
| final int status = packedStatus & 0x0f; |
| if (status >= errors.length) { |
| return ""; |
| } |
| return errors[status]; |
| } |
| |
| /** |
| * Create an intent to show a conversation. |
| * @param conversation Conversation to open. |
| * @param folderUri |
| * @param account |
| * @return |
| */ |
| public static Intent createViewConversationIntent(final Context context, |
| Conversation conversation, final Uri folderUri, Account account) { |
| final Intent intent = new Intent(Intent.ACTION_VIEW); |
| intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
| | Intent.FLAG_ACTIVITY_TASK_ON_HOME); |
| final Uri versionedUri = appendVersionQueryParameter(context, conversation.uri); |
| // We need the URI to be unique, even if it's for the same message, so append the folder URI |
| final Uri uniqueUri = versionedUri.buildUpon().appendQueryParameter( |
| FOLDER_URI_QUERY_PARAMETER, folderUri.toString()).build(); |
| intent.setDataAndType(uniqueUri, account.mimeType); |
| intent.putExtra(Utils.EXTRA_ACCOUNT, account.serialize()); |
| intent.putExtra(Utils.EXTRA_FOLDER_URI, folderUri); |
| intent.putExtra(Utils.EXTRA_CONVERSATION, conversation); |
| return intent; |
| } |
| |
| /** |
| * Create an intent to open a folder. |
| * |
| * @param folderUri Folder to open. |
| * @param account |
| * @return |
| */ |
| public static Intent createViewFolderIntent(final Context context, final Uri folderUri, |
| Account account) { |
| if (folderUri == null || account == null) { |
| LogUtils.wtf(LOG_TAG, "Utils.createViewFolderIntent(%s,%s): Bad input", folderUri, |
| account); |
| return null; |
| } |
| final Intent intent = new Intent(Intent.ACTION_VIEW); |
| intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
| | Intent.FLAG_ACTIVITY_TASK_ON_HOME); |
| intent.setDataAndType(appendVersionQueryParameter(context, folderUri), account.mimeType); |
| intent.putExtra(Utils.EXTRA_ACCOUNT, account.serialize()); |
| intent.putExtra(Utils.EXTRA_FOLDER_URI, folderUri); |
| return intent; |
| } |
| |
| /** |
| * Creates an intent to open the default inbox for the given account. |
| * |
| * @param account |
| * @return |
| */ |
| public static Intent createViewInboxIntent(Account account) { |
| if (account == null) { |
| LogUtils.wtf(LOG_TAG, "Utils.createViewInboxIntent(%s): Bad input", account); |
| return null; |
| } |
| final Intent intent = new Intent(Intent.ACTION_VIEW); |
| intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
| | Intent.FLAG_ACTIVITY_TASK_ON_HOME); |
| intent.setDataAndType(account.settings.defaultInbox, account.mimeType); |
| intent.putExtra(Utils.EXTRA_ACCOUNT, account.serialize()); |
| return intent; |
| } |
| |
| /** |
| * Helper method to show context-aware help. |
| * |
| * @param context Context to be used to open the help. |
| * @param account Account from which the help URI is extracted |
| * @param helpTopic Information about the activity the user was in |
| * when they requested help which specifies the help topic to display |
| */ |
| public static void showHelp(Context context, Account account, String helpTopic) { |
| final String urlString = account.helpIntentUri != null ? |
| account.helpIntentUri.toString() : null; |
| if (TextUtils.isEmpty(urlString)) { |
| LogUtils.e(LOG_TAG, "unable to show help for account: %s", account); |
| return; |
| } |
| showHelp(context, account.helpIntentUri, helpTopic); |
| } |
| |
| /** |
| * Helper method to show context-aware help. |
| * |
| * @param context Context to be used to open the help. |
| * @param helpIntentUri URI of the help content to display |
| * @param helpTopic Information about the activity the user was in |
| * when they requested help which specifies the help topic to display |
| */ |
| public static void showHelp(Context context, Uri helpIntentUri, String helpTopic) { |
| final String urlString = helpIntentUri == null ? null : helpIntentUri.toString(); |
| if (TextUtils.isEmpty(urlString)) { |
| LogUtils.e(LOG_TAG, "unable to show help for help URI: %s", helpIntentUri); |
| return; |
| } |
| |
| // generate the full URL to the requested help section |
| final Uri helpUrl = HelpUrl.getHelpUrl(context, helpIntentUri, helpTopic); |
| |
| final boolean useBrowser = context.getResources().getBoolean(R.bool.openHelpWithBrowser); |
| if (useBrowser) { |
| // open a browser with the full help URL |
| openUrl(context, helpUrl, null); |
| } else { |
| // start the help activity with the full help URL |
| final Intent intent = new Intent(context, HelpActivity.class); |
| intent.putExtra(HelpActivity.PARAM_HELP_URL, helpUrl); |
| context.startActivity(intent); |
| } |
| } |
| |
| /** |
| * Helper method to open a link in a browser. |
| * |
| * @param context Context |
| * @param uri Uri to open. |
| */ |
| private static void openUrl(Context context, Uri uri, Bundle optionalExtras) { |
| if(uri == null || TextUtils.isEmpty(uri.toString())) { |
| LogUtils.wtf(LOG_TAG, "invalid url in Utils.openUrl(): %s", uri); |
| return; |
| } |
| final Intent intent = new Intent(Intent.ACTION_VIEW, uri); |
| // Fill in any of extras that have been requested. |
| if (optionalExtras != null) { |
| intent.putExtras(optionalExtras); |
| } |
| intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); |
| intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); |
| |
| context.startActivity(intent); |
| } |
| |
| /** |
| * Show the top level settings screen for the supplied account. |
| */ |
| public static void showSettings(Context context, Account account) { |
| if (account == null) { |
| LogUtils.e(LOG_TAG, "Invalid attempt to show setting screen with null account"); |
| return; |
| } |
| final Intent settingsIntent = new Intent(Intent.ACTION_EDIT, account.settingsIntentUri); |
| |
| settingsIntent.setPackage(context.getPackageName()); |
| settingsIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); |
| |
| context.startActivity(settingsIntent); |
| } |
| |
| /** |
| * Show the account level settings screen for the supplied account. |
| */ |
| public static void showAccountSettings(Context context, Account account) { |
| if (account == null) { |
| LogUtils.e(LOG_TAG, "Invalid attempt to show setting screen with null account"); |
| return; |
| } |
| final Intent settingsIntent = new Intent(Intent.ACTION_EDIT, |
| appendVersionQueryParameter(context, account.settingsIntentUri)); |
| |
| settingsIntent.setPackage(context.getPackageName()); |
| settingsIntent.putExtra(EditSettingsExtras.EXTRA_ACCOUNT, account); |
| settingsIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); |
| |
| context.startActivity(settingsIntent); |
| } |
| |
| /** |
| * Show the settings screen for the supplied account. |
| */ |
| public static void showFolderSettings(Context context, Account account, Folder folder) { |
| if (account == null || folder == null) { |
| LogUtils.e(LOG_TAG, "Invalid attempt to show folder settings. account: %s folder: %s", |
| account, folder); |
| return; |
| } |
| final Intent settingsIntent = new Intent(Intent.ACTION_EDIT, |
| appendVersionQueryParameter(context, account.settingsIntentUri)); |
| |
| settingsIntent.setPackage(context.getPackageName()); |
| settingsIntent.putExtra(EditSettingsExtras.EXTRA_ACCOUNT, account); |
| settingsIntent.putExtra(EditSettingsExtras.EXTRA_FOLDER, folder); |
| settingsIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); |
| |
| context.startActivity(settingsIntent); |
| } |
| |
| /** |
| * Show the settings screen for managing all folders. |
| */ |
| public static void showManageFolder(Context context, Account account) { |
| if (account == null) { |
| LogUtils.e(LOG_TAG, "Invalid attempt to the manage folders screen with null account"); |
| return; |
| } |
| final Intent settingsIntent = new Intent(Intent.ACTION_EDIT, account.settingsIntentUri); |
| |
| settingsIntent.setPackage(context.getPackageName()); |
| settingsIntent.putExtra(EditSettingsExtras.EXTRA_ACCOUNT, account); |
| settingsIntent.putExtra(EditSettingsExtras.EXTRA_MANAGE_FOLDERS, true); |
| settingsIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); |
| |
| context.startActivity(settingsIntent); |
| } |
| |
| /** |
| * Show the feedback screen for the supplied account. |
| */ |
| public static void sendFeedback(Activity activity, Account account, boolean reportingProblem) { |
| if (activity != null && account != null) { |
| sendFeedback(activity, account.sendFeedbackIntentUri, reportingProblem); |
| } |
| } |
| |
| public static void sendFeedback(Activity activity, Uri feedbackIntentUri, |
| boolean reportingProblem) { |
| if (activity != null && !isEmpty(feedbackIntentUri)) { |
| final Bundle optionalExtras = new Bundle(2); |
| optionalExtras.putBoolean( |
| UIProvider.SendFeedbackExtras.EXTRA_REPORTING_PROBLEM, reportingProblem); |
| final Bitmap screenBitmap = getReducedSizeBitmap(activity); |
| if (screenBitmap != null) { |
| optionalExtras.putParcelable( |
| UIProvider.SendFeedbackExtras.EXTRA_SCREEN_SHOT, screenBitmap); |
| } |
| openUrl(activity, feedbackIntentUri, optionalExtras); |
| } |
| } |
| |
| private static Bitmap getReducedSizeBitmap(Activity activity) { |
| final Window activityWindow = activity.getWindow(); |
| final View currentView = activityWindow != null ? activityWindow.getDecorView() : null; |
| final View rootView = currentView != null ? currentView.getRootView() : null; |
| if (rootView != null) { |
| rootView.setDrawingCacheEnabled(true); |
| final Bitmap drawingCache = rootView.getDrawingCache(); |
| // Null check to avoid NPE discovered from monkey crash: |
| if (drawingCache != null) { |
| try { |
| final Bitmap originalBitmap = drawingCache.copy(Bitmap.Config.RGB_565, false); |
| double originalHeight = originalBitmap.getHeight(); |
| double originalWidth = originalBitmap.getWidth(); |
| int newHeight = SCALED_SCREENSHOT_MAX_HEIGHT_WIDTH; |
| int newWidth = SCALED_SCREENSHOT_MAX_HEIGHT_WIDTH; |
| double scaleX, scaleY; |
| scaleX = newWidth / originalWidth; |
| scaleY = newHeight / originalHeight; |
| final double scale = Math.min(scaleX, scaleY); |
| newWidth = (int)Math.round(originalWidth * scale); |
| newHeight = (int)Math.round(originalHeight * scale); |
| return Bitmap.createScaledBitmap(originalBitmap, newWidth, newHeight, true); |
| } catch (OutOfMemoryError e) { |
| LogUtils.e(LOG_TAG, e, "OOME when attempting to scale screenshot"); |
| } |
| } |
| } |
| return null; |
| } |
| |
| /** |
| * Retrieves the mailbox search query associated with an intent (or null if not available), |
| * doing proper sanitizing (e.g. trims whitespace). |
| */ |
| public static String mailSearchQueryForIntent(Intent intent) { |
| String query = intent.getStringExtra(SearchManager.QUERY); |
| return TextUtils.isEmpty(query) ? null : query.trim(); |
| } |
| |
| /** |
| * Split out a filename's extension and return it. |
| * @param filename a file name |
| * @return the file extension (max of 5 chars including period, like ".docx"), or null |
| */ |
| public static String getFileExtension(String filename) { |
| String extension = null; |
| int index = !TextUtils.isEmpty(filename) ? filename.lastIndexOf('.') : -1; |
| // Limit the suffix to dot + four characters |
| if (index >= 0 && filename.length() - index <= FILE_EXTENSION_MAX_CHARS + 1) { |
| extension = filename.substring(index); |
| } |
| return extension; |
| } |
| |
| /** |
| * (copied from {@link Intent#normalizeMimeType(String)} for pre-J) |
| * |
| * Normalize a MIME data type. |
| * |
| * <p>A normalized MIME type has white-space trimmed, |
| * content-type parameters removed, and is lower-case. |
| * This aligns the type with Android best practices for |
| * intent filtering. |
| * |
| * <p>For example, "text/plain; charset=utf-8" becomes "text/plain". |
| * "text/x-vCard" becomes "text/x-vcard". |
| * |
| * <p>All MIME types received from outside Android (such as user input, |
| * or external sources like Bluetooth, NFC, or the Internet) should |
| * be normalized before they are used to create an Intent. |
| * |
| * @param type MIME data type to normalize |
| * @return normalized MIME data type, or null if the input was null |
| * @see {@link android.content.Intent#setType} |
| * @see {@link android.content.Intent#setTypeAndNormalize} |
| */ |
| public static String normalizeMimeType(String type) { |
| if (type == null) { |
| return null; |
| } |
| |
| type = type.trim().toLowerCase(Locale.US); |
| |
| final int semicolonIndex = type.indexOf(';'); |
| if (semicolonIndex != -1) { |
| type = type.substring(0, semicolonIndex); |
| } |
| return type; |
| } |
| |
| /** |
| * (copied from {@link android.net.Uri#normalizeScheme()} for pre-J) |
| * |
| * Return a normalized representation of this Uri. |
| * |
| * <p>A normalized Uri has a lowercase scheme component. |
| * This aligns the Uri with Android best practices for |
| * intent filtering. |
| * |
| * <p>For example, "HTTP://www.android.com" becomes |
| * "http://www.android.com" |
| * |
| * <p>All URIs received from outside Android (such as user input, |
| * or external sources like Bluetooth, NFC, or the Internet) should |
| * be normalized before they are used to create an Intent. |
| * |
| * <p class="note">This method does <em>not</em> validate bad URI's, |
| * or 'fix' poorly formatted URI's - so do not use it for input validation. |
| * A Uri will always be returned, even if the Uri is badly formatted to |
| * begin with and a scheme component cannot be found. |
| * |
| * @return normalized Uri (never null) |
| * @see {@link android.content.Intent#setData} |
| */ |
| public static Uri normalizeUri(Uri uri) { |
| String scheme = uri.getScheme(); |
| if (scheme == null) return uri; // give up |
| String lowerScheme = scheme.toLowerCase(Locale.US); |
| if (scheme.equals(lowerScheme)) return uri; // no change |
| |
| return uri.buildUpon().scheme(lowerScheme).build(); |
| } |
| |
| public static Intent setIntentTypeAndNormalize(Intent intent, String type) { |
| return intent.setType(normalizeMimeType(type)); |
| } |
| |
| public static Intent setIntentDataAndTypeAndNormalize(Intent intent, Uri data, String type) { |
| return intent.setDataAndType(normalizeUri(data), normalizeMimeType(type)); |
| } |
| |
| public static int getTransparentColor(int color) { |
| return 0x00ffffff & color; |
| } |
| |
| public static void setMenuItemVisibility(Menu menu, int itemId, boolean shouldShow) { |
| final MenuItem item = menu.findItem(itemId); |
| if (item == null) { |
| return; |
| } |
| item.setVisible(shouldShow); |
| } |
| |
| /** |
| * Parse a string (possibly null or empty) into a URI. If the string is null |
| * or empty, null is returned back. Otherwise an empty URI is returned. |
| * |
| * @param uri |
| * @return a valid URI, possibly {@link android.net.Uri#EMPTY} |
| */ |
| public static Uri getValidUri(String uri) { |
| if (TextUtils.isEmpty(uri) || uri == JSONObject.NULL) |
| return Uri.EMPTY; |
| return Uri.parse(uri); |
| } |
| |
| public static boolean isEmpty(Uri uri) { |
| return uri == null || Uri.EMPTY.equals(uri); |
| } |
| |
| public static String dumpFragment(Fragment f) { |
| final StringWriter sw = new StringWriter(); |
| f.dump("", new FileDescriptor(), new PrintWriter(sw), new String[0]); |
| return sw.toString(); |
| } |
| |
| public static void dumpViewTree(ViewGroup root) { |
| dumpViewTree(root, ""); |
| } |
| |
| private static void dumpViewTree(ViewGroup g, String prefix) { |
| LogUtils.i(LOG_TAG, "%sVIEWGROUP: %s childCount=%s", prefix, g, g.getChildCount()); |
| final String childPrefix = prefix + " "; |
| for (int i = 0; i < g.getChildCount(); i++) { |
| final View child = g.getChildAt(i); |
| if (child instanceof ViewGroup) { |
| dumpViewTree((ViewGroup) child, childPrefix); |
| } else { |
| LogUtils.i(LOG_TAG, "%sCHILD #%s: %s", childPrefix, i, child); |
| } |
| } |
| } |
| |
| /** |
| * Executes an out-of-band command on the cursor. |
| * @param cursor |
| * @param request Bundle with all keys and values set for the command. |
| * @param key The string value against which we will check for success or failure |
| * @return true if the operation was a success. |
| */ |
| private static boolean executeConversationCursorCommand( |
| Cursor cursor, Bundle request, String key) { |
| final Bundle response = cursor.respond(request); |
| final String result = response.getString(key, |
| UIProvider.ConversationCursorCommand.COMMAND_RESPONSE_FAILED); |
| |
| return UIProvider.ConversationCursorCommand.COMMAND_RESPONSE_OK.equals(result); |
| } |
| |
| /** |
| * Commands a cursor representing a set of conversations to indicate that an item is being shown |
| * in the UI. |
| * |
| * @param cursor a conversation cursor |
| * @param position position of the item being shown. |
| */ |
| public static boolean notifyCursorUIPositionChange(Cursor cursor, int position) { |
| final Bundle request = new Bundle(); |
| final String key = |
| UIProvider.ConversationCursorCommand.COMMAND_NOTIFY_CURSOR_UI_POSITION_CHANGE; |
| request.putInt(key, position); |
| return executeConversationCursorCommand(cursor, request, key); |
| } |
| |
| /** |
| * Commands a cursor representing a set of conversations to set its visibility state. |
| * |
| * @param cursor a conversation cursor |
| * @param visible true if the conversation list is visible, false otherwise. |
| * @param isFirstSeen true if you want to notify the cursor that this conversation list was seen |
| * for the first time: the user launched the app into it, or the user switched from some |
| * other folder into it. |
| */ |
| public static void setConversationCursorVisibility( |
| Cursor cursor, boolean visible, boolean isFirstSeen) { |
| new MarkConversationCursorVisibleTask(cursor, visible, isFirstSeen).execute(); |
| } |
| |
| /** |
| * Async task for marking conversations "seen" and informing the cursor that the folder was |
| * seen for the first time by the UI. |
| */ |
| private static class MarkConversationCursorVisibleTask extends AsyncTask<Void, Void, Void> { |
| private final Cursor mCursor; |
| private final boolean mVisible; |
| private final boolean mIsFirstSeen; |
| |
| /** |
| * Create a new task with the given cursor, with the given visibility and |
| * |
| * @param cursor |
| * @param isVisible true if the conversation list is visible, false otherwise. |
| * @param isFirstSeen true if the folder was shown for the first time: either the user has |
| * just switched to it, or the user started the app in this folder. |
| */ |
| public MarkConversationCursorVisibleTask( |
| Cursor cursor, boolean isVisible, boolean isFirstSeen) { |
| mCursor = cursor; |
| mVisible = isVisible; |
| mIsFirstSeen = isFirstSeen; |
| } |
| |
| @Override |
| protected Void doInBackground(Void... params) { |
| if (mCursor == null) { |
| return null; |
| } |
| final Bundle request = new Bundle(); |
| if (mIsFirstSeen) { |
| request.putBoolean( |
| UIProvider.ConversationCursorCommand.COMMAND_KEY_ENTERED_FOLDER, true); |
| } |
| final String key = UIProvider.ConversationCursorCommand.COMMAND_KEY_SET_VISIBILITY; |
| request.putBoolean(key, mVisible); |
| executeConversationCursorCommand(mCursor, request, key); |
| return null; |
| } |
| } |
| |
| |
| /** |
| * This utility method returns the conversation ID at the current cursor position. |
| * @return the conversation id at the cursor. |
| */ |
| public static long getConversationId(ConversationCursor cursor) { |
| return cursor.getLong(UIProvider.CONVERSATION_ID_COLUMN); |
| } |
| |
| /** |
| * This utility method returns the conversation Uri at the current cursor position. |
| * @return the conversation id at the cursor. |
| */ |
| public static String getConversationUri(ConversationCursor cursor) { |
| return cursor.getString(UIProvider.CONVERSATION_URI_COLUMN); |
| } |
| |
| /** |
| * @return whether to show two pane or single pane search results. |
| */ |
| public static boolean showTwoPaneSearchResults(Context context) { |
| return context.getResources().getBoolean(R.bool.show_two_pane_search_results); |
| } |
| |
| /** |
| * Sets the layer type of a view to hardware if the view is attached and hardware acceleration |
| * is enabled. Does nothing otherwise. |
| */ |
| public static void enableHardwareLayer(View v) { |
| if (v != null && v.isHardwareAccelerated()) { |
| v.setLayerType(View.LAYER_TYPE_HARDWARE, null); |
| v.buildLayer(); |
| } |
| } |
| |
| /** |
| * Return whether menus should show the disabled archive menu item or just |
| * remove it when archive is not available. |
| */ |
| public static boolean shouldShowDisabledArchiveIcon(Context context) { |
| return context.getResources().getBoolean(R.bool.show_disabled_archive_menu_item); |
| } |
| |
| public static int getDefaultFolderBackgroundColor(Context context) { |
| if (sDefaultFolderBackgroundColor == -1) { |
| sDefaultFolderBackgroundColor = context.getResources().getColor( |
| R.color.default_folder_background_color); |
| } |
| return sDefaultFolderBackgroundColor; |
| } |
| |
| /** |
| * Returns the count that should be shown for the specified folder. This method should be used |
| * when the UI wants to display an "unread" count. For most labels, the returned value will be |
| * the unread count, but for some folder types (outbox, drafts, trash) this will return the |
| * total count. |
| */ |
| public static int getFolderUnreadDisplayCount(final Folder folder) { |
| if (folder != null) { |
| if (folder.isUnreadCountHidden()) { |
| return folder.totalCount; |
| } else { |
| return folder.unreadCount; |
| } |
| } |
| return 0; |
| } |
| |
| /** |
| * @return an intent which, if launched, will reply to the conversation |
| */ |
| public static Intent createReplyIntent(final Context context, final Account account, |
| final Uri messageUri, final boolean isReplyAll) { |
| final Intent intent = |
| ComposeActivity.createReplyIntent(context, account, messageUri, isReplyAll); |
| return intent; |
| } |
| |
| /** |
| * @return an intent which, if launched, will forward the conversation |
| */ |
| public static Intent createForwardIntent( |
| final Context context, final Account account, final Uri messageUri) { |
| final Intent intent = ComposeActivity.createForwardIntent(context, account, messageUri); |
| return intent; |
| } |
| |
| public static Uri appendVersionQueryParameter(final Context context, final Uri uri) { |
| return uri.buildUpon().appendQueryParameter(APP_VERSION_QUERY_PARAMETER, |
| getVersionCode(context)).build(); |
| } |
| |
| /** |
| * Convenience method for diverting mailto: uris directly to our compose activity. Using this |
| * method ensures that the Account object is not accidentally sent to a different process. |
| * |
| * @param context for sending the intent |
| * @param uri mailto: or other uri |
| * @param account desired account for potential compose activity |
| * @return true if a compose activity was started, false if uri should be sent to a view intent |
| */ |
| public static boolean divertMailtoUri(final Context context, final Uri uri, |
| final Account account) { |
| final String scheme = normalizeUri(uri).getScheme(); |
| if (TextUtils.equals(MAILTO_SCHEME, scheme)) { |
| ComposeActivity.composeMailto(context, account, uri); |
| return true; |
| } |
| return false; |
| } |
| |
| /** |
| * Gets the specified {@link Folder} object. |
| * |
| * @param folderUri The {@link Uri} for the folder |
| * @param allowHidden <code>true</code> to allow a hidden folder to be returned, |
| * <code>false</code> to return <code>null</code> instead |
| * @return the specified {@link Folder} object, or <code>null</code> |
| */ |
| public static Folder getFolder(final Context context, final Uri folderUri, |
| final boolean allowHidden) { |
| final Uri uri = folderUri |
| .buildUpon() |
| .appendQueryParameter(UIProvider.ALLOW_HIDDEN_FOLDERS_QUERY_PARAM, |
| Boolean.toString(allowHidden)) |
| .build(); |
| |
| final Cursor cursor = context.getContentResolver().query(uri, |
| UIProvider.FOLDERS_PROJECTION, null, null, null); |
| |
| if (cursor == null) { |
| return null; |
| } |
| |
| try { |
| if (cursor.moveToFirst()) { |
| return new Folder(cursor); |
| } else { |
| return null; |
| } |
| } finally { |
| cursor.close(); |
| } |
| } |
| |
| /** |
| * Begins systrace tracing for a given tag. No-op on unsupported platform versions. |
| * |
| * @param tag systrace tag to use |
| * |
| * @see android.os.Trace#beginSection(String) |
| */ |
| public static void traceBeginSection(String tag) { |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| android.os.Trace.beginSection(tag); |
| } |
| } |
| |
| /** |
| * Ends systrace tracing for the most recently begun section. No-op on unsupported platform |
| * versions. |
| * |
| * @see android.os.Trace#endSection() |
| */ |
| public static void traceEndSection() { |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| android.os.Trace.endSection(); |
| } |
| } |
| |
| /** |
| * Given a value and a set of upper-bounds to use as buckets, return the smallest upper-bound |
| * that is greater than the value.<br> |
| * <br> |
| * Useful for turning a continuous value into one of a set of discrete ones. |
| * |
| * @param value a value to bucketize |
| * @param upperBounds list of upper-bound buckets to clamp to, sorted from smallest-greatest |
| * @return the smallest upper-bound larger than the value, or -1 if the value is larger than |
| * all upper-bounds |
| */ |
| public static long getUpperBound(long value, long[] upperBounds) { |
| for (long ub : upperBounds) { |
| if (value < ub) { |
| return ub; |
| } |
| } |
| return -1; |
| } |
| |
| /** |
| * Get the background color of Gmail's action bar. |
| */ |
| public static int getActionBarBackgroundResource(final Context context) { |
| final TypedValue actionBarStyle = new TypedValue(); |
| if (context.getTheme().resolveAttribute(android.R.attr.actionBarStyle, actionBarStyle, true) |
| && actionBarStyle.type == TypedValue.TYPE_REFERENCE) { |
| final TypedValue backgroundValue = new TypedValue(); |
| final TypedArray attr = context.obtainStyledAttributes(actionBarStyle.resourceId, |
| STYLE_ATTR); |
| attr.getValue(0, backgroundValue); |
| attr.recycle(); |
| return backgroundValue.resourceId; |
| } else { |
| // Default color |
| return context.getResources().getColor(R.color.list_background_color); |
| } |
| } |
| |
| public static Address getAddress(Map<String, Address> cache, String emailStr) { |
| Address addr; |
| synchronized (cache) { |
| addr = cache.get(emailStr); |
| if (addr == null) { |
| addr = Address.getEmailAddress(emailStr); |
| if (addr != null) { |
| cache.put(emailStr, addr); |
| } |
| } |
| } |
| return addr; |
| } |
| |
| /** |
| * Applies the given appearance on the given subString, and inserts that as a parameter in the |
| * given parentString. |
| */ |
| public static Spanned insertStringWithStyle(Context context, |
| String entireString, String subString, int appearance) { |
| final Resources resources = context.getResources(); |
| final int index = entireString.indexOf(subString); |
| final SpannableString descriptionText = new SpannableString(entireString); |
| descriptionText.setSpan( |
| new TextAppearanceSpan(context, appearance), |
| index, |
| index + subString.length(), |
| 0); |
| return descriptionText; |
| } |
| |
| /** |
| * Email addresses are supposed to be treated as case-insensitive for the host-part and |
| * case-sensitive for the local-part, but nobody really wants email addresses to match |
| * case-sensitive on the local-part, so just smash everything to lower case. |
| * @param email Hello@Example.COM |
| * @return hello@example.com |
| */ |
| public static String normalizeEmailAddress(String email) { |
| /* |
| // The RFC5321 version |
| if (TextUtils.isEmpty(email)) { |
| return email; |
| } |
| String[] parts = email.split("@"); |
| if (parts.length != 2) { |
| LogUtils.d(LOG_TAG, "Tried to normalize a malformed email address: ", email); |
| return email; |
| } |
| |
| return parts[0] + "@" + parts[1].toLowerCase(Locale.US); |
| */ |
| if (TextUtils.isEmpty(email)) { |
| return email; |
| } else { |
| // Doing this for other locales might really screw things up, so do US-version only |
| return email.toLowerCase(Locale.US); |
| } |
| } |
| |
| /** |
| * Returns whether the device currently has network connection. This does not guarantee that |
| * the connection is reliable. |
| */ |
| public static boolean isConnected(final Context context) { |
| final ConnectivityManager connectivityManager = |
| ((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); |
| final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); |
| return (networkInfo != null) && networkInfo.isConnected(); |
| } |
| } |