// 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.download; import android.annotation.TargetApi; import android.app.DownloadManager; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.drawable.shapes.OvalShape; import android.os.Binder; import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.util.Pair; import org.chromium.base.ApiCompatibilityUtils; import org.chromium.base.ApplicationStatus; import org.chromium.base.BuildInfo; import org.chromium.base.ContextUtils; import org.chromium.base.Log; import org.chromium.base.ObserverList; import org.chromium.base.VisibleForTesting; import org.chromium.base.library_loader.LibraryProcessType; import org.chromium.base.library_loader.ProcessInitException; import org.chromium.base.metrics.RecordHistogram; import org.chromium.chrome.R; import org.chromium.chrome.browser.AppHooks; import org.chromium.chrome.browser.ChromeApplication; import org.chromium.chrome.browser.download.items.OfflineContentAggregatorNotificationBridgeUiFactory; import org.chromium.chrome.browser.init.BrowserParts; import org.chromium.chrome.browser.init.ChromeBrowserInitializer; import org.chromium.chrome.browser.init.EmptyBrowserParts; import org.chromium.chrome.browser.notifications.ChromeNotificationBuilder; import org.chromium.chrome.browser.notifications.NotificationBuilderFactory; import org.chromium.chrome.browser.notifications.NotificationConstants; import org.chromium.chrome.browser.notifications.NotificationUmaTracker; import org.chromium.chrome.browser.notifications.channels.ChannelDefinitions; import org.chromium.chrome.browser.offlinepages.downloads.OfflinePageDownloadBridge; import org.chromium.chrome.browser.profiles.Profile; import org.chromium.chrome.browser.util.IntentUtils; import org.chromium.components.offline_items_collection.ContentId; import org.chromium.components.offline_items_collection.LegacyHelpers; import org.chromium.components.offline_items_collection.OfflineItem.Progress; import org.chromium.content.browser.BrowserStartupController; import java.util.ArrayList; import java.util.List; /** * Service responsible for creating and updating download notifications even after * Chrome gets killed. * * On O and above, this service will receive {@link Service#startForeground(int, Notification)} * calls when containing active downloads. The foreground notification will be the summary * notification generated by {@link DownloadNotificationService#buildSummaryNotification(Context)}. * The service will receive a {@link Service#stopForeground(boolean)} call when all active downloads * are paused. The summary notification will be hidden when there are no other notifications in the * {@link NotificationConstants#GROUP_DOWNLOADS} group. This gets checked after every notification * gets removed from the {@link NotificationManager}. */ public class DownloadNotificationService extends Service { static final String EXTRA_DOWNLOAD_CONTENTID_ID = "org.chromium.chrome.browser.download.DownloadContentId_Id"; static final String EXTRA_DOWNLOAD_CONTENTID_NAMESPACE = "org.chromium.chrome.browser.download.DownloadContentId_Namespace"; static final String EXTRA_DOWNLOAD_FILE_PATH = "DownloadFilePath"; static final String EXTRA_NOTIFICATION_DISMISSED = "NotificationDismissed"; static final String EXTRA_IS_SUPPORTED_MIME_TYPE = "IsSupportedMimeType"; static final String EXTRA_IS_OFF_THE_RECORD = "org.chromium.chrome.browser.download.IS_OFF_THE_RECORD"; public static final String ACTION_DOWNLOAD_CANCEL = "org.chromium.chrome.browser.download.DOWNLOAD_CANCEL"; public static final String ACTION_DOWNLOAD_PAUSE = "org.chromium.chrome.browser.download.DOWNLOAD_PAUSE"; public static final String ACTION_DOWNLOAD_RESUME = "org.chromium.chrome.browser.download.DOWNLOAD_RESUME"; static final String ACTION_DOWNLOAD_RESUME_ALL = "org.chromium.chrome.browser.download.DOWNLOAD_RESUME_ALL"; public static final String ACTION_DOWNLOAD_OPEN = "org.chromium.chrome.browser.download.DOWNLOAD_OPEN"; public static final String ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON = "org.chromium.chrome.browser.download.DOWNLOAD_UPDATE_SUMMARY_ICON"; public static final String ACTION_DOWNLOAD_FAIL_SAFE = "org.chromium.chrome.browser.download.ACTION_SUMMARY_FAIL_SAFE"; static final String NOTIFICATION_NAMESPACE = "DownloadNotificationService"; private static final String TAG = "DownloadNotification"; // Limit file name to 25 characters. TODO(qinmin): use different limit for different devices? private static final int MAX_FILE_NAME_LENGTH = 25; /** Notification Id starting value, to avoid conflicts from IDs used in prior versions. */ private static final String EXTRA_NOTIFICATION_BUNDLE_ICON_ID = "Chrome.NotificationBundleIconIdExtra"; private static final int STARTING_NOTIFICATION_ID = 1000000; private static final int MAX_RESUMPTION_ATTEMPT_LEFT = 5; private static final String KEY_AUTO_RESUMPTION_ATTEMPT_LEFT = "ResumptionAttemptLeft"; private static final String KEY_NEXT_DOWNLOAD_NOTIFICATION_ID = "NextDownloadNotificationId"; /** * An Observer interface that allows other classes to know when this class is canceling * downloads. */ public interface Observer { /** * Called when a download was canceled from the notification. The implementer is not * responsible for canceling the actual download (that should be triggered internally from * this class). The implementer is responsible for using this to do their own tracking * related to which downloads might be active in this service. File downloads don't trigger * a cancel event when they are told to cancel downloads, so classes might have no idea that * a download stopped otherwise. * @param id The {@link ContentId} of the download that was canceled. */ void onDownloadCanceled(ContentId id); } private final ObserverList<Observer> mObservers = new ObserverList<>(); private final IBinder mBinder = new LocalBinder(); private final List<ContentId> mDownloadsInProgress = new ArrayList<ContentId>(); private NotificationManager mNotificationManager; private SharedPreferences mSharedPrefs; private Context mContext; private int mNextNotificationId; private int mNumAutoResumptionAttemptLeft; private Bitmap mDownloadSuccessLargeIcon; private DownloadSharedPreferenceHelper mDownloadSharedPreferenceHelper; /** * @return Whether or not this service should be made a foreground service if there are active * downloads. */ @VisibleForTesting static boolean useForegroundService() { return BuildInfo.isAtLeastO(); } /** * Checks to see if the summary notification is alone and, if so, hides it. If the summary * notification thinks it's in the foreground, this will start the service with the goal of * shutting it down. That is because if the service is in the foreground it's not possible to * stop it through the notification manager. * @param removedNotificationId The id of the notification that was just removed or {@code -1} * if this does not apply. */ @TargetApi(Build.VERSION_CODES.M) public static void hideDanglingSummaryNotification(Context context, int removedNotificationId) { if (!useForegroundService()) return; NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); if (hasDownloadNotifications(manager, removedNotificationId)) return; StatusBarNotification summary = getSummaryNotification(manager); if (summary == null) return; boolean isForeground = (summary.getNotification().flags & Notification.FLAG_FOREGROUND_SERVICE) != 0; if (BrowserStartupController.get(LibraryProcessType.PROCESS_BROWSER) .isStartupSuccessfullyCompleted()) { RecordHistogram.recordBooleanHistogram( "MobileDownload.Notification.FixingSummaryLeak", isForeground); } if (isForeground) { // If it is a foreground notification, we are in a bad state. We don't have any // other download notifications, but we can't close the summary. Try to start // up the service and quit through that path? startDownloadNotificationService(context, new Intent(ACTION_DOWNLOAD_FAIL_SAFE)); } else { manager.cancel(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY); } } /** * Start this service with a summary {@link Notification}. This will start the service in the * foreground. * @param context The context used to build the notification and to start the service. * @param source The {@link Intent} that should be used to build on to start the service. */ public static void startDownloadNotificationService(Context context, Intent source) { Intent intent = source != null ? new Intent(source) : new Intent(); intent.setComponent(new ComponentName(context, DownloadNotificationService.class)); if (useForegroundService()) { NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); // Attempt to update the notification summary icon without starting the service. if (ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON.equals(intent.getAction())) { // updateSummaryIcon should be a noop if the notification isn't showing or if the // icon won't change anyway. updateSummaryIcon(context, manager, -1, null); return; } AppHooks.get().startForegroundService(intent); } else { context.startService(intent); } } /** * Updates the notification summary with a new icon, if necessary. * @param removedNotificationId The id of a notification that is currently closing and should be * ignored. -1 if no notifications are being closed. * @param addedNotification A {@link Pair} of <id, Notification> of a notification that is * currently being added and should be used in addition to or in * place of the existing icons. */ private static void updateSummaryIcon(Context context, NotificationManager manager, int removedNotificationId, Pair<Integer, Notification> addedNotification) { if (!useForegroundService()) return; Pair<Boolean, Integer> icon = getSummaryIcon(context, manager, removedNotificationId, addedNotification); if (!icon.first || !hasDownloadNotifications(manager, removedNotificationId)) return; manager.notify(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY, buildSummaryNotificationWithIcon(context, icon.second)); } /** * Returns whether or not there are any download notifications showing that aren't the summary * notification. * @param notificationIdToIgnore If not -1, the id of a notification to ignore and * assume is closing or about to be closed. * @return Whether or not there are valid download notifications currently visible. */ @TargetApi(Build.VERSION_CODES.M) private static boolean hasDownloadNotifications( NotificationManager manager, int notificationIdToIgnore) { if (!useForegroundService()) return false; StatusBarNotification[] notifications = manager.getActiveNotifications(); for (StatusBarNotification notification : notifications) { boolean isDownloadsGroup = TextUtils.equals(notification.getNotification().getGroup(), NotificationConstants.GROUP_DOWNLOADS); boolean isSummaryNotification = notification.getId() == NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY; boolean isIgnoredNotification = notificationIdToIgnore != -1 && notificationIdToIgnore == notification.getId(); if (isDownloadsGroup && !isSummaryNotification && !isIgnoredNotification) return true; } return false; } /** * Calculates the suggested icon for the summary notification based on the other notifications * currently showing. * @param context A context to use to query Android-specific information (NotificationManager). * @param removedNotificationId The id of a notification that is currently closing and should be * ignored. -1 if no notifications are being closed. * @param addedNotification A {@link Pair} of <id, Notification> of a notification that is * currently being added and should be used in addition to or in * place of the existing icons. * @return A {@link Pair} that represents both whether or not the new icon * is different from the old one and the icon id itself. */ @TargetApi(Build.VERSION_CODES.M) private static Pair<Boolean, Integer> getSummaryIcon(Context context, NotificationManager manager, int removedNotificationId, Pair<Integer, Notification> addedNotification) { if (!useForegroundService()) return new Pair<Boolean, Integer>(false, -1); boolean progress = false; boolean paused = false; boolean pending = false; boolean completed = false; boolean failed = false; final int progressIcon = android.R.drawable.stat_sys_download; final int pausedIcon = R.drawable.ic_download_pause; final int pendingIcon = R.drawable.ic_download_pending; final int completedIcon = R.drawable.offline_pin; final int failedIcon = android.R.drawable.stat_sys_download_done; StatusBarNotification[] notifications = manager.getActiveNotifications(); int oldIcon = -1; for (StatusBarNotification notification : notifications) { boolean isDownloadsGroup = TextUtils.equals(notification.getNotification().getGroup(), NotificationConstants.GROUP_DOWNLOADS); if (!isDownloadsGroup) continue; if (notification.getId() == removedNotificationId) continue; boolean isSummaryNotification = notification.getId() == NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY; if (addedNotification != null && addedNotification.first == notification.getId()) { continue; } int icon = notification.getNotification().extras.getInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID); if (isSummaryNotification) { oldIcon = icon; continue; } progress |= icon == progressIcon; paused |= icon == pausedIcon; pending |= icon == pendingIcon; completed |= icon == completedIcon; failed |= icon == failedIcon; } if (addedNotification != null) { int icon = addedNotification.second.extras.getInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID); progress |= icon == progressIcon; paused |= icon == pausedIcon; pending |= icon == pendingIcon; completed |= icon == completedIcon; failed |= icon == failedIcon; } int newIcon = android.R.drawable.stat_sys_download_done; if (progress) { newIcon = android.R.drawable.stat_sys_download; } else if (pending) { newIcon = R.drawable.ic_download_pending; } else if (failed) { newIcon = android.R.drawable.stat_sys_download_done; } else if (paused) { newIcon = R.drawable.ic_download_pause; } else if (completed) { newIcon = R.drawable.offline_pin; } return new Pair<Boolean, Integer>(newIcon != oldIcon, newIcon); } /** * Builds a summary notification that represents all downloads. * {@see #buildSummaryNotification(Context)}. * @param context A context used to query Android strings and resources. * @param iconId The id of an icon to use for the notification. * @return a {@link Notification} that represents the summary icon for all downloads. */ private static Notification buildSummaryNotificationWithIcon(Context context, int iconId) { ChromeNotificationBuilder builder = NotificationBuilderFactory .createChromeNotificationBuilder( true /* preferCompat */, ChannelDefinitions.CHANNEL_ID_DOWNLOADS) .setContentTitle( context.getString(R.string.download_notification_summary_title)) .setSubText(context.getString(R.string.menu_downloads)) .setSmallIcon(iconId) .setLocalOnly(true) .setGroup(NotificationConstants.GROUP_DOWNLOADS) .setGroupSummary(true); Bundle extras = new Bundle(); extras.putInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID, iconId); builder.addExtras(extras); // This notification should not actually be shown. But if it is, set the click intent to // open downloads home. // TODO(dtrainor): Only do this if we have no transient downloads. Intent downloadHomeIntent = buildActionIntent( context, DownloadManager.ACTION_NOTIFICATION_CLICKED, null, false); builder.setContentIntent(PendingIntent.getBroadcast(context, NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY, downloadHomeIntent, PendingIntent.FLAG_UPDATE_CURRENT)); return builder.build(); } /** * Builds a summary notification that represents downloads. This is the notification passed to * {@link #startForeground(int, Notification)}, which keeps this service in the foreground. * @param context The context used to build the notification and pull specific resources. * @return The {@link Notification} to show for the summary. Meant to be used by * {@link NotificationManager#notify(int, Notification)}. */ private static Notification buildSummaryNotification( Context context, NotificationManager manager) { Pair<Boolean, Integer> icon = getSummaryIcon(context, manager, -1, null); return buildSummaryNotificationWithIcon(context, icon.second); } /** * @return Whether or not there are any current resumable downloads being tracked. These * tracked downloads may not currently be showing notifications. */ public static boolean isTrackingResumableDownloads(Context context) { List<DownloadSharedPreferenceEntry> entries = DownloadSharedPreferenceHelper.getInstance().getEntries(); for (DownloadSharedPreferenceEntry entry : entries) { if (canResumeDownload(context, entry)) return true; } return false; } /** * Class for clients to access. */ public class LocalBinder extends Binder { DownloadNotificationService getService() { return DownloadNotificationService.this; } } @Override public void onTaskRemoved(Intent rootIntent) { super.onTaskRemoved(rootIntent); // If we've lost all Activities, cancel the off the record downloads and validate that we // should still be showing any download notifications at all. if (ApplicationStatus.isEveryActivityDestroyed()) { cancelOffTheRecordDownloads(); hideSummaryNotificationIfNecessary(-1); } } @Override public void onCreate() { mContext = ContextUtils.getApplicationContext(); mNotificationManager = (NotificationManager) mContext.getSystemService( Context.NOTIFICATION_SERVICE); mSharedPrefs = ContextUtils.getAppSharedPreferences(); mNumAutoResumptionAttemptLeft = mSharedPrefs.getInt(KEY_AUTO_RESUMPTION_ATTEMPT_LEFT, MAX_RESUMPTION_ATTEMPT_LEFT); mDownloadSharedPreferenceHelper = DownloadSharedPreferenceHelper.getInstance(); mNextNotificationId = mSharedPrefs.getInt( KEY_NEXT_DOWNLOAD_NOTIFICATION_ID, STARTING_NOTIFICATION_ID); } @Override public void onDestroy() { updateNotificationsForShutdown(); rescheduleDownloads(); super.onDestroy(); } @Override public int onStartCommand(final Intent intent, int flags, int startId) { // Start a foreground service every time we process a valid intent. This makes sure we // honor the promise that we'll be in the foreground when we start, even if we immediately // drop ourselves back. if (useForegroundService() && intent != null) startForegroundInternal(); if (intent == null) { // Intent is only null during a process restart because of returning START_STICKY. In // this case cancel the off the record notifications and put the normal notifications // into a pending state, then try to restart. Finally validate that we are actually // showing something. updateNotificationsForShutdown(); handleDownloadOperation( new Intent(DownloadNotificationService.ACTION_DOWNLOAD_RESUME_ALL)); hideSummaryNotificationIfNecessary(-1); } else if (TextUtils.equals(intent.getAction(), DownloadNotificationService.ACTION_DOWNLOAD_FAIL_SAFE)) { hideSummaryNotificationIfNecessary(-1); } else if (isDownloadOperationIntent(intent)) { handleDownloadOperation(intent); DownloadResumptionScheduler.getDownloadResumptionScheduler(mContext).cancelTask(); // Limit the number of auto resumption attempts in case Chrome falls into a vicious // cycle. if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) { if (mNumAutoResumptionAttemptLeft > 0) { mNumAutoResumptionAttemptLeft--; updateResumptionAttemptLeft(); } } else { // Reset number of attempts left if the action is triggered by user. mNumAutoResumptionAttemptLeft = MAX_RESUMPTION_ATTEMPT_LEFT; clearResumptionAttemptLeft(); } } // This should restart the service after Chrome gets killed. However, this // doesn't work on Android 4.4.2. return START_STICKY; } /** * Adds an {@link Observer}, which will be notified when this service attempts to * start stopping itself. */ public void addObserver(Observer observer) { mObservers.addObserver(observer); } /** * Removes {@code observer}, which will no longer be notified when this class decides to start * stopping itself. */ public void removeObserver(Observer observer) { mObservers.removeObserver(observer); } /** * On >= O Android releases, puts this service into a background state. * @param killNotification Whether or not this call should kill the summary notification or not. * Not killing it puts the service into the background, but leaves the * download notifications visible. */ @VisibleForTesting @TargetApi(Build.VERSION_CODES.N) void stopForegroundInternal(boolean killNotification) { if (!useForegroundService()) return; stopForeground(killNotification ? STOP_FOREGROUND_REMOVE : STOP_FOREGROUND_DETACH); } /** * On >= O Android releases, puts this service into a foreground state, binding it to the * {@link Notification} generated by {@link #buildSummaryNotification(Context)}. */ @VisibleForTesting void startForegroundInternal() { if (!useForegroundService()) return; Notification notification = buildSummaryNotification(getApplicationContext(), mNotificationManager); startForeground(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY, notification); } @VisibleForTesting boolean hasDownloadNotificationsInternal(int notificationIdToIgnore) { return hasDownloadNotifications(mNotificationManager, notificationIdToIgnore); } @VisibleForTesting void updateSummaryIconInternal( int removedNotificationId, Pair<Integer, Notification> addedNotification) { updateSummaryIcon(mContext, mNotificationManager, removedNotificationId, addedNotification); } private void rescheduleDownloads() { List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries(); if (entries.isEmpty()) return; boolean scheduleAutoResumption = false; boolean allowMeteredConnection = false; for (int i = 0; i < entries.size(); ++i) { DownloadSharedPreferenceEntry entry = entries.get(i); if (entry.isAutoResumable) { scheduleAutoResumption = true; if (entry.canDownloadWhileMetered) { allowMeteredConnection = true; break; } } } if (scheduleAutoResumption && mNumAutoResumptionAttemptLeft > 0) { DownloadResumptionScheduler.getDownloadResumptionScheduler(mContext).schedule( allowMeteredConnection); } } @VisibleForTesting void updateNotificationsForShutdown() { cancelOffTheRecordDownloads(); List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries(); for (DownloadSharedPreferenceEntry entry : entries) { if (entry.isOffTheRecord) continue; // Move all regular downloads to pending. Don't propagate the pause because // if native is still working and it triggers an update, then the service will be // restarted. notifyDownloadPaused(entry.id, entry.fileName, !entry.isOffTheRecord, true, entry.isOffTheRecord, entry.isTransient, null); } } @VisibleForTesting void cancelOffTheRecordDownloads() { boolean cancelActualDownload = BrowserStartupController.get(LibraryProcessType.PROCESS_BROWSER) .isStartupSuccessfullyCompleted() && Profile.getLastUsedProfile().hasOffTheRecordProfile(); List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries(); List<DownloadSharedPreferenceEntry> copies = new ArrayList<DownloadSharedPreferenceEntry>(entries); for (DownloadSharedPreferenceEntry entry : copies) { if (!entry.isOffTheRecord) continue; ContentId id = entry.id; notifyDownloadCanceled(id); if (cancelActualDownload) { DownloadServiceDelegate delegate = getServiceDelegate(id); delegate.cancelDownload(id, true); delegate.destroyServiceDelegate(); } for (Observer observer : mObservers) observer.onDownloadCanceled(id); } } /** * Track in-progress downloads here and, if on an Android version >= O, make * this a foreground service. * @param id The {@link ContentId} of the download that has been started and should be tracked. */ private void startTrackingInProgressDownload(ContentId id) { if (mDownloadsInProgress.size() == 0) startForegroundInternal(); if (!mDownloadsInProgress.contains(id)) mDownloadsInProgress.add(id); } /** * Stop tracking the download represented by {@code id}. If on an Android version >= O, stop * making this a foreground service. * @param id The {@link ContentId} of the download that has been paused or * canceled and shouldn't be tracked. * @param allowStopForeground Whether or not this should check internal state and stop the * foreground notification from showing. This could be false if we * plan on removing the notification in the near future. We don't * want to just detach here, because that will put us in a * potentially bad state where we cannot dismiss the notification. */ private void stopTrackingInProgressDownload(ContentId id, boolean allowStopForeground) { mDownloadsInProgress.remove(id); if (allowStopForeground && mDownloadsInProgress.size() == 0) stopForegroundInternal(false); } /** * @return The summary {@link StatusBarNotification} if one is showing. */ @TargetApi(Build.VERSION_CODES.M) private static StatusBarNotification getSummaryNotification(NotificationManager manager) { if (!useForegroundService()) return null; StatusBarNotification[] notifications = manager.getActiveNotifications(); for (StatusBarNotification notification : notifications) { boolean isDownloadsGroup = TextUtils.equals(notification.getNotification().getGroup(), NotificationConstants.GROUP_DOWNLOADS); boolean isSummaryNotification = notification.getId() == NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY; if (isDownloadsGroup && isSummaryNotification) return notification; } return null; } /** * Cancels the existing summary notification. Moved to a helper method for test mocking. */ @VisibleForTesting void cancelSummaryNotification() { mNotificationManager.cancel(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY); } /** * Check all current notifications and hide the summary notification if we have no downloads * notifications left. On Android if the user swipes away the last download notification the * summary will be dismissed. But if the last downloads notification is dismissed via * {@link NotificationManager#cancel(int)}, the summary will remain, so we need to check and * manually remove it ourselves. * @param notificationIdToIgnore Canceling a notification and querying for the current list of * active notifications isn't synchronous. Pass a notification id * here if there is a notification that should be assumed gone. * Or pass -1 if no notification fits that criteria. */ @TargetApi(Build.VERSION_CODES.M) boolean hideSummaryNotificationIfNecessary(int notificationIdToIgnore) { if (!useForegroundService()) return false; if (mDownloadsInProgress.size() > 0) return false; if (hasDownloadNotificationsInternal(notificationIdToIgnore)) return false; StatusBarNotification notification = getSummaryNotification(mNotificationManager); if (notification != null) { // We have a valid summary notification, but how we dismiss it depends on whether or not // it is currently bound to this service via startForeground(...). if ((notification.getNotification().flags & Notification.FLAG_FOREGROUND_SERVICE) != 0) { // If we are a foreground service and we are hiding the notification, we have no // other downloads notifications showing, so we need to remove the notification and // unregister it from this service at the same time. stopForegroundInternal(true); } else { // If we are not a foreground service, remove the notification via the // NotificationManager. The notification is not bound to this service, so any call // to stopForeground() won't affect the notification. cancelSummaryNotification(); } } else { // If we don't have a valid summary, just guarantee that we aren't in the foreground for // safety. Still try to remove the summary notification to make sure it's gone. This // is because querying for it might fail if we have just recently started up and began // showing it. This might leave us in a bad state if the cancel request fails inside // the framework. // TODO(dtrainor): Add a way to attempt to automatically clean up the notification // shortly after this. stopForegroundInternal(true); } // Stop the service which should start the destruction process. At this point we should be // a background service. We might not be unbound from any clients. When they unbind we // will shut down. That is okay because they will only unbind from us when they are ok with // us going away (e.g. we shouldn't be unbound while in the foreground). stopSelf(); return true; } @Override public IBinder onBind(Intent intent) { return mBinder; } /** * Helper method to update the remaining number of background resumption attempts left. */ private void updateResumptionAttemptLeft() { SharedPreferences.Editor editor = mSharedPrefs.edit(); editor.putInt(KEY_AUTO_RESUMPTION_ATTEMPT_LEFT, mNumAutoResumptionAttemptLeft); editor.apply(); } /** * Helper method to clear the remaining number of background resumption attempts left. */ static void clearResumptionAttemptLeft() { SharedPreferences SharedPrefs = ContextUtils.getAppSharedPreferences(); SharedPreferences.Editor editor = SharedPrefs.edit(); editor.remove(KEY_AUTO_RESUMPTION_ATTEMPT_LEFT); editor.apply(); } /** * Adds or updates an in-progress download notification. * @param id The {@link ContentId} of the download. * @param fileName File name of the download. * @param progress The current download progress. * @param bytesReceived Total number of bytes received. * @param timeRemainingInMillis Remaining download time in milliseconds. * @param startTime Time when download started. * @param isOffTheRecord Whether the download is off the record. * @param canDownloadWhileMetered Whether the download can happen in metered network. * @param isTransient Whether or not clicking on the download should launch * downloads home. * @param icon A {@link Bitmap} to be used as the large icon for display. */ @VisibleForTesting public void notifyDownloadProgress(ContentId id, String fileName, Progress progress, long bytesReceived, long timeRemainingInMillis, long startTime, boolean isOffTheRecord, boolean canDownloadWhileMetered, boolean isTransient, Bitmap icon) { updateActiveDownloadNotification(id, fileName, progress, bytesReceived, timeRemainingInMillis, startTime, isOffTheRecord, canDownloadWhileMetered, false, isTransient, icon); } /** * Adds or updates a pending download notification. * @param id The {@link ContentId} of the download. * @param fileName File name of the download. * @param isOffTheRecord Whether the download is off the record. * @param canDownloadWhileMetered Whether the download can happen in metered network. * @param isTransient Whether or not clicking on the download should launch * downloads home. * @param icon A {@link Bitmap} to be used as the large icon for display. */ private void notifyDownloadPending(ContentId id, String fileName, boolean isOffTheRecord, boolean canDownloadWhileMetered, boolean isTransient, Bitmap icon) { updateActiveDownloadNotification(id, fileName, Progress.createIndeterminateProgress(), 0, 0, 0, isOffTheRecord, canDownloadWhileMetered, true, isTransient, icon); } /** * Helper method to update the notification for an active download, the download is either in * progress or pending. * @param id The {@link ContentId} of the download. * @param fileName File name of the download. * @param progress The current download progress. * @param bytesReceived Total number of bytes received. * @param timeRemainingInMillis Remaining download time in milliseconds or -1 if it is * unknown. * @param startTime Time when download started. * @param isOffTheRecord Whether the download is off the record. * @param canDownloadWhileMetered Whether the download can happen in metered network. * @param isDownloadPending Whether the download is pending. * @param isTransient Whether or not clicking on the download should launch * downloads home. * @param icon A {@link Bitmap} to be used as the large icon for display. */ private void updateActiveDownloadNotification(ContentId id, String fileName, Progress progress, long bytesReceived, long timeRemainingInMillis, long startTime, boolean isOffTheRecord, boolean canDownloadWhileMetered, boolean isDownloadPending, boolean isTransient, Bitmap icon) { boolean indeterminate = (progress.isIndeterminate() || isDownloadPending); String contentText = null; if (isDownloadPending) { contentText = mContext.getResources().getString(R.string.download_notification_pending); } else if (indeterminate || timeRemainingInMillis < 0) { // TODO(dimich): Enable the byte count back in M59. See bug 704049 for more info and // details of what was temporarily reverted (for M58). contentText = mContext.getResources().getString(R.string.download_started); } else { contentText = DownloadUtils.getTimeOrFilesLeftString( mContext, progress, timeRemainingInMillis); } int resId = isDownloadPending ? R.drawable.ic_download_pending : android.R.drawable.stat_sys_download; ChromeNotificationBuilder builder = buildNotification(resId, fileName, contentText); builder.setOngoing(true); builder.setPriority(Notification.PRIORITY_HIGH); // Avoid animations while the download isn't progressing. if (!isDownloadPending) { builder.setProgress(100, indeterminate ? -1 : progress.getPercentage(), indeterminate); } if (!indeterminate && !LegacyHelpers.isLegacyOfflinePage(id)) { String percentText = DownloadUtils.getPercentageString(progress.getPercentage()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { builder.setSubText(percentText); } else { builder.setContentInfo(percentText); } } int notificationId = getNotificationId(id); if (startTime > 0) builder.setWhen(startTime); if (!isTransient) { // Clicking on an in-progress download sends the user to see all their downloads. Intent downloadHomeIntent = buildActionIntent( mContext, DownloadManager.ACTION_NOTIFICATION_CLICKED, null, isOffTheRecord); builder.setContentIntent(PendingIntent.getBroadcast(mContext, notificationId, downloadHomeIntent, PendingIntent.FLAG_UPDATE_CURRENT)); } builder.setAutoCancel(false); if (icon != null) builder.setLargeIcon(icon); Intent pauseIntent = buildActionIntent(mContext, ACTION_DOWNLOAD_PAUSE, id, isOffTheRecord); builder.addAction(R.drawable.ic_pause_white_24dp, mContext.getResources().getString(R.string.download_notification_pause_button), buildPendingIntent(pauseIntent, notificationId)); Intent cancelIntent = buildActionIntent(mContext, ACTION_DOWNLOAD_CANCEL, id, isOffTheRecord); builder.addAction(R.drawable.btn_close_white, mContext.getResources().getString(R.string.download_notification_cancel_button), buildPendingIntent(cancelIntent, notificationId)); updateNotification(notificationId, builder.build(), id, new DownloadSharedPreferenceEntry(id, notificationId, isOffTheRecord, canDownloadWhileMetered, fileName, true, isTransient)); startTrackingInProgressDownload(id); } /** * Removes a download notification and all associated tracking. This method relies on the * caller to provide the notification id, which is useful in the case where the internal * tracking doesn't exist (e.g. in the case of a successful download, where we show the download * completed notification and remove our internal state tracking). * @param notificationId Notification ID of the download * @param id The {@link ContentId} of the download. */ public void cancelNotification(int notificationId, ContentId id) { mNotificationManager.cancel(NOTIFICATION_NAMESPACE, notificationId); mDownloadSharedPreferenceHelper.removeSharedPreferenceEntry(id); // Since we are about to go through the process of validating whether or not we can shut // down, don't stop foreground if we have no download notifications left to show. Hiding // the summary will take care of that for us. stopTrackingInProgressDownload(id, hasDownloadNotificationsInternal(notificationId)); if (!hideSummaryNotificationIfNecessary(notificationId)) { updateSummaryIcon(mContext, mNotificationManager, notificationId, null); } } /** * Called when a download is canceled. This method uses internal tracking to try to find the * notification id to cancel. * @param id The {@link ContentId} of the download. */ @VisibleForTesting public void notifyDownloadCanceled(ContentId id) { DownloadSharedPreferenceEntry entry = mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id); if (entry == null) return; cancelNotification(entry.notificationId, id); } /** * Change a download notification to paused state. * @param id The {@link ContentId} of the download. * @param fileName File name of the download. * @param isResumable Whether download can be resumed. * @param isAutoResumable Whether download is can be resumed automatically. * @param isOffTheRecord Whether the download is off the record. * @param isTransient Whether or not clicking on the download should launch downloads home. * @param icon A {@link Bitmap} to be used as the large icon for display. */ public void notifyDownloadPaused(ContentId id, String fileName, boolean isResumable, boolean isAutoResumable, boolean isOffTheRecord, boolean isTransient, Bitmap icon) { DownloadSharedPreferenceEntry entry = mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id); if (!isResumable) { notifyDownloadFailed(id, fileName, icon); return; } // If download is already paused, do nothing. if (entry != null && !entry.isAutoResumable) return; boolean canDownloadWhileMetered = entry == null ? false : entry.canDownloadWhileMetered; // If download is interrupted due to network disconnection, show download pending state. if (isAutoResumable) { notifyDownloadPending(id, fileName, isOffTheRecord, canDownloadWhileMetered, isTransient, icon); stopTrackingInProgressDownload(id, true); return; } String contentText = mContext.getResources().getString( R.string.download_notification_paused); ChromeNotificationBuilder builder = buildNotification(R.drawable.ic_download_pause, fileName, contentText); int notificationId = entry == null ? getNotificationId(id) : entry.notificationId; if (!isTransient) { // Clicking on an in-progress download sends the user to see all their downloads. Intent downloadHomeIntent = buildActionIntent( mContext, DownloadManager.ACTION_NOTIFICATION_CLICKED, null, false); builder.setContentIntent(PendingIntent.getBroadcast(mContext, notificationId, downloadHomeIntent, PendingIntent.FLAG_UPDATE_CURRENT)); } builder.setAutoCancel(false); if (icon != null) builder.setLargeIcon(icon); Intent resumeIntent = buildActionIntent(mContext, ACTION_DOWNLOAD_RESUME, id, isOffTheRecord); builder.addAction(R.drawable.ic_file_download_white_24dp, mContext.getResources().getString(R.string.download_notification_resume_button), buildPendingIntent(resumeIntent, notificationId)); Intent cancelIntent = buildActionIntent(mContext, ACTION_DOWNLOAD_CANCEL, id, isOffTheRecord); builder.addAction(R.drawable.btn_close_white, mContext.getResources().getString(R.string.download_notification_cancel_button), buildPendingIntent(cancelIntent, notificationId)); PendingIntent deleteIntent = isTransient ? buildPendingIntent(cancelIntent, notificationId) : buildSummaryIconIntent(notificationId); builder.setDeleteIntent(deleteIntent); updateNotification(notificationId, builder.build(), id, new DownloadSharedPreferenceEntry(id, notificationId, isOffTheRecord, canDownloadWhileMetered, fileName, isAutoResumable, isTransient)); stopTrackingInProgressDownload(id, true); } /** * Add a download successful notification. * @param id The {@link ContentId} of the download. * @param filePath Full path to the download. * @param fileName Filename of the download. * @param systemDownloadId Download ID assigned by system DownloadManager. * @param isSupportedMimeType Whether the MIME type can be viewed inside browser. * @param isOpenable Whether or not this download can be opened. * @param icon A {@link Bitmap} to be used as the large icon for display. * @return ID of the successful download notification. Used for removing the * notification when user click on the snackbar. */ @VisibleForTesting public int notifyDownloadSuccessful(ContentId id, String filePath, String fileName, long systemDownloadId, boolean isOffTheRecord, boolean isSupportedMimeType, boolean isOpenable, Bitmap icon) { int notificationId = getNotificationId(id); ChromeNotificationBuilder builder = buildNotification(R.drawable.offline_pin, fileName, mContext.getResources().getString(R.string.download_notification_completed)); ComponentName component = new ComponentName( mContext.getPackageName(), DownloadBroadcastReceiver.class.getName()); if (isOpenable) { Intent intent = null; if (LegacyHelpers.isLegacyDownload(id)) { intent = new Intent(DownloadManager.ACTION_NOTIFICATION_CLICKED); long[] idArray = {systemDownloadId}; intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS, idArray); intent.putExtra(EXTRA_DOWNLOAD_FILE_PATH, filePath); intent.putExtra(EXTRA_IS_SUPPORTED_MIME_TYPE, isSupportedMimeType); intent.putExtra(EXTRA_IS_OFF_THE_RECORD, isOffTheRecord); intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_ID, id.id); intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_NAMESPACE, id.namespace); intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_ID, notificationId); } else { intent = buildActionIntent(mContext, ACTION_DOWNLOAD_OPEN, id, false); } intent.setComponent(component); builder.setContentIntent(PendingIntent.getBroadcast( mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT)); } if (icon == null && mDownloadSuccessLargeIcon == null) { Bitmap bitmap = BitmapFactory.decodeResource( mContext.getResources(), R.drawable.offline_pin); mDownloadSuccessLargeIcon = getLargeNotificationIcon(bitmap); } builder.setDeleteIntent(buildSummaryIconIntent(notificationId)); builder.setLargeIcon(icon != null ? icon : mDownloadSuccessLargeIcon); updateNotification(notificationId, builder.build(), id, null); stopTrackingInProgressDownload(id, true); return notificationId; } /** * Add a download failed notification. * @param id The {@link ContentId} of the download. * @param fileName Filename of the download. * @param icon A {@link Bitmap} to be used as the large icon for display. */ @VisibleForTesting public void notifyDownloadFailed(ContentId id, String fileName, Bitmap icon) { // If the download is not in history db, fileName could be empty. Get it from // SharedPreferences. if (TextUtils.isEmpty(fileName)) { DownloadSharedPreferenceEntry entry = mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id); if (entry == null) return; fileName = entry.fileName; } int notificationId = getNotificationId(id); ChromeNotificationBuilder builder = buildNotification(android.R.drawable.stat_sys_download_done, fileName, mContext.getResources().getString(R.string.download_notification_failed)); if (icon != null) builder.setLargeIcon(icon); builder.setDeleteIntent(buildSummaryIconIntent(notificationId)); updateNotification(notificationId, builder.build(), id, null); stopTrackingInProgressDownload(id, true); } /** * Helper method to build a PendingIntent from the provided intent. * @param intent Intent to broadcast. * @param notificationId ID of the notification. */ private PendingIntent buildPendingIntent(Intent intent, int notificationId) { return PendingIntent.getBroadcast( mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); } private PendingIntent buildSummaryIconIntent(int notificationId) { Intent intent = new Intent(mContext, DownloadBroadcastReceiver.class); intent.setAction(ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON); return buildPendingIntent(intent, notificationId); } /** * Helper method to build an download action Intent from the provided information. * @param context {@link Context} to pull resources from. * @param action Download action to perform. * @param id The {@link ContentId} of the download. * @param isOffTheRecord Whether the download is incognito. */ static Intent buildActionIntent( Context context, String action, ContentId id, boolean isOffTheRecord) { ComponentName component = new ComponentName( context.getPackageName(), DownloadBroadcastReceiver.class.getName()); Intent intent = new Intent(action); intent.setComponent(component); intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_ID, id != null ? id.id : ""); intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_NAMESPACE, id != null ? id.namespace : ""); intent.putExtra(EXTRA_IS_OFF_THE_RECORD, isOffTheRecord); return intent; } /** * Builds a notification to be displayed. * @param iconId Id of the notification icon. * @param title Title of the notification. * @param contentText Notification content text to be displayed. * @return notification builder that builds the notification to be displayed */ private ChromeNotificationBuilder buildNotification( int iconId, String title, String contentText) { Bundle extras = new Bundle(); extras.putInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID, iconId); ChromeNotificationBuilder builder = NotificationBuilderFactory .createChromeNotificationBuilder( true /* preferCompat */, ChannelDefinitions.CHANNEL_ID_DOWNLOADS) .setContentTitle( DownloadUtils.getAbbreviatedFileName(title, MAX_FILE_NAME_LENGTH)) .setSmallIcon(iconId) .setLocalOnly(true) .setAutoCancel(true) .setContentText(contentText) .setGroup(NotificationConstants.GROUP_DOWNLOADS) .addExtras(extras); return builder; } private Bitmap getLargeNotificationIcon(Bitmap bitmap) { Resources resources = mContext.getResources(); int height = (int) resources.getDimension(android.R.dimen.notification_large_icon_height); int width = (int) resources.getDimension(android.R.dimen.notification_large_icon_width); final OvalShape circle = new OvalShape(); circle.resize(width, height); final Paint paint = new Paint(); paint.setColor(ApiCompatibilityUtils.getColor(resources, R.color.google_blue_grey_500)); final Bitmap result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(result); circle.draw(canvas, paint); float leftOffset = (width - bitmap.getWidth()) / 2f; float topOffset = (height - bitmap.getHeight()) / 2f; if (leftOffset >= 0 && topOffset >= 0) { canvas.drawBitmap(bitmap, leftOffset, topOffset, null); } else { // Scale down the icon into the notification icon dimensions canvas.drawBitmap(bitmap, new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()), new Rect(0, 0, width, height), null); } return result; } /** * Retrieves DownloadSharedPreferenceEntry from a download action intent. * @param intent Intent that contains the download action. */ private DownloadSharedPreferenceEntry getDownloadEntryFromIntent(Intent intent) { if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) return null; return mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry( getContentIdFromIntent(intent)); } /** * Helper method to launch the browser process and handle a download operation that is included * in the given intent. * @param intent Intent with the download operation. */ private void handleDownloadOperation(final Intent intent) { // Process updating the summary notification first. This has no impact on a specific // download. if (ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON.equals(intent.getAction())) { updateSummaryIcon(mContext, mNotificationManager, -1, null); hideSummaryNotificationIfNecessary(-1); return; } // TODO(qinmin): Figure out how to properly handle this case. final ContentId id = getContentIdFromIntent(intent); final DownloadSharedPreferenceEntry entry = getDownloadEntryFromIntent(intent); if (entry == null && !(id != null && LegacyHelpers.isLegacyOfflinePage(id) && TextUtils.equals(intent.getAction(), ACTION_DOWNLOAD_OPEN))) { handleDownloadOperationForMissingNotification(intent); hideSummaryNotificationIfNecessary(-1); return; } if (ACTION_DOWNLOAD_PAUSE.equals(intent.getAction())) { // If browser process already goes away, the download should have already paused. Do // nothing in that case. if (!DownloadManagerService.hasDownloadManagerService()) { // TODO(dtrainor): Should we spin up native to make sure we have the icon? Or maybe // build a Java cache for easy access. notifyDownloadPaused( entry.id, entry.fileName, !entry.isOffTheRecord, false, entry.isOffTheRecord, entry.isTransient, null); hideSummaryNotificationIfNecessary(-1); return; } } else if (ACTION_DOWNLOAD_RESUME.equals(intent.getAction())) { // If user manually resumes a download, update the network type if it // is not metered previously. boolean canDownloadWhileMetered = entry.canDownloadWhileMetered || DownloadManagerService.isActiveNetworkMetered(mContext); // Update the SharedPreference entry. mDownloadSharedPreferenceHelper.addOrReplaceSharedPreferenceEntry( new DownloadSharedPreferenceEntry(entry.id, entry.notificationId, entry.isOffTheRecord, canDownloadWhileMetered, entry.fileName, true, entry.isTransient)); } else if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction()) && (mDownloadSharedPreferenceHelper.getEntries().isEmpty() || DownloadManagerService.hasDownloadManagerService())) { hideSummaryNotificationIfNecessary(-1); return; } else if (ACTION_DOWNLOAD_OPEN.equals(intent.getAction())) { // TODO(fgorski): Do we even need to do anything special here, before we launch Chrome? } else if (ACTION_DOWNLOAD_CANCEL.equals(intent.getAction()) && IntentUtils.safeGetBooleanExtra(intent, EXTRA_NOTIFICATION_DISMISSED, false)) { // User canceled a download by dismissing its notification from earlier versions, ignore // it. TODO(qinmin): remove this else-if block after M60. return; } BrowserParts parts = new EmptyBrowserParts() { @Override public void finishNativeInitialization() { // Make sure the OfflineContentAggregator bridge is initialized. OfflineContentAggregatorNotificationBridgeUiFactory.instance(); DownloadServiceDelegate downloadServiceDelegate = ACTION_DOWNLOAD_OPEN.equals(intent.getAction()) ? null : getServiceDelegate(id); if (ACTION_DOWNLOAD_CANCEL.equals(intent.getAction())) { // TODO(qinmin): Alternatively, we can delete the downloaded content on // SD card, and remove the download ID from the SharedPreferences so we // don't need to restart the browser process. http://crbug.com/579643. cancelNotification(entry.notificationId, entry.id); downloadServiceDelegate.cancelDownload(entry.id, entry.isOffTheRecord); for (Observer observer : mObservers) { observer.onDownloadCanceled(entry.id); } } else if (ACTION_DOWNLOAD_PAUSE.equals(intent.getAction())) { // TODO(dtrainor): Consider hitting the delegate and rely on that to update the // state. notifyDownloadPaused(entry.id, entry.fileName, true, false, entry.isOffTheRecord, entry.isTransient, null); downloadServiceDelegate.pauseDownload(entry.id, entry.isOffTheRecord); } else if (ACTION_DOWNLOAD_RESUME.equals(intent.getAction())) { // TODO(dtrainor): Consider hitting the delegate and rely on that to update the // state. notifyDownloadPending(entry.id, entry.fileName, entry.isOffTheRecord, entry.canDownloadWhileMetered, entry.isTransient, null); downloadServiceDelegate.resumeDownload( entry.id, entry.buildDownloadItem(), true); } else if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) { assert entry == null; resumeAllPendingDownloads(); } else if (ACTION_DOWNLOAD_OPEN.equals(intent.getAction())) { ContentId id = getContentIdFromIntent(intent); if (LegacyHelpers.isLegacyOfflinePage(id)) { OfflinePageDownloadBridge.openDownloadedPage(id); } else if (id != null) { OfflineContentAggregatorNotificationBridgeUiFactory.instance().openItem(id); } } else { Log.e(TAG, "Unrecognized intent action.", intent); } if (!ACTION_DOWNLOAD_OPEN.equals(intent.getAction())) { downloadServiceDelegate.destroyServiceDelegate(); } hideSummaryNotificationIfNecessary(ACTION_DOWNLOAD_CANCEL.equals(intent.getAction()) ? entry.notificationId : -1); } }; try { ChromeBrowserInitializer.getInstance(mContext).handlePreNativeStartup(parts); ChromeBrowserInitializer.getInstance(mContext).handlePostNativeStartup(true, parts); } catch (ProcessInitException e) { Log.e(TAG, "Unable to load native library.", e); ChromeApplication.reportStartupErrorAndExit(e); } } /** * Handles operations for downloads that the DownloadNotificationService is unaware of. * * This can happen because the DownloadNotificationService learn about downloads later than * Download Home does, and may not yet have a DownloadSharedPreferenceEntry for the item. * * TODO(qinmin): Figure out how to fix the SharedPreferences so that it properly tracks entries. */ private void handleDownloadOperationForMissingNotification(Intent intent) { // This function should only be called via Download Home, but catch this case to be safe. if (!DownloadManagerService.hasDownloadManagerService()) return; String action = intent.getAction(); ContentId id = getContentIdFromIntent(intent); boolean isOffTheRecord = IntentUtils.safeGetBooleanExtra(intent, EXTRA_IS_OFF_THE_RECORD, false); if (!LegacyHelpers.isLegacyDownload(id)) return; // Pass information directly to the DownloadManagerService. if (TextUtils.equals(action, ACTION_DOWNLOAD_CANCEL)) { getServiceDelegate(id).cancelDownload(id, isOffTheRecord); } else if (TextUtils.equals(action, ACTION_DOWNLOAD_PAUSE)) { getServiceDelegate(id).pauseDownload(id, isOffTheRecord); } else if (TextUtils.equals(action, ACTION_DOWNLOAD_RESUME)) { DownloadInfo info = new DownloadInfo.Builder() .setDownloadGuid(id.id) .setIsOffTheRecord(isOffTheRecord) .build(); getServiceDelegate(id).resumeDownload(id, new DownloadItem(false, info), true); } } /** * Gets appropriate download delegate that can handle interactions with download item referred * to by the entry. * @param id The {@link ContentId} to grab the delegate for. * @return delegate for interactions with the entry */ DownloadServiceDelegate getServiceDelegate(ContentId id) { if (LegacyHelpers.isLegacyOfflinePage(id)) { return OfflinePageDownloadBridge.getDownloadServiceDelegate(); } if (LegacyHelpers.isLegacyDownload(id)) { return DownloadManagerService.getDownloadManagerService(); } return OfflineContentAggregatorNotificationBridgeUiFactory.instance(); } @VisibleForTesting void updateNotification(int id, Notification notification) { mNotificationManager.notify(NOTIFICATION_NAMESPACE, id, notification); } private void updateNotification(int notificationId, Notification notification, ContentId id, DownloadSharedPreferenceEntry entry) { updateNotification(notificationId, notification); trackNotificationUma(id); if (entry != null) { mDownloadSharedPreferenceHelper.addOrReplaceSharedPreferenceEntry(entry); } else { mDownloadSharedPreferenceHelper.removeSharedPreferenceEntry(id); } updateSummaryIcon(mContext, mNotificationManager, -1, new Pair<Integer, Notification>(notificationId, notification)); } private void trackNotificationUma(ContentId id) { // Check if we already have an entry in the DownloadSharedPreferenceHelper. This is a // reasonable indicator for whether or not a notification is already showing (or at least if // we had built one for this download before. if (mDownloadSharedPreferenceHelper.hasEntry(id)) return; NotificationUmaTracker.getInstance().onNotificationShown( LegacyHelpers.isLegacyOfflinePage(id) ? NotificationUmaTracker.DOWNLOAD_PAGES : NotificationUmaTracker.DOWNLOAD_FILES, ChannelDefinitions.CHANNEL_ID_DOWNLOADS); } /** * Checks if an intent requires operations on a download. * @param intent An intent to validate. * @return true if the intent requires actions, or false otherwise. */ static boolean isDownloadOperationIntent(Intent intent) { if (intent == null) return false; if (ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON.equals(intent.getAction())) return true; if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) return true; if (!ACTION_DOWNLOAD_CANCEL.equals(intent.getAction()) && !ACTION_DOWNLOAD_RESUME.equals(intent.getAction()) && !ACTION_DOWNLOAD_PAUSE.equals(intent.getAction()) && !ACTION_DOWNLOAD_OPEN.equals(intent.getAction())) { return false; } ContentId id = getContentIdFromIntent(intent); if (id == null) return false; return true; } private static boolean canResumeDownload(Context context, DownloadSharedPreferenceEntry entry) { if (entry == null) return false; if (!entry.isAutoResumable) return false; boolean isNetworkMetered = DownloadManagerService.isActiveNetworkMetered(context); return entry.canDownloadWhileMetered || !isNetworkMetered; } /** * @param intent The {@link Intent} to pull from and build a {@link ContentId}. * @return A {@link ContentId} built by pulling extras from {@code intent}. This will be * {@code null} if {@code intent} is missing any required extras. */ public static ContentId getContentIdFromIntent(Intent intent) { if (!intent.hasExtra(EXTRA_DOWNLOAD_CONTENTID_ID) || !intent.hasExtra(EXTRA_DOWNLOAD_CONTENTID_NAMESPACE)) { return null; } return new ContentId( IntentUtils.safeGetStringExtra(intent, EXTRA_DOWNLOAD_CONTENTID_NAMESPACE), IntentUtils.safeGetStringExtra(intent, EXTRA_DOWNLOAD_CONTENTID_ID)); } /** * Resumes all pending downloads from SharedPreferences. If a download is * already in progress, do nothing. */ public void resumeAllPendingDownloads() { if (!DownloadManagerService.hasDownloadManagerService()) return; List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries(); for (int i = 0; i < entries.size(); ++i) { DownloadSharedPreferenceEntry entry = entries.get(i); if (!canResumeDownload(mContext, entry)) continue; if (mDownloadsInProgress.contains(entry.id)) continue; notifyDownloadPending(entry.id, entry.fileName, false, entry.canDownloadWhileMetered, entry.isTransient, null); DownloadServiceDelegate downloadServiceDelegate = getServiceDelegate(entry.id); downloadServiceDelegate.resumeDownload(entry.id, entry.buildDownloadItem(), false); downloadServiceDelegate.destroyServiceDelegate(); } } /** * Return the notification ID for the given download {@link ContentId}. * @param id the {@link ContentId} of the download. * @return notification ID to be used. */ private int getNotificationId(ContentId id) { DownloadSharedPreferenceEntry entry = mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id); if (entry != null) return entry.notificationId; int notificationId = mNextNotificationId; mNextNotificationId = mNextNotificationId == Integer.MAX_VALUE ? STARTING_NOTIFICATION_ID : mNextNotificationId + 1; SharedPreferences.Editor editor = mSharedPrefs.edit(); editor.putInt(KEY_NEXT_DOWNLOAD_NOTIFICATION_ID, mNextNotificationId); editor.apply(); return notificationId; } }