chromium/chrome/android/java/src/org/chromium/chrome/browser/download/DownloadNotificationFactory.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.download.DownloadNotificationService.EXTRA_NOTIFICATION_BUNDLE_ICON_ID;
import static org.chromium.chrome.browser.download.DownloadNotificationService.EXTRA_OTR_PROFILE_ID;

import android.app.DownloadManager;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;

import androidx.core.app.NotificationCompat;

import org.chromium.chrome.R;
import org.chromium.chrome.browser.notifications.NotificationConstants;
import org.chromium.chrome.browser.notifications.NotificationUmaTracker;
import org.chromium.chrome.browser.notifications.NotificationWrapperBuilderFactory;
import org.chromium.chrome.browser.notifications.channels.ChromeChannelDefinitions;
import org.chromium.chrome.browser.profiles.OTRProfileID;
import org.chromium.components.browser_ui.notifications.NotificationMetadata;
import org.chromium.components.browser_ui.notifications.NotificationWrapperBuilder;
import org.chromium.components.browser_ui.notifications.PendingIntentProvider;
import org.chromium.components.browser_ui.util.DownloadUtils;
import org.chromium.components.offline_items_collection.ContentId;
import org.chromium.components.offline_items_collection.LegacyHelpers;
import org.chromium.components.offline_items_collection.OfflineItem;

/** Creates and updates notifications related to downloads. */
public final class DownloadNotificationFactory {
    // Limit file name to 25 characters. TODO(qinmin): use different limit for different devices?
    public static final int MAX_FILE_NAME_LENGTH = 25;

    // Limit the origin length so that the eTLD+1 cannot be hidden. If the origin exceeds this
    // length the eTLD+1 is extracted and shown.
    public static final int MAX_ORIGIN_LENGTH = 40;

    // Time out duration for success and failed download notification.
    private static final long TIME_OUT_DURATION_IN_MILLIS = 60 * 60 * 1000;

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

    private static void checkArgument(boolean expression) {
        if (!expression) {
            throw new IllegalArgumentException();
        }
    }

    /**
     * Builds a downloads notification based on the status of the download and its information. All
     * changes to this function should consider the difference between normal profile and off the
     * record profile.
     * @param context of the download.
     * @param downloadStatus (in progress, paused, successful, failed, deleted, or summary).
     * @param downloadUpdate information about the download (ie. contentId, fileName, icon,
     * isOffTheRecord, etc).
     * @param notificationId The notification id passed to {@link
     *         android.app.NotificationManager#notify(String, int, Notification)}.
     * @return Notification that is built based on these parameters.
     */
    public static Notification buildNotification(
            Context context,
            @DownloadNotificationService.DownloadStatus int downloadStatus,
            DownloadUpdate downloadUpdate,
            int notificationId) {
        // TODO(xingliu): Write a unit test for this class.
        String channelId = ChromeChannelDefinitions.ChannelId.DOWNLOADS;
        if (LegacyHelpers.isLegacyDownload(downloadUpdate.getContentId())
                && downloadStatus == DownloadNotificationService.DownloadStatus.COMPLETED) {
            channelId = ChromeChannelDefinitions.ChannelId.COMPLETED_DOWNLOADS;
        }
        var metadata =
                new NotificationMetadata(
                        LegacyHelpers.isLegacyDownload(downloadUpdate.getContentId())
                                ? NotificationUmaTracker.SystemNotificationType.DOWNLOAD_FILES
                                : NotificationUmaTracker.SystemNotificationType.DOWNLOAD_PAGES,
                        /* tag= */ null,
                        notificationId);
        NotificationWrapperBuilder builder =
                NotificationWrapperBuilderFactory.createNotificationWrapperBuilder(
                                channelId, metadata)
                        .setLocalOnly(true)
                        .setGroup(NotificationConstants.GROUP_DOWNLOADS)
                        .setAutoCancel(true);

        String contentText;
        int iconId;
        @NotificationUmaTracker.ActionType int cancelActionType;
        @NotificationUmaTracker.ActionType int pauseActionType;
        @NotificationUmaTracker.ActionType int resumeActionType;
        if (LegacyHelpers.isLegacyDownload(downloadUpdate.getContentId())) {
            cancelActionType = NotificationUmaTracker.ActionType.DOWNLOAD_CANCEL;
            pauseActionType = NotificationUmaTracker.ActionType.DOWNLOAD_PAUSE;
            resumeActionType = NotificationUmaTracker.ActionType.DOWNLOAD_RESUME;
        } else {
            cancelActionType = NotificationUmaTracker.ActionType.DOWNLOAD_PAGE_CANCEL;
            pauseActionType = NotificationUmaTracker.ActionType.DOWNLOAD_PAGE_PAUSE;
            resumeActionType = NotificationUmaTracker.ActionType.DOWNLOAD_PAGE_RESUME;
        }

        var resources = context.getResources();
        switch (downloadStatus) {
            case DownloadNotificationService.DownloadStatus.IN_PROGRESS:
                checkNotNull(downloadUpdate.getProgress());
                checkNotNull(downloadUpdate.getContentId());
                checkArgument(downloadUpdate.getNotificationId() != -1);

                if (downloadUpdate.getIsDownloadPending()) {
                    contentText =
                            StringUtils.getPendingStatusForUi(downloadUpdate.getPendingState());
                } else {
                    // Incognito mode should hide download progress details like file size.
                    OfflineItem.Progress progress =
                            downloadUpdate.getIsOffTheRecord()
                                    ? OfflineItem.Progress.createIndeterminateProgress()
                                    : downloadUpdate.getProgress();
                    contentText = StringUtils.getProgressTextForUi(progress);
                }

                iconId =
                        downloadUpdate.getIsDownloadPending()
                                ? R.drawable.ic_download_pending
                                : android.R.drawable.stat_sys_download;

                Intent pauseIntent =
                        buildActionIntent(
                                context,
                                ACTION_DOWNLOAD_PAUSE,
                                downloadUpdate.getContentId(),
                                downloadUpdate.getOTRProfileID());
                Intent cancelIntent =
                        buildActionIntent(
                                context,
                                ACTION_DOWNLOAD_CANCEL,
                                downloadUpdate.getContentId(),
                                downloadUpdate.getOTRProfileID());
                cancelIntent.putExtra(
                        NotificationConstants.EXTRA_NOTIFICATION_ID,
                        downloadUpdate.getNotificationId());
                builder.setOngoing(true)
                        .setPriorityBeforeO(NotificationCompat.PRIORITY_HIGH)
                        .setAutoCancel(false)
                        .addAction(
                                R.drawable.ic_pause_white_24dp,
                                resources.getString(R.string.download_notification_pause_button),
                                buildPendingIntentProvider(
                                        context, pauseIntent, downloadUpdate.getNotificationId()),
                                pauseActionType)
                        .addAction(
                                R.drawable.btn_close_white,
                                resources.getString(R.string.download_notification_cancel_button),
                                buildPendingIntentProvider(
                                        context, cancelIntent, downloadUpdate.getNotificationId()),
                                cancelActionType);

                if (!downloadUpdate.getIsOffTheRecord()) {
                    builder.setLargeIcon(downloadUpdate.getIcon());
                }

                if (!downloadUpdate.getIsDownloadPending()) {
                    boolean indeterminate = downloadUpdate.getProgress().isIndeterminate();
                    builder.setProgress(
                            100,
                            indeterminate ? -1 : downloadUpdate.getProgress().getPercentage(),
                            indeterminate);
                }

                if (!downloadUpdate.getProgress().isIndeterminate()
                        && !downloadUpdate.getIsOffTheRecord()
                        && downloadUpdate.getTimeRemainingInMillis() >= 0
                        && !LegacyHelpers.isLegacyOfflinePage(downloadUpdate.getContentId())) {
                    String subText =
                            StringUtils.timeLeftForUi(
                                    context, downloadUpdate.getTimeRemainingInMillis());
                    builder.setSubText(subText);
                }

                if (downloadUpdate.getStartTime() > 0) {
                    builder.setWhen(downloadUpdate.getStartTime());
                }

                break;
            case DownloadNotificationService.DownloadStatus.PAUSED:
                checkNotNull(downloadUpdate.getContentId());
                checkArgument(downloadUpdate.getNotificationId() != -1);

                contentText = resources.getString(R.string.download_notification_paused);
                iconId = R.drawable.ic_download_pause;

                Intent resumeIntent =
                        buildActionIntent(
                                context,
                                ACTION_DOWNLOAD_RESUME,
                                downloadUpdate.getContentId(),
                                downloadUpdate.getOTRProfileID());
                cancelIntent =
                        buildActionIntent(
                                context,
                                ACTION_DOWNLOAD_CANCEL,
                                downloadUpdate.getContentId(),
                                downloadUpdate.getOTRProfileID());

                builder.setAutoCancel(false)
                        .addAction(
                                R.drawable.ic_file_download_white_24dp,
                                resources.getString(R.string.download_notification_resume_button),
                                buildPendingIntentProvider(
                                        context, resumeIntent, downloadUpdate.getNotificationId()),
                                resumeActionType)
                        .addAction(
                                R.drawable.btn_close_white,
                                resources.getString(R.string.download_notification_cancel_button),
                                buildPendingIntentProvider(
                                        context, cancelIntent, downloadUpdate.getNotificationId()),
                                cancelActionType);

                if (!downloadUpdate.getIsOffTheRecord()) {
                    builder.setLargeIcon(downloadUpdate.getIcon());
                }

                if (downloadUpdate.getIsTransient()) {
                    builder.setDeleteIntent(
                            buildPendingIntentProvider(
                                    context, cancelIntent, downloadUpdate.getNotificationId()));
                }

                break;
            case DownloadNotificationService.DownloadStatus.COMPLETED:
                checkArgument(downloadUpdate.getNotificationId() != -1);

                // Don't show file size in incognito mode.
                if (downloadUpdate.getTotalBytes() > 0 && !downloadUpdate.getIsOffTheRecord()) {
                    contentText =
                            resources.getString(
                                    R.string.download_notification_completed_with_size,
                                    org.chromium.components.browser_ui.util.DownloadUtils
                                            .getStringForBytes(
                                                    context, downloadUpdate.getTotalBytes()));
                } else {
                    contentText = resources.getString(R.string.download_notification_completed);
                }

                iconId = R.drawable.offline_pin;
                // Download from Android DownloadManager carries an empty namespace.
                if (TextUtils.isEmpty(downloadUpdate.getContentId().namespace)) {
                    // Create an intent to view all Android downloads.
                    Intent intent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
                    intent.setFlags(
                            Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
                    builder.setContentIntent(
                            PendingIntentProvider.getActivity(
                                    context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT));
                } else if (downloadUpdate.getIsOpenable()) {
                    Intent intent =
                            buildActionIntent(
                                    context,
                                    ACTION_DOWNLOAD_OPEN,
                                    downloadUpdate.getContentId(),
                                    null);

                    ComponentName component =
                            new ComponentName(
                                    context.getPackageName(),
                                    DownloadBroadcastManager.class.getName());
                    intent.setComponent(component);
                    builder.setContentIntent(
                            PendingIntentProvider.getService(
                                    context,
                                    downloadUpdate.getNotificationId(),
                                    intent,
                                    PendingIntent.FLAG_UPDATE_CURRENT));
                }

                // It's the job of the service to ensure that the default icon is provided when
                // in incognito mode.
                if (downloadUpdate.getIcon() != null) {
                    builder.setLargeIcon(downloadUpdate.getIcon());
                }
                builder.setTimeoutAfter(TIME_OUT_DURATION_IN_MILLIS);

                break;
            case DownloadNotificationService.DownloadStatus.FAILED:
                iconId = android.R.drawable.stat_sys_download_done;
                contentText = StringUtils.getFailStatusForUi(downloadUpdate.getFailState());
                builder.setTimeoutAfter(TIME_OUT_DURATION_IN_MILLIS);
                break;
            default:
                iconId = -1;
                contentText = "";
                break;
        }

        Bundle extras = new Bundle();
        extras.putInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID, iconId);
        builder.setSmallIcon(iconId).addExtras(extras);

        // Context text is shown as title in incognito mode as the file name is not shown.
        if (downloadUpdate.getIsOffTheRecord()) {
            builder.setContentTitle(contentText);
        } else {
            builder.setContentText(contentText);
        }

        // Don't show file name in incognito mode.
        if (downloadUpdate.getFileName() != null && !downloadUpdate.getIsOffTheRecord()) {
            builder.setContentTitle(
                    StringUtils.getAbbreviatedFileName(
                            downloadUpdate.getFileName(), MAX_FILE_NAME_LENGTH));
        }

        if (!downloadUpdate.getIsTransient()
                && downloadUpdate.getNotificationId() != -1
                && downloadStatus != DownloadNotificationService.DownloadStatus.COMPLETED
                && downloadStatus != DownloadNotificationService.DownloadStatus.FAILED) {
            Intent downloadHomeIntent =
                    buildActionIntent(
                            context,
                            ACTION_NOTIFICATION_CLICKED,
                            null,
                            downloadUpdate.getOTRProfileID());
            builder.setContentIntent(
                    PendingIntentProvider.getService(
                            context,
                            downloadUpdate.getNotificationId(),
                            downloadHomeIntent,
                            PendingIntent.FLAG_UPDATE_CURRENT));
        }

        if (downloadUpdate.getIsOffTheRecord()) {
            // A sub text to inform the users that they are using incognito mode.
            builder.setSubText(
                    resources.getString(R.string.download_notification_incognito_subtext));
        } else if (downloadUpdate.getShouldPromoteOrigin()) {
            // Always show the origin URL if available (for normal profiles).
            String formattedUrl =
                    DownloadUtils.formatUrlForDisplayInNotification(
                            downloadUpdate.getOriginalUrl(),
                            DownloadUtils.MAX_ORIGIN_LENGTH_FOR_NOTIFICATION);
            if (formattedUrl != null) builder.setSubText(formattedUrl);
        }

        return builder.build();
    }

    /**
     * Helper method to build a PendingIntent from the provided intent.
     * @param intent Intent to broadcast.
     * @param notificationId ID of the notification.
     */
    private static PendingIntentProvider buildPendingIntentProvider(
            Context context, Intent intent, int notificationId) {
        return PendingIntentProvider.getService(
                context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
    }

    /**
     * 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 otrProfileID The {@link OTRProfileID} of the download. Null if in regular mode.
     */
    public static Intent buildActionIntent(
            Context context, String action, ContentId id, OTRProfileID otrProfileID) {
        ComponentName component =
                new ComponentName(
                        context.getPackageName(), DownloadBroadcastManager.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, OTRProfileID.isOffTheRecord(otrProfileID));
        intent.putExtra(EXTRA_OTR_PROFILE_ID, OTRProfileID.serialize(otrProfileID));
        return intent;
    }
}