chromium/chrome/android/java/src/org/chromium/chrome/browser/download/DownloadBroadcastManagerImpl.java

// Copyright 2017 The Chromium Authors
// 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 static android.app.DownloadManager.ACTION_NOTIFICATION_CLICKED;

import static org.chromium.chrome.browser.download.DownloadNotificationService.ACTION_DOWNLOAD_CANCEL;
import static org.chromium.chrome.browser.download.DownloadNotificationService.ACTION_DOWNLOAD_OPEN;
import static org.chromium.chrome.browser.download.DownloadNotificationService.ACTION_DOWNLOAD_PAUSE;
import static org.chromium.chrome.browser.download.DownloadNotificationService.ACTION_DOWNLOAD_RESUME;
import static org.chromium.chrome.browser.download.DownloadNotificationService.EXTRA_DOWNLOAD_CONTENTID_ID;
import static org.chromium.chrome.browser.download.DownloadNotificationService.EXTRA_DOWNLOAD_CONTENTID_NAMESPACE;
import static org.chromium.chrome.browser.download.DownloadNotificationService.EXTRA_IS_OFF_THE_RECORD;
import static org.chromium.chrome.browser.notifications.NotificationConstants.EXTRA_NOTIFICATION_ID;

import android.app.DownloadManager;
import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Handler;
import android.os.IBinder;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.ContentUriUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.IntentUtils;
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.profiles.OTRProfileID;
import org.chromium.components.offline_items_collection.ContentId;
import org.chromium.components.offline_items_collection.LaunchLocation;
import org.chromium.components.offline_items_collection.LegacyHelpers;
import org.chromium.components.offline_items_collection.OpenParams;
import org.chromium.components.offline_items_collection.PendingState;
import org.chromium.content_public.browser.BrowserStartupController;

/**
 * Class that spins up native when an interaction with a notification happens and passes the
 * relevant information on to native.
 */
public class DownloadBroadcastManagerImpl extends DownloadBroadcastManager.Impl {
    private static final int WAIT_TIME_MS = 5000;

    private final DownloadSharedPreferenceHelper mDownloadSharedPreferenceHelper =
            DownloadSharedPreferenceHelper.getInstance();

    private final DownloadNotificationService mDownloadNotificationService;
    private final Handler mHandler = new Handler();
    private final Runnable mStopSelfRunnable =
            new Runnable() {
                @Override
                public void run() {
                    getService().stopSelf();
                }
            };

    public static <T> void checkNotNull(T reference) {
        if (reference == null) {
            throw new NullPointerException();
        }
    }

    public DownloadBroadcastManagerImpl() {
        mDownloadNotificationService = DownloadNotificationService.getInstance();
    }

    // The service is only explicitly started in the resume case.
    // TODO(dtrainor): Start DownloadBroadcastManager explicitly in resumption refactor.
    public static void startDownloadBroadcastManager(Context context, Intent source) {
        Intent intent = source != null ? new Intent(source) : new Intent();
        intent.setComponent(new ComponentName(context, DownloadBroadcastManager.class));
        context.startService(intent);
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // Handle the download operation.
        onNotificationInteraction(intent);

        // If Chrome gets killed, do not restart the service.
        return Service.START_NOT_STICKY;
    }

    /**
     * Passes down information about a notification interaction to native.
     * @param intent with information about the notification interaction (action, contentId, etc).
     */
    public void onNotificationInteraction(final Intent intent) {
        if (!isActionHandled(intent)) return;

        // Remove delayed stop of service until after native library is loaded.
        mHandler.removeCallbacks(mStopSelfRunnable);

        // Update notification appearance immediately in case it takes a while for native to load.
        updateNotification(intent);

        // Handle the intent and propagate it through the native library.
        loadNativeAndPropagateInteraction(intent);
    }

    /**
     * Immediately update notification appearance without changing stored notification state.
     * @param intent with information about the notification.
     */
    void updateNotification(Intent intent) {
        String action = intent.getAction();
        if (!immediateNotificationUpdateNeeded(action)) return;

        final DownloadSharedPreferenceEntry entry = getDownloadEntryFromIntent(intent);
        final ContentId contentId = getContentIdFromIntent(intent);

        switch (action) {
            case ACTION_DOWNLOAD_PAUSE:
                if (entry != null) {
                    mDownloadNotificationService.notifyDownloadPaused(
                            entry.id,
                            entry.fileName,
                            true,
                            false,
                            entry.otrProfileID,
                            entry.isTransient,
                            null,
                            null,
                            false,
                            true,
                            false,
                            PendingState.NOT_PENDING);
                }
                break;

            case ACTION_DOWNLOAD_CANCEL:
                int notificationId = IntentUtils.safeGetIntExtra(intent, EXTRA_NOTIFICATION_ID, -1);
                // For old build, notification needs to be retrieved from the
                // DownloadSharedPreferenceEntry.
                if (notificationId < 0 && entry != null) {
                    notificationId = entry.notificationId;
                }
                if (notificationId >= 0 && contentId != null) {
                    mDownloadNotificationService.notifyDownloadCanceled(
                            contentId, notificationId, true);
                }
                break;

            case ACTION_DOWNLOAD_RESUME:
                if (entry != null) {
                    // If user manually resumes a download, update the network type if it
                    // is not metered previously.
                    boolean canDownloadWhileMetered =
                            entry.canDownloadWhileMetered
                                    || DownloadManagerService.isActiveNetworkMetered(
                                            ContextUtils.getApplicationContext());
                    // Update the SharedPreference entry.
                    mDownloadSharedPreferenceHelper.addOrReplaceSharedPreferenceEntry(
                            new DownloadSharedPreferenceEntry(
                                    entry.id,
                                    entry.notificationId,
                                    entry.otrProfileID,
                                    canDownloadWhileMetered,
                                    entry.fileName,
                                    true,
                                    entry.isTransient));

                    mDownloadNotificationService.notifyDownloadPending(
                            entry.id,
                            entry.fileName,
                            entry.otrProfileID,
                            entry.canDownloadWhileMetered,
                            entry.isTransient,
                            null,
                            null,
                            false,
                            true,
                            PendingState.PENDING_NETWORK);
                }
                break;

            default:
                // No-op.
                break;
        }
    }

    boolean immediateNotificationUpdateNeeded(String action) {
        return ACTION_DOWNLOAD_PAUSE.equals(action)
                || ACTION_DOWNLOAD_CANCEL.equals(action)
                || ACTION_DOWNLOAD_RESUME.equals(action);
    }

    /**
     * Helper function that loads the native and runs given runnable.
     * @param intent that is propagated when the native is loaded.
     */
    @VisibleForTesting
    void loadNativeAndPropagateInteraction(final Intent intent) {
        final ContentId id = getContentIdFromIntent(intent);
        final BrowserParts parts =
                new EmptyBrowserParts() {
                    @Override
                    public void finishNativeInitialization() {
                        // Delay the stop of the service by WAIT_TIME_MS after native library is
                        // loaded.
                        mHandler.postDelayed(mStopSelfRunnable, WAIT_TIME_MS);

                        DownloadStartupUtils.ensureDownloadSystemInitialized(
                                BrowserStartupController.getInstance().isFullBrowserStarted(),
                                IntentUtils.safeGetBooleanExtra(
                                        intent, EXTRA_IS_OFF_THE_RECORD, false));
                        propagateInteraction(intent);
                    }

                    @Override
                    public boolean startMinimalBrowser() {
                        if (!LegacyHelpers.isLegacyDownload(id)) return false;
                        return !ACTION_DOWNLOAD_OPEN.equals(intent.getAction());
                    }
                };

        ChromeBrowserInitializer.getInstance().handlePreNativeStartupAndLoadLibraries(parts);
        ChromeBrowserInitializer.getInstance().handlePostNativeStartup(true, parts);
    }

    @VisibleForTesting
    void propagateInteraction(Intent intent) {
        String action = intent.getAction();
        DownloadNotificationUmaHelper.recordNotificationInteractionHistogram(action);
        final ContentId id = getContentIdFromIntent(intent);
        final DownloadSharedPreferenceEntry entry = getDownloadEntryFromIntent(intent);
        boolean isOffTheRecord =
                IntentUtils.safeGetBooleanExtra(intent, EXTRA_IS_OFF_THE_RECORD, false);

        OTRProfileID otrProfileID;
        if (entry != null) {
            otrProfileID = entry.otrProfileID;
        } else {
            // If the profile doesn't exist, then do not perform any action.
            if (!DownloadUtils.doesProfileExistFromIntent(intent)) return;
            otrProfileID = DownloadUtils.getOTRProfileIDFromIntent(intent);
        }
        assert !isOffTheRecord || otrProfileID != null;

        // Handle actions that do not require a specific entry or service delegate.
        switch (action) {
            case ACTION_NOTIFICATION_CLICKED:
                openDownload(ContextUtils.getApplicationContext(), intent, otrProfileID, id);
                return;

            case ACTION_DOWNLOAD_OPEN:
                if (id != null) {
                    OpenParams openParams = new OpenParams(LaunchLocation.NOTIFICATION);
                    openParams.openInIncognito =
                            IntentUtils.safeGetBooleanExtra(intent, EXTRA_IS_OFF_THE_RECORD, false);
                    OfflineContentAggregatorNotificationBridgeUiFactory.instance()
                            .openItem(openParams, id);
                }
                return;
        }

        DownloadServiceDelegate downloadServiceDelegate = getServiceDelegate(id);

        checkNotNull(downloadServiceDelegate);
        checkNotNull(id);

        // Handle all remaining actions.
        switch (action) {
            case ACTION_DOWNLOAD_CANCEL:
                downloadServiceDelegate.cancelDownload(id, otrProfileID);
                break;

            case ACTION_DOWNLOAD_PAUSE:
                downloadServiceDelegate.pauseDownload(id, otrProfileID);
                break;

            case ACTION_DOWNLOAD_RESUME:
                DownloadItem item =
                        (entry != null)
                                ? entry.buildDownloadItem()
                                : new DownloadItem(
                                        false,
                                        new DownloadInfo.Builder()
                                                .setDownloadGuid(id.id)
                                                .setOTRProfileId(otrProfileID)
                                                .build());
                downloadServiceDelegate.resumeDownload(id, item);
                break;

            default:
                // No-op.
                break;
        }

        downloadServiceDelegate.destroyServiceDelegate();
    }

    static boolean isActionHandled(Intent intent) {
        if (intent == null) return false;
        String action = intent.getAction();
        return ACTION_DOWNLOAD_CANCEL.equals(action)
                || ACTION_DOWNLOAD_PAUSE.equals(action)
                || ACTION_DOWNLOAD_RESUME.equals(action)
                || ACTION_DOWNLOAD_OPEN.equals(action)
                || ACTION_NOTIFICATION_CLICKED.equals(action);
    }

    /**
     * Retrieves DownloadSharedPreferenceEntry from a download action intent.
     * TODO(crbug.com/40506285): Instead of getting entire entry, pass only id/isOffTheRecord, after
     * consolidating all downloads-related objects.
     *
     * @param intent Intent that contains the download action.
     */
    private DownloadSharedPreferenceEntry getDownloadEntryFromIntent(Intent intent) {
        return mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(
                getContentIdFromIntent(intent));
    }

    /**
     * @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.
     */
    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));
    }

    /**
     * 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
     */
    static DownloadServiceDelegate getServiceDelegate(ContentId id) {
        return OfflineContentAggregatorNotificationBridgeUiFactory.instance();
    }

    /**
     * Called to open a particular download item. Falls back to opening Download Home if
     * the download cannot be found by android DownloadManager.
     * @param context Context of the receiver.
     * @param intent Intent from the notification.
     * @param otrProfileID The {@link OTRProfileID} to determine whether to open download page
     * in incognito profile.
     * @param contentId Content ID of the download.
     */
    private void openDownload(
            Context context, Intent intent, OTRProfileID otrProfileID, ContentId contentId) {
        String downloadFilePath =
                IntentUtils.safeGetStringExtra(
                        intent, DownloadNotificationService.EXTRA_DOWNLOAD_FILE_PATH);
        if (ContentUriUtils.isContentUri(downloadFilePath)) {
            // On Q+, content URI is being used and there is no download ID.
            openDownloadWithId(context, intent, DownloadConstants.INVALID_DOWNLOAD_ID, contentId);
        } else {
            long[] ids =
                    intent.getLongArrayExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS);
            if (ids == null || ids.length == 0) {
                DownloadManagerService.openDownloadsPage(
                        otrProfileID, DownloadOpenSource.NOTIFICATION);
                return;
            }

            long id = ids[0];
            DownloadManagerBridge.queryDownloadResult(
                    id,
                    result -> {
                        if (result.contentUri == null) {
                            DownloadManagerService.openDownloadsPage(
                                    otrProfileID, DownloadOpenSource.NOTIFICATION);
                            return;
                        }
                        openDownloadWithId(context, intent, id, contentId);
                    });
        }
    }

    /**
     * Called to open a particular download item with the given ID.
     * @param context Context of the receiver.
     * @param intent Intent from the notification.
     * @param id ID from the Android DownloadManager, or DownloadConstants.INVALID_DOWNLOAD_ID on
     *         Q+.
     * @param contentId Content ID of the download.
     */
    private void openDownloadWithId(Context context, Intent intent, long id, ContentId contentId) {
        String downloadFilePath =
                IntentUtils.safeGetStringExtra(
                        intent, DownloadNotificationService.EXTRA_DOWNLOAD_FILE_PATH);
        boolean isSupportedMimeType =
                IntentUtils.safeGetBooleanExtra(
                        intent, DownloadNotificationService.EXTRA_IS_SUPPORTED_MIME_TYPE, false);
        boolean isOffTheRecord =
                IntentUtils.safeGetBooleanExtra(
                        intent, DownloadNotificationService.EXTRA_IS_OFF_THE_RECORD, false);
        // If the profile doesn't exist, then do not open the download.
        if (!DownloadUtils.doesProfileExistFromIntent(intent)) return;
        OTRProfileID otrProfileID = DownloadUtils.getOTRProfileIDFromIntent(intent);
        assert !isOffTheRecord || otrProfileID != null;
        Uri originalUrl = IntentUtils.safeGetParcelableExtra(intent, Intent.EXTRA_ORIGINATING_URI);
        Uri referrer = IntentUtils.safeGetParcelableExtra(intent, Intent.EXTRA_REFERRER);
        DownloadManagerService.openDownloadedContent(
                context,
                downloadFilePath,
                isSupportedMimeType,
                otrProfileID,
                contentId.id,
                id,
                originalUrl == null ? null : originalUrl.toString(),
                referrer == null ? null : referrer.toString(),
                DownloadOpenSource.NOTIFICATION,
                null);
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        // Since this service does not need to be bound, just return null.
        return null;
    }
}