// Copyright 2016 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.download; import android.app.Activity; import android.app.PendingIntent; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; import android.net.Uri; import android.os.StrictMode; import android.provider.Browser; import android.support.annotation.Nullable; import android.support.customtabs.CustomTabsIntent; import android.text.TextUtils; import org.chromium.base.ApplicationStatus; import org.chromium.base.ContentUriUtils; import org.chromium.base.ContextUtils; import org.chromium.base.Log; import org.chromium.base.metrics.RecordHistogram; import org.chromium.base.metrics.RecordUserAction; import org.chromium.chrome.R; import org.chromium.chrome.browser.ChromeFeatureList; import org.chromium.chrome.browser.ChromeTabbedActivity; import org.chromium.chrome.browser.IntentHandler; import org.chromium.chrome.browser.UrlConstants; import org.chromium.chrome.browser.customtabs.CustomTabActivity; import org.chromium.chrome.browser.customtabs.CustomTabIntentDataProvider; import org.chromium.chrome.browser.download.ui.BackendProvider; import org.chromium.chrome.browser.download.ui.BackendProvider.DownloadDelegate; import org.chromium.chrome.browser.download.ui.DownloadFilter; import org.chromium.chrome.browser.download.ui.DownloadHistoryItemWrapper; import org.chromium.chrome.browser.offlinepages.downloads.OfflinePageDownloadBridge; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.browser.tabmodel.TabModel.TabLaunchType; import org.chromium.chrome.browser.tabmodel.document.TabDelegate; import org.chromium.chrome.browser.util.IntentUtils; import org.chromium.content_public.browser.LoadUrlParams; import org.chromium.ui.base.DeviceFormFactor; import org.chromium.ui.widget.Toast; import java.io.File; import java.util.ArrayList; import java.util.List; /** * A class containing some utility static methods. */ public class DownloadUtils { private static final String TAG = "download"; private static final String DEFAULT_MIME_TYPE = "*/*"; private static final String MIME_TYPE_DELIMITER = "/"; private static final String EXTRA_IS_OFF_THE_RECORD = "org.chromium.chrome.browser.download.IS_OFF_THE_RECORD"; private static final String PREF_IS_DOWNLOAD_HOME_ENABLED = "org.chromium.chrome.browser.download.IS_DOWNLOAD_HOME_ENABLED"; /** * @return Whether or not the Download Home is enabled. */ public static boolean isDownloadHomeEnabled() { SharedPreferences preferences = ContextUtils.getAppSharedPreferences(); return preferences.getBoolean(PREF_IS_DOWNLOAD_HOME_ENABLED, false); } /** * Caches the native flag that enables the Download Home in SharedPreferences. * This is necessary because the DownloadActivity can be opened before native has been loaded. */ public static void cacheIsDownloadHomeEnabled() { boolean isEnabled = ChromeFeatureList.isEnabled("DownloadsUi"); SharedPreferences preferences = ContextUtils.getAppSharedPreferences(); preferences.edit().putBoolean(PREF_IS_DOWNLOAD_HOME_ENABLED, isEnabled).apply(); } /** * Displays the download manager UI. Note the UI is different on tablets and on phones. * @return Whether the UI was shown. */ public static boolean showDownloadManager(@Nullable Activity activity, @Nullable Tab tab) { if (!isDownloadHomeEnabled()) return false; // Figure out what tab was last being viewed by the user. if (activity == null) activity = ApplicationStatus.getLastTrackedFocusedActivity(); if (tab == null && activity instanceof ChromeTabbedActivity) { tab = ((ChromeTabbedActivity) activity).getActivityTab(); } Context appContext = ContextUtils.getApplicationContext(); if (DeviceFormFactor.isTablet(appContext)) { // Download Home shows up as a tab on tablets. LoadUrlParams params = new LoadUrlParams(UrlConstants.DOWNLOADS_URL); if (tab == null || !tab.isInitialized()) { // Open a new tab, which pops Chrome into the foreground. TabDelegate delegate = new TabDelegate(false); delegate.createNewTab(params, TabLaunchType.FROM_CHROME_UI, null); } else { // Download Home shows up inside an existing tab, but only if the last Activity was // the ChromeTabbedActivity. tab.loadUrl(params); // Bring Chrome to the foreground, if possible. Intent intent = Tab.createBringTabToFrontIntent(tab.getId()); if (intent != null) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); IntentUtils.safeStartActivity(appContext, intent); } } } else { // Download Home shows up as a new Activity on phones. Intent intent = new Intent(); intent.setClass(appContext, DownloadActivity.class); if (tab != null) intent.putExtra(EXTRA_IS_OFF_THE_RECORD, tab.isIncognito()); if (activity == null) { // Stands alone in its own task. intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); appContext.startActivity(intent); } else { // Sits on top of another Activity. intent.putExtra(IntentHandler.EXTRA_PARENT_COMPONENT, activity.getComponentName()); activity.startActivity(intent); } } return true; } /** * @return Whether or not the Intent corresponds to a DownloadActivity that should show off the * record downloads. */ public static boolean shouldShowOffTheRecordDownloads(Intent intent) { return IntentUtils.safeGetBooleanExtra(intent, EXTRA_IS_OFF_THE_RECORD, false); } /** * Records metrics related to downloading a page. Should be called after a tap on the download * page button. * @param tab The Tab containing the page being downloaded. */ public static void recordDownloadPageMetrics(Tab tab) { RecordHistogram.recordPercentageHistogram("OfflinePages.SavePage.PercentLoaded", tab.getProgress()); } /** * Shows a "Downloading..." toast. Should be called after a download has been started. * @param context The {@link Context} used to make the toast. */ public static void showDownloadStartToast(Context context) { Toast.makeText(context, R.string.download_pending, Toast.LENGTH_SHORT).show(); } /** * Issues a request to the {@link DownloadDelegate} associated with backendProvider to check * for externally removed downloads. * See {@link DownloadManagerService#checkForExternallyRemovedDownloads}. * * @param backendProvider The {@link BackendProvider} associated with the DownloadDelegate used * to check for externally removed downloads. * @param isOffTheRecord Whether to check downloads for the off the record profile. */ public static void checkForExternallyRemovedDownloads(BackendProvider backendProvider, boolean isOffTheRecord) { if (isOffTheRecord) { backendProvider.getDownloadDelegate().checkForExternallyRemovedDownloads(true); } backendProvider.getDownloadDelegate().checkForExternallyRemovedDownloads(false); RecordUserAction.record( "Android.DownloadManager.CheckForExternallyRemovedItems"); } /** * Trigger the download of an Offline Page. * @param context Context to pull resources from. */ public static void downloadOfflinePage(Context context, Tab tab) { final OfflinePageDownloadBridge bridge = new OfflinePageDownloadBridge(tab.getProfile()); bridge.startDownload(tab); bridge.destroy(); DownloadUtils.recordDownloadPageMetrics(tab); } /** * Whether the user should be allowed to download the current page. * @param tab Tab displaying the page that will be downloaded. * @return Whether the "Download Page" button should be enabled. */ public static boolean isAllowedToDownloadPage(Tab tab) { if (tab == null) return false; // Only allow HTTP and HTTPS pages, as that is these are the only scenarios supported by the // background/offline page saving. if (!tab.getUrl().startsWith(UrlConstants.HTTP_SCHEME) && !tab.getUrl().startsWith(UrlConstants.HTTPS_SCHEME)) { return false; } if (tab.isShowingErrorPage()) return false; if (tab.isShowingInterstitialPage()) return false; // Don't allow re-downloading the currently displayed offline page. if (tab.isOfflinePage()) return false; // Offline pages isn't supported in Incognito. if (tab.isIncognito()) return false; return true; } /** * Creates an Intent to open the file in another app by firing an Intent to Android. * @param fileUri Uri pointing to the file. * @param mimeType MIME type for the file. * @return Intent that can be used to start an Activity for the file. */ public static Intent createViewIntentForDownloadItem(Uri fileUri, String mimeType) { Intent fileIntent = new Intent(Intent.ACTION_VIEW); String normalizedMimeType = Intent.normalizeMimeType(mimeType); if (TextUtils.isEmpty(normalizedMimeType)) { fileIntent.setData(fileUri); } else { fileIntent.setDataAndType(fileUri, normalizedMimeType); } fileIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); fileIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); return fileIntent; } /** * Creates an Intent to share {@code items} with another app by firing an Intent to Android. * * Sharing a DownloadItem shares the file itself, while sharing an OfflinePageItem shares the * URL. * * @param items Items to share. * @return Intent that can be used to share the items. */ public static Intent createShareIntent(List<DownloadHistoryItemWrapper> items) { Intent shareIntent = new Intent(); String intentAction; ArrayList<Uri> itemUris = new ArrayList<Uri>(); StringBuilder offlinePagesString = new StringBuilder(); int selectedItemsFilterType = items.get(0).getFilterType(); String intentMimeType = ""; String[] intentMimeParts = {"", ""}; for (int i = 0; i < items.size(); i++) { DownloadHistoryItemWrapper wrappedItem = items.get(i); if (wrappedItem instanceof DownloadHistoryItemWrapper.OfflinePageItemWrapper) { if (offlinePagesString.length() != 0) { offlinePagesString.append("\n"); } offlinePagesString.append(wrappedItem.getUrl()); } else { itemUris.add(getUriForItem(wrappedItem.getFile())); } if (selectedItemsFilterType != wrappedItem.getFilterType()) { selectedItemsFilterType = DownloadFilter.FILTER_ALL; } String mimeType = Intent.normalizeMimeType(wrappedItem.getMimeType()); // If a mime type was not retrieved from the backend or could not be normalized, // set the mime type to the default. if (TextUtils.isEmpty(mimeType)) { intentMimeType = DEFAULT_MIME_TYPE; continue; } // If the intent mime type has not been set yet, set it to the mime type for this item. if (TextUtils.isEmpty(intentMimeType)) { intentMimeType = mimeType; if (!TextUtils.isEmpty(intentMimeType)) { intentMimeParts = intentMimeType.split(MIME_TYPE_DELIMITER); // Guard against invalid mime types. if (intentMimeParts.length != 2) intentMimeType = DEFAULT_MIME_TYPE; } continue; } // Either the mime type is already the default or it matches the current item's mime // type. In either case, intentMimeType is already the correct value. if (TextUtils.equals(intentMimeType, DEFAULT_MIME_TYPE) || TextUtils.equals(intentMimeType, mimeType)) { continue; } String[] mimeParts = mimeType.split(MIME_TYPE_DELIMITER); if (!TextUtils.equals(intentMimeParts[0], mimeParts[0])) { // The top-level types don't match; fallback to the default mime type. intentMimeType = DEFAULT_MIME_TYPE; } else { // The mime type should be {top-level type}/* intentMimeType = intentMimeParts[0] + MIME_TYPE_DELIMITER + "*"; } } // Use Action_SEND if there is only one downloaded item or only text to share. if (itemUris.size() == 0 || (itemUris.size() == 1 && offlinePagesString.length() == 0)) { intentAction = Intent.ACTION_SEND; } else { intentAction = Intent.ACTION_SEND_MULTIPLE; } if (itemUris.size() == 1 && offlinePagesString.length() == 0) { // Sharing a DownloadItem. shareIntent.putExtra(Intent.EXTRA_STREAM, getUriForItem(items.get(0).getFile())); } else { shareIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, itemUris); } if (offlinePagesString.length() != 0) { shareIntent.putExtra(Intent.EXTRA_TEXT, offlinePagesString.toString()); } shareIntent.setAction(intentAction); shareIntent.setType(intentMimeType); shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); recordShareHistograms(items.size(), selectedItemsFilterType); return shareIntent; } private static Intent createShareIntent(Uri fileUri, String mimeType) { if (TextUtils.isEmpty(mimeType)) mimeType = DEFAULT_MIME_TYPE; Intent intent = new Intent(Intent.ACTION_SEND); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.putExtra(Intent.EXTRA_STREAM, fileUri); intent.setType(mimeType); return intent; } /** * Creates an Intent that allows viewing the given file in an internal media viewer. * @param fileUri URI pointing at the file, ideally in file:// form. * @param shareUri URI pointing at the file, ideally in content:// form. * @param mimeType MIME type of the file. * @return Intent that can be fired to open the file. */ public static Intent getMediaViewerIntentForDownloadItem( Uri fileUri, Uri shareUri, String mimeType) { Context context = ContextUtils.getApplicationContext(); Intent viewIntent = createViewIntentForDownloadItem(fileUri, mimeType); Bitmap closeIcon = BitmapFactory.decodeResource( context.getResources(), R.drawable.ic_arrow_back_white_24dp); Bitmap shareIcon = BitmapFactory.decodeResource( context.getResources(), R.drawable.ic_share_white_24dp); CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); builder.setToolbarColor(Color.BLACK); builder.setCloseButtonIcon(closeIcon); builder.setShowTitle(true); // Create a PendingIntent that can be used to view the file externally. // TODO(dfalcantara): Check if this is problematic in multi-window mode, where two // different viewers could be visible at the same time. Intent chooserIntent = Intent.createChooser(viewIntent, null); chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); String openWithStr = context.getString(R.string.download_manager_open_with); PendingIntent pendingViewIntent = PendingIntent.getActivity( context, 0, chooserIntent, PendingIntent.FLAG_CANCEL_CURRENT); builder.addMenuItem(openWithStr, pendingViewIntent); // Create a PendingIntent that shares the file with external apps. PendingIntent pendingShareIntent = PendingIntent.getActivity( context, 0, createShareIntent(shareUri, mimeType), 0); builder.setActionButton( shareIcon, context.getString(R.string.share), pendingShareIntent, true); // Build up the Intent further. Intent intent = builder.build().intent; intent.setPackage(context.getPackageName()); intent.setData(fileUri); intent.putExtra(CustomTabIntentDataProvider.EXTRA_IS_MEDIA_VIEWER, true); intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); IntentHandler.addTrustedIntentExtras(intent, context); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.setClass(context, CustomTabActivity.class); return intent; } /** * Returns a URI that points at the file. * @param file File to get a URI for. * @return URI that points at that file, either as a content:// URI or a file:// URI. */ public static Uri getUriForItem(File file) { Uri uri = null; // #getContentUriFromFile causes a disk read when it calls into FileProvider#getUriForFile. // Obtaining a content URI is on the critical path for creating a share intent after the // user taps on the share button, so even if we were to run this method on a background // thread we would have to wait. As it depends on user-selected items, we cannot // know/preload which URIs we need until the user presses share. StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads(); try { // Try to obtain a content:// URI, which is preferred to a file:// URI so that // receiving apps don't attempt to determine the file's mime type (which often fails). uri = ContentUriUtils.getContentUriFromFile(ContextUtils.getApplicationContext(), file); } catch (IllegalArgumentException e) { Log.e(TAG, "Could not create content uri: " + e); } StrictMode.setThreadPolicy(oldPolicy); if (uri == null) uri = Uri.fromFile(file); return uri; } private static void recordShareHistograms(int count, int filterType) { RecordHistogram.recordEnumeratedHistogram("Android.DownloadManager.Share.FileTypes", filterType, DownloadFilter.FILTER_BOUNDARY); RecordHistogram.recordLinearCountHistogram("Android.DownloadManager.Share.Count", count, 1, 20, 20); } /** * Fires an Intent to open a downloaded item. * @param context Context to use. * @param intent Intent that can be fired. * @return Whether an Activity was successfully started for the Intent. */ static boolean fireOpenIntentForDownload(Context context, Intent intent) { try { if (TextUtils.equals(intent.getPackage(), context.getPackageName())) { IntentHandler.startActivityForTrustedIntent(intent, context); } else { context.startActivity(intent); } return true; } catch (ActivityNotFoundException ex) { Log.d(TAG, "Activity not found for " + intent.getType() + " over " + intent.getData().getScheme(), ex); } catch (SecurityException ex) { Log.d(TAG, "cannot open intent: " + intent, ex); } return false; } }