// Copyright 2015 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.offlinepages; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.net.Uri; import android.os.AsyncTask; import android.os.BatteryManager; import android.os.Environment; import org.chromium.base.Callback; import org.chromium.base.FileUtils; import org.chromium.base.Log; import org.chromium.base.StreamUtil; import org.chromium.base.VisibleForTesting; import org.chromium.base.metrics.RecordHistogram; import org.chromium.base.metrics.RecordUserAction; import org.chromium.chrome.R; import org.chromium.chrome.browser.ChromeActivity; import org.chromium.chrome.browser.profiles.Profile; import org.chromium.chrome.browser.share.ShareHelper; import org.chromium.chrome.browser.snackbar.Snackbar; import org.chromium.chrome.browser.snackbar.SnackbarManager; import org.chromium.chrome.browser.snackbar.SnackbarManager.SnackbarController; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.browser.tabmodel.TabModelSelector; import org.chromium.components.bookmarks.BookmarkId; import org.chromium.components.offlinepages.SavePageResult; import org.chromium.content_public.browser.WebContents; import org.chromium.net.ConnectionType; import org.chromium.net.NetworkChangeNotifier; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.channels.FileChannel; import java.util.concurrent.TimeUnit; /** * A class holding static util functions for offline pages. */ public class OfflinePageUtils { private static final String TAG = "OfflinePageUtils"; /** Background task tag to differentiate from other task types */ public static final String TASK_TAG = "OfflinePageUtils"; public static final String EXTERNAL_MHTML_FILE_PATH = "offline-pages"; private static final int DEFAULT_SNACKBAR_DURATION_MS = 6 * 1000; // 6 second private static final long STORAGE_ALMOST_FULL_THRESHOLD_BYTES = 10L * (1 << 20); // 10M // Used instead of the constant so tests can override the value. private static int sSnackbarDurationMs = DEFAULT_SNACKBAR_DURATION_MS; private static OfflinePageUtils sInstance; private static File sOfflineSharingDirectory; private static OfflinePageUtils getInstance() { if (sInstance == null) { sInstance = new OfflinePageUtils(); } return sInstance; } /** * Returns the number of free bytes on the storage. */ public static long getFreeSpaceInBytes() { return Environment.getDataDirectory().getUsableSpace(); } /** * Returns the number of total bytes on the storage. */ public static long getTotalSpaceInBytes() { return Environment.getDataDirectory().getTotalSpace(); } /** * Returns true if the network is connected. */ public static boolean isConnected() { return NetworkChangeNotifier.isOnline(); } /* * Save an offline copy for the bookmarked page asynchronously. * * @param bookmarkId The ID of the page to save an offline copy. * @param tab A {@link Tab} object. * @param callback The callback to be invoked when the offline copy is saved. */ public static void saveBookmarkOffline(BookmarkId bookmarkId, Tab tab) { // If bookmark ID is missing there is nothing to save here. if (bookmarkId == null) return; // Making sure the feature is enabled. if (!OfflinePageBridge.isOfflineBookmarksEnabled()) return; // Making sure tab is worth keeping. if (shouldSkipSavingTabOffline(tab)) return; OfflinePageBridge offlinePageBridge = getInstance().getOfflinePageBridge(tab.getProfile()); if (offlinePageBridge == null) return; WebContents webContents = tab.getWebContents(); ClientId clientId = ClientId.createClientIdForBookmarkId(bookmarkId); // TODO(fgorski): Ensure that request is queued if the model is not loaded. offlinePageBridge.savePage(webContents, clientId, new OfflinePageBridge.SavePageCallback() { @Override public void onSavePageDone(int savePageResult, String url, long offlineId) { // TODO(fgorski): Decide if we need to do anything with result. // Perhaps some UMA reporting, but that can really happen someplace else. } }); } /** * Indicates whether we should skip saving the given tab as an offline page. * A tab shouldn't be saved offline if it shows an error page or a sad tab page. */ private static boolean shouldSkipSavingTabOffline(Tab tab) { WebContents webContents = tab.getWebContents(); return tab.isShowingErrorPage() || tab.isShowingSadTab() || webContents == null || webContents.isDestroyed() || webContents.isIncognito(); } /** * Strips scheme from the original URL of the offline page. This is meant to be used by UI. * @param onlineUrl an online URL to from which the scheme is removed * @return onlineUrl without the scheme */ public static String stripSchemeFromOnlineUrl(String onlineUrl) { onlineUrl = onlineUrl.trim(); // Offline pages are only saved for https:// and http:// schemes. if (onlineUrl.startsWith("https://")) { return onlineUrl.substring(8); } else if (onlineUrl.startsWith("http://")) { return onlineUrl.substring(7); } else { return onlineUrl; } } /** * Shows the snackbar for the current tab to provide offline specific information if needed. * @param activity The activity owning the tab. * @param tab The current tab. */ public static void showOfflineSnackbarIfNecessary(ChromeActivity activity, Tab tab) { if (OfflinePageTabObserver.getInstance() == null) { SnackbarController snackbarController = createReloadSnackbarController(activity.getTabModelSelector()); OfflinePageTabObserver.init( activity.getBaseContext(), activity.getSnackbarManager(), snackbarController); } showOfflineSnackbarIfNecessary(tab); } /** * Shows the snackbar for the current tab to provide offline specific information if needed. * This method is used by testing for dependency injecting a snackbar controller. * @param context android context * @param snackbarManager The snackbar manager to show and dismiss snackbars. * @param tab The current tab. * @param snackbarController The snackbar controller to control snackbar behavior. */ static void showOfflineSnackbarIfNecessary(Tab tab) { // Set up the tab observer to watch for the tab being shown (not hidden) and a valid // connection. When both conditions are met a snackbar is shown. OfflinePageTabObserver.addObserverForTab(tab); } /** * Shows the "reload" snackbar for the given tab. * @param activity The activity owning the tab. * @param snackbarController Class to show the snackbar. */ public static void showReloadSnackbar(Context context, SnackbarManager snackbarManager, final SnackbarController snackbarController, int tabId) { if (tabId == Tab.INVALID_TAB_ID) return; Log.d(TAG, "showReloadSnackbar called with controller " + snackbarController); Snackbar snackbar = Snackbar.make(context.getString(R.string.offline_pages_viewing_offline_page), snackbarController, Snackbar.TYPE_ACTION, Snackbar.UMA_OFFLINE_PAGE_RELOAD) .setSingleLine(false).setAction(context.getString(R.string.reload), tabId); snackbar.setDuration(sSnackbarDurationMs); snackbarManager.showSnackbar(snackbar); } /** * Gets a snackbar controller that we can use to show our snackbar. * @param tabModelSelector used to retrieve a tab by ID */ private static SnackbarController createReloadSnackbarController( final TabModelSelector tabModelSelector) { Log.d(TAG, "building snackbar controller"); return new SnackbarController() { @Override public void onAction(Object actionData) { assert actionData != null; int tabId = (int) actionData; RecordUserAction.record("OfflinePages.ReloadButtonClicked"); Tab foundTab = tabModelSelector.getTabById(tabId); if (foundTab == null) return; // Delegates to Tab to reload the page. Tab will send the correct header in order to // load the right page. foundTab.reload(); } @Override public void onDismissNoAction(Object actionData) { RecordUserAction.record("OfflinePages.ReloadButtonNotClicked"); } }; } public static DeviceConditions getDeviceConditions(Context context) { return getInstance().getDeviceConditionsImpl(context); } /** * Records UMA data when the Offline Pages Background Load service awakens. * @param context android context */ public static void recordWakeupUMA(Context context, long taskScheduledTimeMillis) { DeviceConditions deviceConditions = getDeviceConditions(context); if (deviceConditions == null) return; // Report charging state. RecordHistogram.recordBooleanHistogram( "OfflinePages.Wakeup.ConnectedToPower", deviceConditions.isPowerConnected()); // Report battery percentage. RecordHistogram.recordPercentageHistogram( "OfflinePages.Wakeup.BatteryPercentage", deviceConditions.getBatteryPercentage()); // Report the default network found (or none, if we aren't connected). int connectionType = deviceConditions.getNetConnectionType(); Log.d(TAG, "Found default network of type " + connectionType); RecordHistogram.recordEnumeratedHistogram("OfflinePages.Wakeup.NetworkAvailable", connectionType, ConnectionType.CONNECTION_LAST + 1); // Collect UMA on the time since the request started. long nowMillis = System.currentTimeMillis(); long delayInMilliseconds = nowMillis - taskScheduledTimeMillis; if (delayInMilliseconds <= 0) { return; } RecordHistogram.recordLongTimesHistogram( "OfflinePages.Wakeup.DelayTime", delayInMilliseconds, TimeUnit.MILLISECONDS); } /** * Share an offline copy of the current page. * @param shareDirectly Whether it should share directly with the activity that was most * recently used to share. * @param saveLastUsed Whether to save the chosen activity for future direct sharing. * @param mainActivity Activity that is used to access package manager. * @param text Text to be shared. If both |text| and |url| are supplied, they are concatenated * with a space. * @param screenshotUri Screenshot of the page to be shared. * @param callback Optional callback to be called when user makes a choice. Will not be called * if receiving a response when the user makes a choice is not supported (see * TargetChosenReceiver#isSupported()). * @param currentTab The current tab for which sharing is being done. */ public static void shareOfflinePage(final boolean shareDirectly, final boolean saveLastUsed, final Activity mainActivity, final String text, final Uri screenshotUri, final ShareHelper.TargetChosenCallback callback, final Tab currentTab) { final String url = currentTab.getUrl(); final String title = currentTab.getTitle(); final OfflinePageBridge offlinePageBridge = OfflinePageBridge.getForProfile(currentTab.getProfile()); if (offlinePageBridge == null) { Log.e(TAG, "Unable to perform sharing on current tab."); return; } OfflinePageItem offlinePage = currentTab.getOfflinePage(); if (offlinePage != null) { // If we're currently on offline page get the saved file directly. prepareFileAndShare(shareDirectly, saveLastUsed, mainActivity, title, text, url, screenshotUri, callback, offlinePage.getFilePath()); return; } // If this is an online page, share the offline copy of it. Callback<OfflinePageItem> prepareForSharing = onGotOfflinePageItemToShare(shareDirectly, saveLastUsed, mainActivity, title, text, url, screenshotUri, callback); offlinePageBridge.selectPageForOnlineUrl(url, currentTab.getId(), selectPageForOnlineUrlCallback(currentTab.getWebContents(), offlinePageBridge, prepareForSharing)); } /** * Callback for receiving the OfflinePageItem and use it to call prepareForSharing. * @param shareDirectly Whether it should share directly with the activity that was most * recently used to share. * @param mainActivity Activity that is used to access package manager * @param title Title of the page. * @param onlineUrl Online URL associated with the offline page that is used to access the * offline page file path. * @param screenshotUri Screenshot of the page to be shared. * @param mContext The application context. * @return a callback of OfflinePageItem */ private static Callback<OfflinePageItem> onGotOfflinePageItemToShare( final boolean shareDirectly, final boolean saveLastUsed, final Activity mainActivity, final String title, final String text, final String onlineUrl, final Uri screenshotUri, final ShareHelper.TargetChosenCallback callback) { return new Callback<OfflinePageItem>() { @Override public void onResult(OfflinePageItem item) { String offlineFilePath = (item == null) ? null : item.getFilePath(); prepareFileAndShare(shareDirectly, saveLastUsed, mainActivity, title, text, onlineUrl, screenshotUri, callback, offlineFilePath); } }; } /** * Takes the offline page item from selectPageForOnlineURL. If it exists, invokes * |prepareForSharing| with it. Otherwise, saves a page for the online URL and invokes * |prepareForSharing| with the result when it's ready. * @param webContents Contents of the page to save. * @param offlinePageBridge A static copy of the offlinePageBridge. * @param prepareForSharing Callback of a single OfflinePageItem that is used to call * prepareForSharing * @return a callback of OfflinePageItem */ private static Callback<OfflinePageItem> selectPageForOnlineUrlCallback( final WebContents webContents, final OfflinePageBridge offlinePageBridge, final Callback<OfflinePageItem> prepareForSharing) { return new Callback<OfflinePageItem>() { @Override public void onResult(OfflinePageItem item) { if (item == null) { // If the page has no offline copy, save the page offline. ClientId clientId = ClientId.createGuidClientIdForNamespace( OfflinePageBridge.SHARE_NAMESPACE); offlinePageBridge.savePage(webContents, clientId, savePageCallback(prepareForSharing, offlinePageBridge)); return; } // If the online page has offline copy associated with it, use the file directly. prepareForSharing.onResult(item); } }; } /** * Saves the web page loaded into web contents. If page saved successfully, get the offline * page item with the save page result and use it to invoke |prepareForSharing|. Otherwise, * invokes |prepareForSharing| with null. * @param prepareForSharing Callback of a single OfflinePageItem that is used to call * prepareForSharing * @param offlinePageBridge A static copy of the offlinePageBridge. * @return a call back of a list of OfflinePageItem */ private static OfflinePageBridge.SavePageCallback savePageCallback( final Callback<OfflinePageItem> prepareForSharing, final OfflinePageBridge offlinePageBridge) { return new OfflinePageBridge.SavePageCallback() { @Override public void onSavePageDone(int savePageResult, String url, long offlineId) { if (savePageResult != SavePageResult.SUCCESS) { Log.e(TAG, "Unable to save the page."); prepareForSharing.onResult(null); return; } offlinePageBridge.getPageByOfflineId(offlineId, prepareForSharing); } }; } /** * If file path of offline page is not null, do file operations needed for the page to be * shared. Otherwise, only share the online url. * @param shareDirectly Whether it should share directly with the activity that was most * recently used to share. * @param saveLastUsed Whether to save the chosen activity for future direct sharing. * @param activity Activity that is used to access package manager * @param title Title of the page. * @param text Text to be shared. If both |text| and |url| are supplied, they are concatenated * with a space. * @param onlineUrl Online URL associated with the offline page that is used to access the * offline page file path. * @param screenshotUri Screenshot of the page to be shared. * @param callback Optional callback to be called when user makes a choice. Will not be called * if receiving a response when the user makes a choice is not supported (on * older Android versions). * @param filePath File path of the offline page. */ private static void prepareFileAndShare(final boolean shareDirectly, final boolean saveLastUsed, final Activity activity, final String title, final String text, final String onlineUrl, final Uri screenshotUri, final ShareHelper.TargetChosenCallback callback, final String filePath) { new AsyncTask<Void, Void, File>() { @Override protected File doInBackground(Void... params) { if (filePath == null) return null; File offlinePageOriginal = new File(filePath); File shareableDir = getDirectoryForOfflineSharing(activity); if (shareableDir == null) { Log.e(TAG, "Unable to create subdirectory in shareable directory"); return null; } String fileName = rewriteOfflineFileName(offlinePageOriginal.getName()); File offlinePageShareable = new File(shareableDir, fileName); if (offlinePageShareable.exists()) { try { // Old shareable files are stored in an external directory, which may cause // problems when: // 1. Files been changed by external sources. // 2. Difference in file size that results in partial overwrite. // Thus the file is deleted before we make a new copy. offlinePageShareable.delete(); } catch (SecurityException e) { Log.e(TAG, "Failed to delete: " + offlinePageOriginal.getName(), e); return null; } } if (copyToShareableLocation(offlinePageOriginal, offlinePageShareable)) { return offlinePageShareable; } return null; } @Override protected void onPostExecute(File offlinePageShareable) { Uri offlineUri = null; if (offlinePageShareable != null) { offlineUri = Uri.fromFile(offlinePageShareable); } ShareHelper.share(shareDirectly, saveLastUsed, activity, title, text, onlineUrl, offlineUri, screenshotUri, callback); } }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } /** * Copies the file from internal storage to a sharable directory. * @param src The original file to be copied. * @param dst The destination file. */ @VisibleForTesting static boolean copyToShareableLocation(File src, File dst) { FileInputStream inputStream = null; FileOutputStream outputStream = null; try { inputStream = new FileInputStream(src); outputStream = new FileOutputStream(dst); FileChannel inChannel = inputStream.getChannel(); FileChannel outChannel = outputStream.getChannel(); inChannel.transferTo(0, inChannel.size(), outChannel); } catch (IOException e) { Log.e(TAG, "Failed to copy the file: " + src.getName(), e); return false; } finally { StreamUtil.closeQuietly(inputStream); StreamUtil.closeQuietly(outputStream); } return true; } /** * Gets the directory to use for sharing offline pages, creating it if necessary. * @param context Context that is used to access external cache directory. * @return Path to the directory where shared files are stored. */ @VisibleForTesting static File getDirectoryForOfflineSharing(Context context) { if (sOfflineSharingDirectory == null) { sOfflineSharingDirectory = new File(context.getExternalCacheDir(), EXTERNAL_MHTML_FILE_PATH); } if (!sOfflineSharingDirectory.exists() && !sOfflineSharingDirectory.mkdir()) { sOfflineSharingDirectory = null; } return sOfflineSharingDirectory; } /** * Rewrite file name so that it does not contain periods except the one to separate the file * extension. * This step is used to ensure that file name can be recognized by intent filter (.*\\.mhtml") * as Android's path pattern only matches the first dot that appears in a file path. * @pram fileName Name of the offline page file. */ @VisibleForTesting static String rewriteOfflineFileName(String fileName) { fileName = fileName.replaceAll("\\s+", ""); return fileName.replaceAll("\\.(?=.*\\.)", "_"); } /** * Clears all shared mhtml files. * @param context Context that is used to access external cache directory. */ public static void clearSharedOfflineFiles(final Context context) { if (!OfflinePageBridge.isPageSharingEnabled()) return; new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { File offlinePath = getDirectoryForOfflineSharing(context); if (offlinePath != null) { FileUtils.recursivelyDeleteFile(offlinePath); } return null; } }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } /** * Retrieves the extra request header to reload the offline page. * @param tab The current tab. * @return The extra request header string. */ public static String getOfflinePageHeaderForReload(Tab tab) { OfflinePageBridge offlinePageBridge = getInstance().getOfflinePageBridge(tab.getProfile()); if (offlinePageBridge == null) return ""; return offlinePageBridge.getOfflinePageHeaderForReload(tab.getWebContents()); } private static boolean isPowerConnected(Intent batteryStatus) { int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); boolean isConnected = (status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL); Log.d(TAG, "Power connected is " + isConnected); return isConnected; } private static int batteryPercentage(Intent batteryStatus) { int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); if (scale == 0) return 0; int percentage = Math.round(100 * level / (float) scale); Log.d(TAG, "Battery Percentage is " + percentage); return percentage; } protected OfflinePageBridge getOfflinePageBridge(Profile profile) { return OfflinePageBridge.getForProfile(profile); } /** Returns the current device conditions. May be overridden for testing. */ protected DeviceConditions getDeviceConditionsImpl(Context context) { IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); // Note this is a sticky intent, so we aren't really registering a receiver, just getting // the sticky intent. That means that we don't need to unregister the filter later. Intent batteryStatus = context.registerReceiver(null, filter); if (batteryStatus == null) return null; return new DeviceConditions(isPowerConnected(batteryStatus), batteryPercentage(batteryStatus), NetworkChangeNotifier.getInstance().getCurrentConnectionType()); } @VisibleForTesting static void setInstanceForTesting(OfflinePageUtils instance) { sInstance = instance; } @VisibleForTesting public static void setSnackbarDurationForTesting(int durationMs) { sSnackbarDurationMs = durationMs; } }