// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.contextmenu; import android.content.Context; import android.net.MailTo; import android.support.annotation.IntDef; import android.text.TextUtils; import android.view.ContextMenu; import android.view.MenuInflater; import android.view.MenuItem; import android.webkit.MimeTypeMap; import org.chromium.base.Log; import org.chromium.base.metrics.RecordHistogram; import org.chromium.chrome.R; import org.chromium.chrome.browser.net.spdyproxy.DataReductionProxySettings; import org.chromium.chrome.browser.offlinepages.OfflinePageBridge; import org.chromium.chrome.browser.preferences.datareduction.DataReductionProxyUma; import org.chromium.chrome.browser.search_engines.TemplateUrlService; import org.chromium.chrome.browser.util.UrlUtilities; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.net.URI; import java.net.URISyntaxException; import java.util.Arrays; /** * A {@link ContextMenuPopulator} used for showing the default Chrome context menu. */ public class ChromeContextMenuPopulator implements ContextMenuPopulator { private static final String TAG = "CCMenuPopulator"; /** * Defines the context menu modes */ @Retention(RetentionPolicy.SOURCE) @IntDef({ NORMAL_MODE, /* Default mode */ CUSTOM_TAB_MODE, /* Custom tab mode */ FULLSCREEN_TAB_MODE /* Full screen mode */ }) public @interface ContextMenuMode {} public static final int NORMAL_MODE = 0; public static final int CUSTOM_TAB_MODE = 1; public static final int FULLSCREEN_TAB_MODE = 2; // Items that are included in all context menus. private static final int[] BASE_WHITELIST = { R.id.contextmenu_copy_link_address, R.id.contextmenu_copy_email_address, R.id.contextmenu_copy_link_text, R.id.contextmenu_save_link_as, R.id.contextmenu_save_image, R.id.contextmenu_share_image, R.id.contextmenu_save_video, }; // Items that are included for normal Chrome browser mode. private static final int[] NORMAL_MODE_WHITELIST = { R.id.contextmenu_load_images, R.id.contextmenu_open_in_new_tab, R.id.contextmenu_open_in_other_window, R.id.contextmenu_open_in_incognito_tab, R.id.contextmenu_save_link_as, R.id.contextmenu_load_original_image, R.id.contextmenu_open_image_in_new_tab, R.id.contextmenu_search_by_image, R.id.contextmenu_save_offline, }; // Additional items for custom tabs mode. private static final int[] CUSTOM_TAB_MODE_WHITELIST = { R.id.contextmenu_open_image, R.id.contextmenu_search_by_image }; // Additional items for fullscreen tabs mode. private static final int[] FULLSCREEN_TAB_MODE_WHITELIST = { R.id.menu_id_open_in_chrome }; private final ContextMenuItemDelegate mDelegate; private MenuInflater mMenuInflater; private static final String BLANK_URL = "about:blank"; private final int mMode; static class ContextMenuUma { // Note: these values must match the ContextMenuOption enum in histograms.xml. static final int ACTION_OPEN_IN_NEW_TAB = 0; static final int ACTION_OPEN_IN_INCOGNITO_TAB = 1; static final int ACTION_COPY_LINK_ADDRESS = 2; static final int ACTION_COPY_EMAIL_ADDRESS = 3; static final int ACTION_COPY_LINK_TEXT = 4; static final int ACTION_SAVE_LINK = 5; static final int ACTION_SAVE_IMAGE = 6; static final int ACTION_OPEN_IMAGE = 7; static final int ACTION_OPEN_IMAGE_IN_NEW_TAB = 8; static final int ACTION_SEARCH_BY_IMAGE = 11; static final int ACTION_LOAD_IMAGES = 12; static final int ACTION_LOAD_ORIGINAL_IMAGE = 13; static final int ACTION_SAVE_VIDEO = 14; static final int ACTION_SHARE_IMAGE = 19; static final int ACTION_OPEN_IN_OTHER_WINDOW = 20; static final int NUM_ACTIONS = 21; // Note: these values must match the ContextMenuSaveLinkType enum in histograms.xml. // Only add new values at the end, right before NUM_TYPES. We depend on these specific // values in UMA histograms. static final int TYPE_UNKNOWN = 0; static final int TYPE_TEXT = 1; static final int TYPE_IMAGE = 2; static final int TYPE_AUDIO = 3; static final int TYPE_VIDEO = 4; static final int TYPE_PDF = 5; static final int NUM_TYPES = 6; /** * Records a histogram entry when the user selects an item from a context menu. * @param params The ContextMenuParams describing the current context menu. * @param action The action that the user selected (e.g. ACTION_SAVE_IMAGE). */ static void record(ContextMenuParams params, int action) { assert action >= 0; assert action < NUM_ACTIONS; String histogramName; if (params.isVideo()) { histogramName = "ContextMenu.SelectedOption.Video"; } else if (params.isImage()) { histogramName = params.isAnchor() ? "ContextMenu.SelectedOption.ImageLink" : "ContextMenu.SelectedOption.Image"; } else { assert params.isAnchor(); histogramName = "ContextMenu.SelectedOption.Link"; } RecordHistogram.recordEnumeratedHistogram(histogramName, action, NUM_ACTIONS); } /** * Records the content types when user downloads the file by long pressing the * save link context menu option. */ static void recordSaveLinkTypes(String url) { String extension = MimeTypeMap.getFileExtensionFromUrl(url); int mimeType = TYPE_UNKNOWN; if (extension != null) { String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); if (type != null) { if (type.startsWith("text")) { mimeType = TYPE_TEXT; } else if (type.startsWith("image")) { mimeType = TYPE_IMAGE; } else if (type.startsWith("audio")) { mimeType = TYPE_AUDIO; } else if (type.startsWith("video")) { mimeType = TYPE_VIDEO; } else if (type.equals("application/pdf")) { mimeType = TYPE_PDF; } } } RecordHistogram.recordEnumeratedHistogram( "ContextMenu.SaveLinkType", mimeType, NUM_TYPES); } } /** * Builds a {@link ChromeContextMenuPopulator}. * @param delegate The {@link ContextMenuItemDelegate} that will be notified with actions * to perform when menu items are selected. * @param mode Defines the context menu mode */ public ChromeContextMenuPopulator(ContextMenuItemDelegate delegate, @ContextMenuMode int mode) { mDelegate = delegate; mMode = mode; } @Override public void buildContextMenu(ContextMenu menu, Context context, ContextMenuParams params) { if (!TextUtils.isEmpty(params.getLinkUrl()) && !params.getLinkUrl().equals(BLANK_URL)) { setHeaderText(context, menu, params.getLinkUrl()); } else if (!TextUtils.isEmpty(params.getTitleText())) { setHeaderText(context, menu, params.getTitleText()); } if (mMenuInflater == null) mMenuInflater = new MenuInflater(context); mMenuInflater.inflate(R.menu.chrome_context_menu, menu); menu.setGroupVisible(R.id.contextmenu_group_anchor, params.isAnchor()); menu.setGroupVisible(R.id.contextmenu_group_image, params.isImage()); menu.setGroupVisible(R.id.contextmenu_group_video, params.isVideo()); if (params.isAnchor() && !mDelegate.isOpenInOtherWindowSupported()) { menu.findItem(R.id.contextmenu_open_in_other_window).setVisible(false); } if (mDelegate.isIncognito() || !mDelegate.isIncognitoSupported()) { menu.findItem(R.id.contextmenu_open_in_incognito_tab).setVisible(false); } if (params.getLinkText().trim().isEmpty() || params.isImage()) { menu.findItem(R.id.contextmenu_copy_link_text).setVisible(false); } if (params.isAnchor() && !UrlUtilities.isAcceptedScheme(params.getLinkUrl())) { menu.findItem(R.id.contextmenu_open_in_other_window).setVisible(false); menu.findItem(R.id.contextmenu_open_in_new_tab).setVisible(false); menu.findItem(R.id.contextmenu_open_in_incognito_tab).setVisible(false); } if (MailTo.isMailTo(params.getLinkUrl())) { menu.findItem(R.id.contextmenu_copy_link_address).setVisible(false); } else { menu.findItem(R.id.contextmenu_copy_email_address).setVisible(false); } menu.findItem(R.id.contextmenu_save_link_as).setVisible( UrlUtilities.isDownloadableScheme(params.getLinkUrl())); // Only enable the save as offline feature if OfflinePagesBackgroundLoading is enabled, and // it looks like a web page. boolean showSaveOfflineMenuItem = shouldShowBackgroundLoadingContextMenu(params.getLinkUrl()); menu.findItem(R.id.contextmenu_save_offline).setVisible(showSaveOfflineMenuItem); if (params.imageWasFetchedLoFi() || !DataReductionProxySettings.getInstance().wasLoFiModeActiveOnMainFrame() || !DataReductionProxySettings.getInstance().canUseDataReductionProxy( params.getPageUrl())) { menu.findItem(R.id.contextmenu_load_images).setVisible(false); } else { // Links can have images as backgrounds that aren't recognized here as images. CSS // properties can also prevent an image underlying a link from being clickable. // When Lo-Fi is active, provide the user with a "Load images" option on links // to get the images in these cases. DataReductionProxyUma.previewsLoFiContextMenuAction( DataReductionProxyUma.ACTION_LOFI_LOAD_IMAGES_CONTEXT_MENU_SHOWN); } if (params.isVideo()) { menu.findItem(R.id.contextmenu_save_video).setVisible( params.canSaveMedia() && UrlUtilities.isDownloadableScheme(params.getSrcUrl())); } else if (params.isImage() && params.imageWasFetchedLoFi()) { DataReductionProxyUma.previewsLoFiContextMenuAction( DataReductionProxyUma.ACTION_LOFI_LOAD_IMAGE_CONTEXT_MENU_SHOWN); // All image context menu items other than "Load image," "Open original image in // new tab," and "Copy image URL" should be disabled on Lo-Fi images. menu.findItem(R.id.contextmenu_save_image).setVisible(false); menu.findItem(R.id.contextmenu_open_image).setVisible(false); menu.findItem(R.id.contextmenu_search_by_image).setVisible(false); menu.findItem(R.id.contextmenu_share_image).setVisible(false); } else if (params.isImage() && !params.imageWasFetchedLoFi()) { menu.findItem(R.id.contextmenu_load_original_image).setVisible(false); menu.findItem(R.id.contextmenu_save_image).setVisible( UrlUtilities.isDownloadableScheme(params.getSrcUrl())); // Avoid showing open image option for same image which is already opened. if (mDelegate.getPageUrl().equals(params.getSrcUrl())) { menu.findItem(R.id.contextmenu_open_image).setVisible(false); } final TemplateUrlService templateUrlServiceInstance = TemplateUrlService.getInstance(); final boolean isSearchByImageAvailable = UrlUtilities.isDownloadableScheme(params.getSrcUrl()) && templateUrlServiceInstance.isLoaded() && templateUrlServiceInstance.isSearchByImageAvailable() && templateUrlServiceInstance.getDefaultSearchEngineTemplateUrl() != null; menu.findItem(R.id.contextmenu_search_by_image).setVisible(isSearchByImageAvailable); if (isSearchByImageAvailable) { menu.findItem(R.id.contextmenu_search_by_image).setTitle( context.getString(R.string.contextmenu_search_web_for_image, TemplateUrlService.getInstance() .getDefaultSearchEngineTemplateUrl().getShortName())); } } if (mMode == FULLSCREEN_TAB_MODE) { removeUnsupportedItems(menu, FULLSCREEN_TAB_MODE_WHITELIST); } else if (mMode == CUSTOM_TAB_MODE) { removeUnsupportedItems(menu, CUSTOM_TAB_MODE_WHITELIST); } else { removeUnsupportedItems(menu, NORMAL_MODE_WHITELIST); } } private void removeUnsupportedItems(ContextMenu menu, int[] whitelist) { Arrays.sort(BASE_WHITELIST); Arrays.sort(whitelist); for (int i = 0; i < menu.size(); i++) { MenuItem item = menu.getItem(i); if (Arrays.binarySearch(whitelist, item.getItemId()) < 0 && Arrays.binarySearch(BASE_WHITELIST, item.getItemId()) < 0) { menu.removeItem(item.getItemId()); i--; } } } @Override public boolean onItemSelected(ContextMenuHelper helper, ContextMenuParams params, int itemId) { if (itemId == R.id.contextmenu_open_in_other_window) { ContextMenuUma.record(params, ContextMenuUma.ACTION_OPEN_IN_OTHER_WINDOW); mDelegate.onOpenInOtherWindow(params.getLinkUrl(), params.getReferrer()); } else if (itemId == R.id.contextmenu_open_in_new_tab) { ContextMenuUma.record(params, ContextMenuUma.ACTION_OPEN_IN_NEW_TAB); mDelegate.onOpenInNewTab(params.getLinkUrl(), params.getReferrer()); } else if (itemId == R.id.contextmenu_open_in_incognito_tab) { ContextMenuUma.record(params, ContextMenuUma.ACTION_OPEN_IN_INCOGNITO_TAB); mDelegate.onOpenInNewIncognitoTab(params.getLinkUrl()); } else if (itemId == R.id.contextmenu_open_image) { ContextMenuUma.record(params, ContextMenuUma.ACTION_OPEN_IMAGE); mDelegate.onOpenImageUrl(params.getSrcUrl(), params.getReferrer()); } else if (itemId == R.id.contextmenu_open_image_in_new_tab) { ContextMenuUma.record(params, ContextMenuUma.ACTION_OPEN_IMAGE_IN_NEW_TAB); mDelegate.onOpenImageInNewTab(params.getSrcUrl(), params.getReferrer()); } else if (itemId == R.id.contextmenu_load_images) { ContextMenuUma.record(params, ContextMenuUma.ACTION_LOAD_IMAGES); DataReductionProxyUma.previewsLoFiContextMenuAction( DataReductionProxyUma.ACTION_LOFI_LOAD_IMAGES_CONTEXT_MENU_CLICKED); mDelegate.onReloadLoFiImages(); } else if (itemId == R.id.contextmenu_load_original_image) { ContextMenuUma.record(params, ContextMenuUma.ACTION_LOAD_ORIGINAL_IMAGE); DataReductionProxyUma.previewsLoFiContextMenuAction( DataReductionProxyUma.ACTION_LOFI_LOAD_IMAGE_CONTEXT_MENU_CLICKED); if (!DataReductionProxySettings.getInstance().wasLoFiLoadImageRequestedBefore()) { DataReductionProxyUma.previewsLoFiContextMenuAction( DataReductionProxyUma.ACTION_LOFI_LOAD_IMAGE_CONTEXT_MENU_CLICKED_ON_PAGE); DataReductionProxySettings.getInstance().setLoFiLoadImageRequested(); } mDelegate.onLoadOriginalImage(); } else if (itemId == R.id.contextmenu_copy_link_address) { ContextMenuUma.record(params, ContextMenuUma.ACTION_COPY_LINK_ADDRESS); mDelegate.onSaveToClipboard(params.getUnfilteredLinkUrl(), ContextMenuItemDelegate.CLIPBOARD_TYPE_LINK_URL); } else if (itemId == R.id.contextmenu_copy_email_address) { ContextMenuUma.record(params, ContextMenuUma.ACTION_COPY_EMAIL_ADDRESS); mDelegate.onSaveToClipboard(MailTo.parse(params.getLinkUrl()).getTo(), ContextMenuItemDelegate.CLIPBOARD_TYPE_LINK_URL); } else if (itemId == R.id.contextmenu_copy_link_text) { ContextMenuUma.record(params, ContextMenuUma.ACTION_COPY_LINK_TEXT); mDelegate.onSaveToClipboard( params.getLinkText(), ContextMenuItemDelegate.CLIPBOARD_TYPE_LINK_TEXT); } else if (itemId == R.id.contextmenu_save_image) { ContextMenuUma.record(params, ContextMenuUma.ACTION_SAVE_IMAGE); if (mDelegate.startDownload(params.getSrcUrl(), false)) { helper.startContextMenuDownload( false, mDelegate.isDataReductionProxyEnabledForURL(params.getSrcUrl())); } } else if (itemId == R.id.contextmenu_save_video) { ContextMenuUma.record(params, ContextMenuUma.ACTION_SAVE_VIDEO); if (mDelegate.startDownload(params.getSrcUrl(), false)) { helper.startContextMenuDownload(false, false); } } else if (itemId == R.id.contextmenu_save_link_as) { ContextMenuUma.record(params, ContextMenuUma.ACTION_SAVE_LINK); String url = params.getUnfilteredLinkUrl(); if (mDelegate.startDownload(url, true)) { ContextMenuUma.recordSaveLinkTypes(url); helper.startContextMenuDownload(true, false); } } else if (itemId == R.id.contextmenu_save_offline) { // This is a temporary hookup point to save a page later. mDelegate.onSavePageLater(params.getLinkUrl()); } else if (itemId == R.id.contextmenu_search_by_image) { ContextMenuUma.record(params, ContextMenuUma.ACTION_SEARCH_BY_IMAGE); helper.searchForImage(); } else if (itemId == R.id.contextmenu_share_image) { ContextMenuUma.record(params, ContextMenuUma.ACTION_SHARE_IMAGE); helper.shareImage(); } else if (itemId == R.id.menu_id_open_in_chrome) { mDelegate.onOpenInChrome(params.getLinkUrl(), params.getPageUrl()); } else { assert false; } return true; } /** * Return true if we should show a context menu for background loading. Make sure the uri looks * like a web page (scheme is http or https) and has mime type of unknown or text. * @param uriString The uri that we are showing a context menu on. */ private static boolean shouldShowBackgroundLoadingContextMenu(String uriString) { if (!OfflinePageBridge.isBackgroundLoadingEnabled()) return false; URI uri = null; try { uri = new URI(uriString); } catch (URISyntaxException e) { Log.e(TAG, "Trying to parse a link that is not a URI " + e); return false; } String scheme = uri.getScheme(); if (scheme == null) return false; String extension = MimeTypeMap.getFileExtensionFromUrl(uriString); String type = null; if (extension != null) { type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); } return (scheme.equals("http") || scheme.equals("https")) && (type == null || type.startsWith("text")); } private void setHeaderText(Context context, ContextMenu menu, String text) { ContextMenuTitleView title = new ContextMenuTitleView(context, text); menu.setHeaderView(title); } }