chromium/chrome/android/java/src/org/chromium/chrome/browser/download/DownloadForegroundServiceManager.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 org.chromium.chrome.browser.download.DownloadSnackbarController.INVALID_NOTIFICATION_ID;

import android.app.Notification;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Handler;
import android.os.IBinder;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.ApplicationStatus;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.chrome.browser.download.DownloadNotificationService.DownloadStatus;
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.components.browser_ui.notifications.NotificationMetadata;
import org.chromium.components.browser_ui.notifications.NotificationWrapperBuilder;

/**
 * Foreground service implementation of {@link DownloadContinuityManager} that starts and stops a
 * foreground service when there are active downloads. Only active for Android versions < U.
 */
public class DownloadForegroundServiceManager extends DownloadContinuityManager {
    private static final String TAG = "DownloadFgsm";
    // Delay to ensure start/stop foreground doesn't happen too quickly (b/74236718).
    private static final int WAIT_TIME_MS = 200;

    // Variables used to ensure start/stop foreground doesn't happen too quickly (b/74236718).
    private final Handler mHandler = new Handler();
    private final Runnable mMaybeStopServiceRunnable =
            new Runnable() {
                @Override
                public void run() {
                    Log.w(TAG, "Checking if delayed stopAndUnbindService needs to be resolved.");
                    mStopServiceDelayed = false;
                    processDownloadUpdateQueue(false /* not isProcessingPending */);
                    mHandler.removeCallbacks(mMaybeStopServiceRunnable);
                    Log.w(
                            TAG,
                            "Done checking if delayed stopAndUnbindService needs to be resolved.");
                }
            };
    private boolean mStopServiceDelayed;

    // This is true when context.bindService has been called and before context.unbindService.
    private boolean mIsServiceBound;

    // Whether startForeground() is called. startForeground() must be called in 5 seconds after the
    // service is started.
    private boolean mStartForegroundCalled;

    // This is non-null when onServiceConnected has been called (aka service is active).
    private DownloadForegroundServiceImpl mBoundService;

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

    public DownloadForegroundServiceManager() {}

    @Override
    boolean isEnabled() {
        return !DownloadUtils.shouldUseUserInitiatedJobs();
    }

    /**
     * Process the notification queue for all cases and initiate any needed actions.
     * In the happy path, the logic should be:
     * bindAndStartService -> startOrUpdateForegroundService -> stopAndUnbindService.
     * @param isProcessingPending Whether the call was made to process pending notifications that
     *                            have accumulated in the queue during the startup process or if it
     *                            was made based on during a basic update.
     */
    @VisibleForTesting
    @Override
    void processDownloadUpdateQueue(boolean isProcessingPending) {
        DownloadUpdate downloadUpdate = findInterestingDownloadUpdate();
        if (downloadUpdate == null) return;
        // If foreground service is disallowed, don't handle active download updates. Only stopping
        // the foreground service is allowed.
        if (isActive(downloadUpdate.mDownloadStatus) && !canStartForeground()) return;

        // When nothing has been initialized, just bind the service.
        if (!mIsServiceBound) {
            // If the download update is not active at the onset, don't even start the service!
            if (!isActive(downloadUpdate.mDownloadStatus)) {
                cleanDownloadUpdateQueue();
                return;
            }
            startAndBindService(downloadUpdate.mContext);
            return;
        }

        // Skip everything that happens while waiting for startup.
        if (mBoundService == null) return;

        // In the pending case, start foreground with specific notificationId and notification.
        if (isProcessingPending) {
            Log.w(TAG, "Starting service with type " + downloadUpdate.mDownloadStatus);
            startOrUpdateForegroundService(downloadUpdate);

            // Post a delayed task to eventually check to see if service needs to be stopped.
            postMaybeStopServiceRunnable();
        }

        // If the selected downloadUpdate is not active, there are no active downloads left.
        // Stop the foreground service.
        // In the pending case, this will stop the foreground immediately after it was started.
        if (!isActive(downloadUpdate.mDownloadStatus)) {
            // Only stop the service if not waiting for delay (ie. WAIT_TIME_MS has transpired).
            if (!mStopServiceDelayed) {
                stopAndUnbindService(downloadUpdate.mDownloadStatus);
                cleanDownloadUpdateQueue();
            } else {
                Log.w(TAG, "Delaying call to stopAndUnbindService.");
            }
            return;
        }

        // Make sure the pinned notification is still active, if not, update.
        if (mDownloadUpdateQueue.get(mPinnedNotificationId) == null
                || !isActive(mDownloadUpdateQueue.get(mPinnedNotificationId).mDownloadStatus)) {
            startOrUpdateForegroundService(downloadUpdate);
        }

        // Clear out inactive download updates in queue if there is at least one active download.
        cleanDownloadUpdateQueue();
    }

    /** Helper code to bind service. */
    @VisibleForTesting
    void startAndBindService(Context context) {
        Log.w(TAG, "startAndBindService");
        mIsServiceBound = true;
        mStartForegroundCalled = false;
        startAndBindServiceInternal(context);
    }

    @VisibleForTesting
    void startAndBindServiceInternal(Context context) {
        DownloadForegroundServiceImpl.startDownloadForegroundService(context);
        context.bindService(
                new Intent(context, DownloadForegroundService.class),
                mConnection,
                Context.BIND_AUTO_CREATE);
    }

    private final ServiceConnection mConnection =
            new ServiceConnection() {
                @Override
                public void onServiceConnected(ComponentName className, IBinder service) {
                    Log.w(TAG, "onServiceConnected");
                    if (!(service instanceof DownloadForegroundServiceImpl.LocalBinder)) {
                        Log.w(
                                TAG,
                                "Not from DownloadNotificationService, do not connect."
                                        + " Component name: "
                                        + className);
                        return;
                    }
                    mBoundService =
                            ((DownloadForegroundServiceImpl.LocalBinder) service).getService();
                    DownloadForegroundServiceObservers.addObserver(
                            DownloadNotificationServiceObserver.class);
                    processDownloadUpdateQueue(/* isProcessingPending= */ true);
                }

                @Override
                public void onServiceDisconnected(ComponentName componentName) {
                    Log.w(TAG, "onServiceDisconnected");
                    mBoundService = null;
                }
            };

    /** Helper code to start or update foreground service. */
    @VisibleForTesting
    void startOrUpdateForegroundService(DownloadUpdate update) {
        Log.w(
                TAG,
                "startOrUpdateForegroundService id: "
                        + update.mNotificationId
                        + ", startForeground() Called: "
                        + mStartForegroundCalled);

        int notificationId = update.mNotificationId;
        Notification notification = update.mNotification;

        // On O+, we must call startForeground or Android will crash. If the last update
        // is DownloadStatus.CANCELLED, then create an empty notification. See crbug.com/1121096.
        // Notices the empty notification will be cancelled immediately in
        // DownloadNotificationService afterward.
        if (VERSION.SDK_INT >= VERSION_CODES.O && notification == null && !mStartForegroundCalled) {
            assert update.mDownloadStatus == DownloadStatus.CANCELLED;
            notification = createEmptyNotification(notificationId, update.mContext);
        }

        if (mBoundService != null
                && notificationId != INVALID_NOTIFICATION_ID
                && notification != null) {
            // If there was an originally pinned notification, get its id and notification.
            DownloadUpdate downloadUpdate = mDownloadUpdateQueue.get(mPinnedNotificationId);
            Notification oldNotification =
                    (downloadUpdate == null) ? null : downloadUpdate.mNotification;

            boolean killOldNotification =
                    downloadUpdate != null
                            && downloadUpdate.mDownloadStatus == DownloadStatus.CANCELLED;

            // Start service and handle notifications.
            mBoundService.startOrUpdateForegroundService(
                    notificationId,
                    notification,
                    mPinnedNotificationId,
                    oldNotification,
                    killOldNotification);
            mStartForegroundCalled = true;

            // After the service has been started and the notification handled, change stored id.
            mPinnedNotificationId = notificationId;
        }
    }

    // Creates an empty notification to feed to startForeground().
    private Notification createEmptyNotification(int notificationId, Context context) {
        NotificationWrapperBuilder builder =
                NotificationWrapperBuilderFactory.createNotificationWrapperBuilder(
                        ChromeChannelDefinitions.ChannelId.DOWNLOADS,
                        new NotificationMetadata(
                                NotificationUmaTracker.SystemNotificationType.DOWNLOAD_FILES,
                                null,
                                notificationId));
        return builder.build();
    }

    /** Helper code to stop and unbind service. */
    @VisibleForTesting
    void stopAndUnbindService(@DownloadNotificationService.DownloadStatus int downloadStatus) {
        Log.w(TAG, "stopAndUnbindService status: " + downloadStatus);
        checkNotNull(mBoundService);
        mIsServiceBound = false;

        @DownloadForegroundServiceImpl.StopForegroundNotification int stopForegroundNotification;
        if (downloadStatus == DownloadNotificationService.DownloadStatus.CANCELLED) {
            stopForegroundNotification =
                    DownloadForegroundServiceImpl.StopForegroundNotification.KILL;
        } else {
            stopForegroundNotification =
                    DownloadForegroundServiceImpl.StopForegroundNotification.DETACH;
        }

        DownloadUpdate downloadUpdate = mDownloadUpdateQueue.get(mPinnedNotificationId);
        Notification oldNotification =
                (downloadUpdate == null) ? null : downloadUpdate.mNotification;

        stopAndUnbindServiceInternal(
                stopForegroundNotification, mPinnedNotificationId, oldNotification);

        mBoundService = null;
        mStartForegroundCalled = false;

        mPinnedNotificationId = INVALID_NOTIFICATION_ID;
    }

    @VisibleForTesting
    void stopAndUnbindServiceInternal(
            @DownloadForegroundServiceImpl.StopForegroundNotification int stopForegroundStatus,
            int pinnedNotificationId,
            Notification pinnedNotification) {
        mBoundService.stopDownloadForegroundService(
                stopForegroundStatus, pinnedNotificationId, pinnedNotification);
        ContextUtils.getApplicationContext().unbindService(mConnection);

        DownloadForegroundServiceObservers.removeObserver(
                DownloadNotificationServiceObserver.class);
    }

    /** Helper code for testing. */
    @VisibleForTesting
    void setBoundService(DownloadForegroundServiceImpl service) {
        mBoundService = service;
    }

    // Allow testing methods to skip posting the delayed runnable.
    @VisibleForTesting
    void postMaybeStopServiceRunnable() {
        mHandler.removeCallbacks(mMaybeStopServiceRunnable);
        mHandler.postDelayed(mMaybeStopServiceRunnable, WAIT_TIME_MS);
        mStopServiceDelayed = true;
    }

    /**
     * @return Whether startForeground() is allowed to be called.
     */
    @VisibleForTesting
    protected boolean canStartForeground() {
        if (VERSION.SDK_INT < VERSION_CODES.S) return true;
        // If foreground service is started, startForeground() must be called.
        return ApplicationStatus.hasVisibleActivities()
                || (mIsServiceBound && !mStartForegroundCalled);
    }
}