| /* |
| * Copyright (C) 2017 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package com.google.android.exoplayer2.offline; |
| |
| import static com.google.android.exoplayer2.offline.Download.FAILURE_REASON_NONE; |
| import static com.google.android.exoplayer2.offline.Download.FAILURE_REASON_UNKNOWN; |
| import static com.google.android.exoplayer2.offline.Download.STATE_COMPLETED; |
| import static com.google.android.exoplayer2.offline.Download.STATE_DOWNLOADING; |
| import static com.google.android.exoplayer2.offline.Download.STATE_FAILED; |
| import static com.google.android.exoplayer2.offline.Download.STATE_QUEUED; |
| import static com.google.android.exoplayer2.offline.Download.STATE_REMOVING; |
| import static com.google.android.exoplayer2.offline.Download.STATE_RESTARTING; |
| import static com.google.android.exoplayer2.offline.Download.STATE_STOPPED; |
| import static com.google.android.exoplayer2.offline.Download.STOP_REASON_NONE; |
| |
| import android.content.Context; |
| import android.os.Handler; |
| import android.os.HandlerThread; |
| import android.os.Looper; |
| import android.os.Message; |
| import androidx.annotation.CheckResult; |
| import androidx.annotation.Nullable; |
| import com.google.android.exoplayer2.C; |
| import com.google.android.exoplayer2.database.DatabaseProvider; |
| import com.google.android.exoplayer2.scheduler.Requirements; |
| import com.google.android.exoplayer2.scheduler.RequirementsWatcher; |
| import com.google.android.exoplayer2.upstream.DataSource; |
| import com.google.android.exoplayer2.upstream.DataSource.Factory; |
| import com.google.android.exoplayer2.upstream.cache.Cache; |
| import com.google.android.exoplayer2.upstream.cache.CacheEvictor; |
| import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; |
| import com.google.android.exoplayer2.util.Assertions; |
| import com.google.android.exoplayer2.util.Log; |
| import com.google.android.exoplayer2.util.Util; |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.concurrent.CopyOnWriteArraySet; |
| |
| /** |
| * Manages downloads. |
| * |
| * <p>Normally a download manager should be accessed via a {@link DownloadService}. When a download |
| * manager is used directly instead, downloads will be initially paused and so must be resumed by |
| * calling {@link #resumeDownloads()}. |
| * |
| * <p>A download manager instance must be accessed only from the thread that created it, unless that |
| * thread does not have a {@link Looper}. In that case, it must be accessed only from the |
| * application's main thread. Registered listeners will be called on the same thread. |
| */ |
| public final class DownloadManager { |
| |
| /** Listener for {@link DownloadManager} events. */ |
| public interface Listener { |
| |
| /** |
| * Called when all downloads have been restored. |
| * |
| * @param downloadManager The reporting instance. |
| */ |
| default void onInitialized(DownloadManager downloadManager) {} |
| |
| /** |
| * Called when downloads are ({@link #pauseDownloads() paused} or {@link #resumeDownloads() |
| * resumed}. |
| * |
| * @param downloadManager The reporting instance. |
| * @param downloadsPaused Whether downloads are currently paused. |
| */ |
| default void onDownloadsPausedChanged( |
| DownloadManager downloadManager, boolean downloadsPaused) {} |
| |
| /** |
| * Called when the state of a download changes. |
| * |
| * @param downloadManager The reporting instance. |
| * @param download The state of the download. |
| */ |
| default void onDownloadChanged(DownloadManager downloadManager, Download download) {} |
| |
| /** |
| * Called when a download is removed. |
| * |
| * @param downloadManager The reporting instance. |
| * @param download The last state of the download before it was removed. |
| */ |
| default void onDownloadRemoved(DownloadManager downloadManager, Download download) {} |
| |
| /** |
| * Called when there is no active download left. |
| * |
| * @param downloadManager The reporting instance. |
| */ |
| default void onIdle(DownloadManager downloadManager) {} |
| |
| /** |
| * Called when the download requirements state changed. |
| * |
| * @param downloadManager The reporting instance. |
| * @param requirements Requirements needed to be met to start downloads. |
| * @param notMetRequirements {@link Requirements.RequirementFlags RequirementFlags} that are not |
| * met, or 0. |
| */ |
| default void onRequirementsStateChanged( |
| DownloadManager downloadManager, |
| Requirements requirements, |
| @Requirements.RequirementFlags int notMetRequirements) {} |
| |
| /** |
| * Called when there is a change in whether this manager has one or more downloads that are not |
| * progressing for the sole reason that the {@link #getRequirements() Requirements} are not met. |
| * See {@link #isWaitingForRequirements()} for more information. |
| * |
| * @param downloadManager The reporting instance. |
| * @param waitingForRequirements Whether this manager has one or more downloads that are not |
| * progressing for the sole reason that the {@link #getRequirements() Requirements} are not |
| * met. |
| */ |
| default void onWaitingForRequirementsChanged( |
| DownloadManager downloadManager, boolean waitingForRequirements) {} |
| } |
| |
| /** The default maximum number of parallel downloads. */ |
| public static final int DEFAULT_MAX_PARALLEL_DOWNLOADS = 3; |
| /** The default minimum number of times a download must be retried before failing. */ |
| public static final int DEFAULT_MIN_RETRY_COUNT = 5; |
| /** The default requirement is that the device has network connectivity. */ |
| public static final Requirements DEFAULT_REQUIREMENTS = new Requirements(Requirements.NETWORK); |
| |
| // Messages posted to the main handler. |
| private static final int MSG_INITIALIZED = 0; |
| private static final int MSG_PROCESSED = 1; |
| private static final int MSG_DOWNLOAD_UPDATE = 2; |
| |
| // Messages posted to the background handler. |
| private static final int MSG_INITIALIZE = 0; |
| private static final int MSG_SET_DOWNLOADS_PAUSED = 1; |
| private static final int MSG_SET_NOT_MET_REQUIREMENTS = 2; |
| private static final int MSG_SET_STOP_REASON = 3; |
| private static final int MSG_SET_MAX_PARALLEL_DOWNLOADS = 4; |
| private static final int MSG_SET_MIN_RETRY_COUNT = 5; |
| private static final int MSG_ADD_DOWNLOAD = 6; |
| private static final int MSG_REMOVE_DOWNLOAD = 7; |
| private static final int MSG_REMOVE_ALL_DOWNLOADS = 8; |
| private static final int MSG_TASK_STOPPED = 9; |
| private static final int MSG_CONTENT_LENGTH_CHANGED = 10; |
| private static final int MSG_UPDATE_PROGRESS = 11; |
| private static final int MSG_RELEASE = 12; |
| |
| private static final String TAG = "DownloadManager"; |
| |
| private final Context context; |
| private final WritableDownloadIndex downloadIndex; |
| private final Handler mainHandler; |
| private final InternalHandler internalHandler; |
| private final RequirementsWatcher.Listener requirementsListener; |
| private final CopyOnWriteArraySet<Listener> listeners; |
| |
| private int pendingMessages; |
| private int activeTaskCount; |
| private boolean initialized; |
| private boolean downloadsPaused; |
| private int maxParallelDownloads; |
| private int minRetryCount; |
| private int notMetRequirements; |
| private boolean waitingForRequirements; |
| private List<Download> downloads; |
| private RequirementsWatcher requirementsWatcher; |
| |
| /** |
| * Constructs a {@link DownloadManager}. |
| * |
| * @param context Any context. |
| * @param databaseProvider Provides the SQLite database in which downloads are persisted. |
| * @param cache A cache to be used to store downloaded data. The cache should be configured with |
| * an {@link CacheEvictor} that will not evict downloaded content, for example {@link |
| * NoOpCacheEvictor}. |
| * @param upstreamFactory A {@link Factory} for creating {@link DataSource}s for downloading data. |
| */ |
| public DownloadManager( |
| Context context, DatabaseProvider databaseProvider, Cache cache, Factory upstreamFactory) { |
| this( |
| context, |
| new DefaultDownloadIndex(databaseProvider), |
| new DefaultDownloaderFactory(new DownloaderConstructorHelper(cache, upstreamFactory))); |
| } |
| |
| /** |
| * Constructs a {@link DownloadManager}. |
| * |
| * @param context Any context. |
| * @param downloadIndex The download index used to hold the download information. |
| * @param downloaderFactory A factory for creating {@link Downloader}s. |
| */ |
| public DownloadManager( |
| Context context, WritableDownloadIndex downloadIndex, DownloaderFactory downloaderFactory) { |
| this.context = context.getApplicationContext(); |
| this.downloadIndex = downloadIndex; |
| |
| maxParallelDownloads = DEFAULT_MAX_PARALLEL_DOWNLOADS; |
| minRetryCount = DEFAULT_MIN_RETRY_COUNT; |
| downloadsPaused = true; |
| downloads = Collections.emptyList(); |
| listeners = new CopyOnWriteArraySet<>(); |
| |
| @SuppressWarnings("methodref.receiver.bound.invalid") |
| Handler mainHandler = Util.createHandler(this::handleMainMessage); |
| this.mainHandler = mainHandler; |
| HandlerThread internalThread = new HandlerThread("ExoPlayer:DownloadManager"); |
| internalThread.start(); |
| internalHandler = |
| new InternalHandler( |
| internalThread, |
| downloadIndex, |
| downloaderFactory, |
| mainHandler, |
| maxParallelDownloads, |
| minRetryCount, |
| downloadsPaused); |
| |
| @SuppressWarnings("methodref.receiver.bound.invalid") |
| RequirementsWatcher.Listener requirementsListener = this::onRequirementsStateChanged; |
| this.requirementsListener = requirementsListener; |
| requirementsWatcher = |
| new RequirementsWatcher(context, requirementsListener, DEFAULT_REQUIREMENTS); |
| notMetRequirements = requirementsWatcher.start(); |
| |
| pendingMessages = 1; |
| internalHandler |
| .obtainMessage(MSG_INITIALIZE, notMetRequirements, /* unused */ 0) |
| .sendToTarget(); |
| } |
| |
| /** Returns whether the manager has completed initialization. */ |
| public boolean isInitialized() { |
| return initialized; |
| } |
| |
| /** |
| * Returns whether the manager is currently idle. The manager is idle if all downloads are in a |
| * terminal state (i.e. completed or failed), or if no progress can be made (e.g. because the |
| * download requirements are not met). |
| */ |
| public boolean isIdle() { |
| return activeTaskCount == 0 && pendingMessages == 0; |
| } |
| |
| /** |
| * Returns whether this manager has one or more downloads that are not progressing for the sole |
| * reason that the {@link #getRequirements() Requirements} are not met. This is true if: |
| * |
| * <ul> |
| * <li>The {@link #getRequirements() Requirements} are not met. |
| * <li>The downloads are not paused (i.e. {@link #getDownloadsPaused()} is {@code false}). |
| * <li>There are downloads in the {@link Download#STATE_QUEUED queued state}. |
| * </ul> |
| */ |
| public boolean isWaitingForRequirements() { |
| return waitingForRequirements; |
| } |
| |
| /** |
| * Adds a {@link Listener}. |
| * |
| * @param listener The listener to be added. |
| */ |
| public void addListener(Listener listener) { |
| listeners.add(listener); |
| } |
| |
| /** |
| * Removes a {@link Listener}. |
| * |
| * @param listener The listener to be removed. |
| */ |
| public void removeListener(Listener listener) { |
| listeners.remove(listener); |
| } |
| |
| /** Returns the requirements needed to be met to progress. */ |
| public Requirements getRequirements() { |
| return requirementsWatcher.getRequirements(); |
| } |
| |
| /** |
| * Returns the requirements needed for downloads to progress that are not currently met. |
| * |
| * @return The not met {@link Requirements.RequirementFlags}, or 0 if all requirements are met. |
| */ |
| @Requirements.RequirementFlags |
| public int getNotMetRequirements() { |
| return notMetRequirements; |
| } |
| |
| /** |
| * Sets the requirements that need to be met for downloads to progress. |
| * |
| * @param requirements A {@link Requirements}. |
| */ |
| public void setRequirements(Requirements requirements) { |
| if (requirements.equals(requirementsWatcher.getRequirements())) { |
| return; |
| } |
| requirementsWatcher.stop(); |
| requirementsWatcher = new RequirementsWatcher(context, requirementsListener, requirements); |
| int notMetRequirements = requirementsWatcher.start(); |
| onRequirementsStateChanged(requirementsWatcher, notMetRequirements); |
| } |
| |
| /** Returns the maximum number of parallel downloads. */ |
| public int getMaxParallelDownloads() { |
| return maxParallelDownloads; |
| } |
| |
| /** |
| * Sets the maximum number of parallel downloads. |
| * |
| * @param maxParallelDownloads The maximum number of parallel downloads. Must be greater than 0. |
| */ |
| public void setMaxParallelDownloads(int maxParallelDownloads) { |
| Assertions.checkArgument(maxParallelDownloads > 0); |
| if (this.maxParallelDownloads == maxParallelDownloads) { |
| return; |
| } |
| this.maxParallelDownloads = maxParallelDownloads; |
| pendingMessages++; |
| internalHandler |
| .obtainMessage(MSG_SET_MAX_PARALLEL_DOWNLOADS, maxParallelDownloads, /* unused */ 0) |
| .sendToTarget(); |
| } |
| |
| /** |
| * Returns the minimum number of times that a download will be retried. A download will fail if |
| * the specified number of retries is exceeded without any progress being made. |
| */ |
| public int getMinRetryCount() { |
| return minRetryCount; |
| } |
| |
| /** |
| * Sets the minimum number of times that a download will be retried. A download will fail if the |
| * specified number of retries is exceeded without any progress being made. |
| * |
| * @param minRetryCount The minimum number of times that a download will be retried. |
| */ |
| public void setMinRetryCount(int minRetryCount) { |
| Assertions.checkArgument(minRetryCount >= 0); |
| if (this.minRetryCount == minRetryCount) { |
| return; |
| } |
| this.minRetryCount = minRetryCount; |
| pendingMessages++; |
| internalHandler |
| .obtainMessage(MSG_SET_MIN_RETRY_COUNT, minRetryCount, /* unused */ 0) |
| .sendToTarget(); |
| } |
| |
| /** Returns the used {@link DownloadIndex}. */ |
| public DownloadIndex getDownloadIndex() { |
| return downloadIndex; |
| } |
| |
| /** |
| * Returns current downloads. Downloads that are in terminal states (i.e. completed or failed) are |
| * not included. To query all downloads including those in terminal states, use {@link |
| * #getDownloadIndex()} instead. |
| */ |
| public List<Download> getCurrentDownloads() { |
| return downloads; |
| } |
| |
| /** Returns whether downloads are currently paused. */ |
| public boolean getDownloadsPaused() { |
| return downloadsPaused; |
| } |
| |
| /** |
| * Resumes downloads. |
| * |
| * <p>If the {@link #setRequirements(Requirements) Requirements} are met up to {@link |
| * #getMaxParallelDownloads() maxParallelDownloads} will be started, excluding those with non-zero |
| * {@link Download#stopReason stopReasons}. |
| */ |
| public void resumeDownloads() { |
| setDownloadsPaused(/* downloadsPaused= */ false); |
| } |
| |
| /** |
| * Pauses downloads. Downloads that would otherwise be making progress will transition to {@link |
| * Download#STATE_QUEUED}. |
| */ |
| public void pauseDownloads() { |
| setDownloadsPaused(/* downloadsPaused= */ true); |
| } |
| |
| /** |
| * Sets the stop reason for one or all downloads. To clear the stop reason, pass {@link |
| * Download#STOP_REASON_NONE}. |
| * |
| * @param id The content id of the download to update, or {@code null} to set the stop reason for |
| * all downloads. |
| * @param stopReason The stop reason, or {@link Download#STOP_REASON_NONE}. |
| */ |
| public void setStopReason(@Nullable String id, int stopReason) { |
| pendingMessages++; |
| internalHandler |
| .obtainMessage(MSG_SET_STOP_REASON, stopReason, /* unused */ 0, id) |
| .sendToTarget(); |
| } |
| |
| /** |
| * Adds a download defined by the given request. |
| * |
| * @param request The download request. |
| */ |
| public void addDownload(DownloadRequest request) { |
| addDownload(request, STOP_REASON_NONE); |
| } |
| |
| /** |
| * Adds a download defined by the given request and with the specified stop reason. |
| * |
| * @param request The download request. |
| * @param stopReason An initial stop reason for the download, or {@link Download#STOP_REASON_NONE} |
| * if the download should be started. |
| */ |
| public void addDownload(DownloadRequest request, int stopReason) { |
| pendingMessages++; |
| internalHandler |
| .obtainMessage(MSG_ADD_DOWNLOAD, stopReason, /* unused */ 0, request) |
| .sendToTarget(); |
| } |
| |
| /** |
| * Cancels the download with the {@code id} and removes all downloaded data. |
| * |
| * @param id The unique content id of the download to be started. |
| */ |
| public void removeDownload(String id) { |
| pendingMessages++; |
| internalHandler.obtainMessage(MSG_REMOVE_DOWNLOAD, id).sendToTarget(); |
| } |
| |
| /** Cancels all pending downloads and removes all downloaded data. */ |
| public void removeAllDownloads() { |
| pendingMessages++; |
| internalHandler.obtainMessage(MSG_REMOVE_ALL_DOWNLOADS).sendToTarget(); |
| } |
| |
| /** |
| * Stops the downloads and releases resources. Waits until the downloads are persisted to the |
| * download index. The manager must not be accessed after this method has been called. |
| */ |
| public void release() { |
| synchronized (internalHandler) { |
| if (internalHandler.released) { |
| return; |
| } |
| internalHandler.sendEmptyMessage(MSG_RELEASE); |
| boolean wasInterrupted = false; |
| while (!internalHandler.released) { |
| try { |
| internalHandler.wait(); |
| } catch (InterruptedException e) { |
| wasInterrupted = true; |
| } |
| } |
| if (wasInterrupted) { |
| // Restore the interrupted status. |
| Thread.currentThread().interrupt(); |
| } |
| mainHandler.removeCallbacksAndMessages(/* token= */ null); |
| // Reset state. |
| downloads = Collections.emptyList(); |
| pendingMessages = 0; |
| activeTaskCount = 0; |
| initialized = false; |
| notMetRequirements = 0; |
| waitingForRequirements = false; |
| } |
| } |
| |
| private void setDownloadsPaused(boolean downloadsPaused) { |
| if (this.downloadsPaused == downloadsPaused) { |
| return; |
| } |
| this.downloadsPaused = downloadsPaused; |
| pendingMessages++; |
| internalHandler |
| .obtainMessage(MSG_SET_DOWNLOADS_PAUSED, downloadsPaused ? 1 : 0, /* unused */ 0) |
| .sendToTarget(); |
| boolean waitingForRequirementsChanged = updateWaitingForRequirements(); |
| for (Listener listener : listeners) { |
| listener.onDownloadsPausedChanged(this, downloadsPaused); |
| } |
| if (waitingForRequirementsChanged) { |
| notifyWaitingForRequirementsChanged(); |
| } |
| } |
| |
| private void onRequirementsStateChanged( |
| RequirementsWatcher requirementsWatcher, |
| @Requirements.RequirementFlags int notMetRequirements) { |
| Requirements requirements = requirementsWatcher.getRequirements(); |
| if (this.notMetRequirements != notMetRequirements) { |
| this.notMetRequirements = notMetRequirements; |
| pendingMessages++; |
| internalHandler |
| .obtainMessage(MSG_SET_NOT_MET_REQUIREMENTS, notMetRequirements, /* unused */ 0) |
| .sendToTarget(); |
| } |
| boolean waitingForRequirementsChanged = updateWaitingForRequirements(); |
| for (Listener listener : listeners) { |
| listener.onRequirementsStateChanged(this, requirements, notMetRequirements); |
| } |
| if (waitingForRequirementsChanged) { |
| notifyWaitingForRequirementsChanged(); |
| } |
| } |
| |
| private boolean updateWaitingForRequirements() { |
| boolean waitingForRequirements = false; |
| if (!downloadsPaused && notMetRequirements != 0) { |
| for (int i = 0; i < downloads.size(); i++) { |
| if (downloads.get(i).state == STATE_QUEUED) { |
| waitingForRequirements = true; |
| break; |
| } |
| } |
| } |
| boolean waitingForRequirementsChanged = this.waitingForRequirements != waitingForRequirements; |
| this.waitingForRequirements = waitingForRequirements; |
| return waitingForRequirementsChanged; |
| } |
| |
| private void notifyWaitingForRequirementsChanged() { |
| for (Listener listener : listeners) { |
| listener.onWaitingForRequirementsChanged(this, waitingForRequirements); |
| } |
| } |
| |
| // Main thread message handling. |
| |
| @SuppressWarnings("unchecked") |
| private boolean handleMainMessage(Message message) { |
| switch (message.what) { |
| case MSG_INITIALIZED: |
| List<Download> downloads = (List<Download>) message.obj; |
| onInitialized(downloads); |
| break; |
| case MSG_DOWNLOAD_UPDATE: |
| DownloadUpdate update = (DownloadUpdate) message.obj; |
| onDownloadUpdate(update); |
| break; |
| case MSG_PROCESSED: |
| int processedMessageCount = message.arg1; |
| int activeTaskCount = message.arg2; |
| onMessageProcessed(processedMessageCount, activeTaskCount); |
| break; |
| default: |
| throw new IllegalStateException(); |
| } |
| return true; |
| } |
| |
| private void onInitialized(List<Download> downloads) { |
| initialized = true; |
| this.downloads = Collections.unmodifiableList(downloads); |
| boolean waitingForRequirementsChanged = updateWaitingForRequirements(); |
| for (Listener listener : listeners) { |
| listener.onInitialized(DownloadManager.this); |
| } |
| if (waitingForRequirementsChanged) { |
| notifyWaitingForRequirementsChanged(); |
| } |
| } |
| |
| private void onDownloadUpdate(DownloadUpdate update) { |
| downloads = Collections.unmodifiableList(update.downloads); |
| Download updatedDownload = update.download; |
| boolean waitingForRequirementsChanged = updateWaitingForRequirements(); |
| if (update.isRemove) { |
| for (Listener listener : listeners) { |
| listener.onDownloadRemoved(this, updatedDownload); |
| } |
| } else { |
| for (Listener listener : listeners) { |
| listener.onDownloadChanged(this, updatedDownload); |
| } |
| } |
| if (waitingForRequirementsChanged) { |
| notifyWaitingForRequirementsChanged(); |
| } |
| } |
| |
| private void onMessageProcessed(int processedMessageCount, int activeTaskCount) { |
| this.pendingMessages -= processedMessageCount; |
| this.activeTaskCount = activeTaskCount; |
| if (isIdle()) { |
| for (Listener listener : listeners) { |
| listener.onIdle(this); |
| } |
| } |
| } |
| |
| /* package */ static Download mergeRequest( |
| Download download, DownloadRequest request, int stopReason, long nowMs) { |
| @Download.State int state = download.state; |
| // Treat the merge as creating a new download if we're currently removing the existing one, or |
| // if the existing download is in a terminal state. Else treat the merge as updating the |
| // existing download. |
| long startTimeMs = |
| state == STATE_REMOVING || download.isTerminalState() ? nowMs : download.startTimeMs; |
| if (state == STATE_REMOVING || state == STATE_RESTARTING) { |
| state = STATE_RESTARTING; |
| } else if (stopReason != STOP_REASON_NONE) { |
| state = STATE_STOPPED; |
| } else { |
| state = STATE_QUEUED; |
| } |
| return new Download( |
| download.request.copyWithMergedRequest(request), |
| state, |
| startTimeMs, |
| /* updateTimeMs= */ nowMs, |
| /* contentLength= */ C.LENGTH_UNSET, |
| stopReason, |
| FAILURE_REASON_NONE); |
| } |
| |
| private static final class InternalHandler extends Handler { |
| |
| private static final int UPDATE_PROGRESS_INTERVAL_MS = 5000; |
| |
| public boolean released; |
| |
| private final HandlerThread thread; |
| private final WritableDownloadIndex downloadIndex; |
| private final DownloaderFactory downloaderFactory; |
| private final Handler mainHandler; |
| private final ArrayList<Download> downloads; |
| private final HashMap<String, Task> activeTasks; |
| |
| @Requirements.RequirementFlags private int notMetRequirements; |
| private boolean downloadsPaused; |
| private int maxParallelDownloads; |
| private int minRetryCount; |
| private int activeDownloadTaskCount; |
| |
| public InternalHandler( |
| HandlerThread thread, |
| WritableDownloadIndex downloadIndex, |
| DownloaderFactory downloaderFactory, |
| Handler mainHandler, |
| int maxParallelDownloads, |
| int minRetryCount, |
| boolean downloadsPaused) { |
| super(thread.getLooper()); |
| this.thread = thread; |
| this.downloadIndex = downloadIndex; |
| this.downloaderFactory = downloaderFactory; |
| this.mainHandler = mainHandler; |
| this.maxParallelDownloads = maxParallelDownloads; |
| this.minRetryCount = minRetryCount; |
| this.downloadsPaused = downloadsPaused; |
| downloads = new ArrayList<>(); |
| activeTasks = new HashMap<>(); |
| } |
| |
| @Override |
| public void handleMessage(Message message) { |
| boolean processedExternalMessage = true; |
| switch (message.what) { |
| case MSG_INITIALIZE: |
| int notMetRequirements = message.arg1; |
| initialize(notMetRequirements); |
| break; |
| case MSG_SET_DOWNLOADS_PAUSED: |
| boolean downloadsPaused = message.arg1 != 0; |
| setDownloadsPaused(downloadsPaused); |
| break; |
| case MSG_SET_NOT_MET_REQUIREMENTS: |
| notMetRequirements = message.arg1; |
| setNotMetRequirements(notMetRequirements); |
| break; |
| case MSG_SET_STOP_REASON: |
| String id = (String) message.obj; |
| int stopReason = message.arg1; |
| setStopReason(id, stopReason); |
| break; |
| case MSG_SET_MAX_PARALLEL_DOWNLOADS: |
| int maxParallelDownloads = message.arg1; |
| setMaxParallelDownloads(maxParallelDownloads); |
| break; |
| case MSG_SET_MIN_RETRY_COUNT: |
| int minRetryCount = message.arg1; |
| setMinRetryCount(minRetryCount); |
| break; |
| case MSG_ADD_DOWNLOAD: |
| DownloadRequest request = (DownloadRequest) message.obj; |
| stopReason = message.arg1; |
| addDownload(request, stopReason); |
| break; |
| case MSG_REMOVE_DOWNLOAD: |
| id = (String) message.obj; |
| removeDownload(id); |
| break; |
| case MSG_REMOVE_ALL_DOWNLOADS: |
| removeAllDownloads(); |
| break; |
| case MSG_TASK_STOPPED: |
| Task task = (Task) message.obj; |
| onTaskStopped(task); |
| processedExternalMessage = false; // This message is posted internally. |
| break; |
| case MSG_CONTENT_LENGTH_CHANGED: |
| task = (Task) message.obj; |
| onContentLengthChanged(task); |
| return; // No need to post back to mainHandler. |
| case MSG_UPDATE_PROGRESS: |
| updateProgress(); |
| return; // No need to post back to mainHandler. |
| case MSG_RELEASE: |
| release(); |
| return; // No need to post back to mainHandler. |
| default: |
| throw new IllegalStateException(); |
| } |
| mainHandler |
| .obtainMessage(MSG_PROCESSED, processedExternalMessage ? 1 : 0, activeTasks.size()) |
| .sendToTarget(); |
| } |
| |
| private void initialize(int notMetRequirements) { |
| this.notMetRequirements = notMetRequirements; |
| DownloadCursor cursor = null; |
| try { |
| downloadIndex.setDownloadingStatesToQueued(); |
| cursor = |
| downloadIndex.getDownloads( |
| STATE_QUEUED, STATE_STOPPED, STATE_DOWNLOADING, STATE_REMOVING, STATE_RESTARTING); |
| while (cursor.moveToNext()) { |
| downloads.add(cursor.getDownload()); |
| } |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to load index.", e); |
| downloads.clear(); |
| } finally { |
| Util.closeQuietly(cursor); |
| } |
| // A copy must be used for the message to ensure that subsequent changes to the downloads list |
| // are not visible to the main thread when it processes the message. |
| ArrayList<Download> downloadsForMessage = new ArrayList<>(downloads); |
| mainHandler.obtainMessage(MSG_INITIALIZED, downloadsForMessage).sendToTarget(); |
| syncTasks(); |
| } |
| |
| private void setDownloadsPaused(boolean downloadsPaused) { |
| this.downloadsPaused = downloadsPaused; |
| syncTasks(); |
| } |
| |
| private void setNotMetRequirements(@Requirements.RequirementFlags int notMetRequirements) { |
| this.notMetRequirements = notMetRequirements; |
| syncTasks(); |
| } |
| |
| private void setStopReason(@Nullable String id, int stopReason) { |
| if (id == null) { |
| for (int i = 0; i < downloads.size(); i++) { |
| setStopReason(downloads.get(i), stopReason); |
| } |
| try { |
| // Set the stop reason for downloads in terminal states as well. |
| downloadIndex.setStopReason(stopReason); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to set manual stop reason", e); |
| } |
| } else { |
| @Nullable Download download = getDownload(id, /* loadFromIndex= */ false); |
| if (download != null) { |
| setStopReason(download, stopReason); |
| } else { |
| try { |
| // Set the stop reason if the download is in a terminal state. |
| downloadIndex.setStopReason(id, stopReason); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to set manual stop reason: " + id, e); |
| } |
| } |
| } |
| syncTasks(); |
| } |
| |
| private void setStopReason(Download download, int stopReason) { |
| if (stopReason == STOP_REASON_NONE) { |
| if (download.state == STATE_STOPPED) { |
| putDownloadWithState(download, STATE_QUEUED); |
| } |
| } else if (stopReason != download.stopReason) { |
| @Download.State int state = download.state; |
| if (state == STATE_QUEUED || state == STATE_DOWNLOADING) { |
| state = STATE_STOPPED; |
| } |
| putDownload( |
| new Download( |
| download.request, |
| state, |
| download.startTimeMs, |
| /* updateTimeMs= */ System.currentTimeMillis(), |
| download.contentLength, |
| stopReason, |
| FAILURE_REASON_NONE, |
| download.progress)); |
| } |
| } |
| |
| private void setMaxParallelDownloads(int maxParallelDownloads) { |
| this.maxParallelDownloads = maxParallelDownloads; |
| syncTasks(); |
| } |
| |
| private void setMinRetryCount(int minRetryCount) { |
| this.minRetryCount = minRetryCount; |
| } |
| |
| private void addDownload(DownloadRequest request, int stopReason) { |
| @Nullable Download download = getDownload(request.id, /* loadFromIndex= */ true); |
| long nowMs = System.currentTimeMillis(); |
| if (download != null) { |
| putDownload(mergeRequest(download, request, stopReason, nowMs)); |
| } else { |
| putDownload( |
| new Download( |
| request, |
| stopReason != STOP_REASON_NONE ? STATE_STOPPED : STATE_QUEUED, |
| /* startTimeMs= */ nowMs, |
| /* updateTimeMs= */ nowMs, |
| /* contentLength= */ C.LENGTH_UNSET, |
| stopReason, |
| FAILURE_REASON_NONE)); |
| } |
| syncTasks(); |
| } |
| |
| private void removeDownload(String id) { |
| @Nullable Download download = getDownload(id, /* loadFromIndex= */ true); |
| if (download == null) { |
| Log.e(TAG, "Failed to remove nonexistent download: " + id); |
| return; |
| } |
| putDownloadWithState(download, STATE_REMOVING); |
| syncTasks(); |
| } |
| |
| private void removeAllDownloads() { |
| List<Download> terminalDownloads = new ArrayList<>(); |
| try (DownloadCursor cursor = downloadIndex.getDownloads(STATE_COMPLETED, STATE_FAILED)) { |
| while (cursor.moveToNext()) { |
| terminalDownloads.add(cursor.getDownload()); |
| } |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to load downloads."); |
| } |
| for (int i = 0; i < downloads.size(); i++) { |
| downloads.set(i, copyDownloadWithState(downloads.get(i), STATE_REMOVING)); |
| } |
| for (int i = 0; i < terminalDownloads.size(); i++) { |
| downloads.add(copyDownloadWithState(terminalDownloads.get(i), STATE_REMOVING)); |
| } |
| Collections.sort(downloads, InternalHandler::compareStartTimes); |
| try { |
| downloadIndex.setStatesToRemoving(); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to update index.", e); |
| } |
| ArrayList<Download> updateList = new ArrayList<>(downloads); |
| for (int i = 0; i < downloads.size(); i++) { |
| DownloadUpdate update = |
| new DownloadUpdate(downloads.get(i), /* isRemove= */ false, updateList); |
| mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); |
| } |
| syncTasks(); |
| } |
| |
| private void release() { |
| for (Task task : activeTasks.values()) { |
| task.cancel(/* released= */ true); |
| } |
| try { |
| downloadIndex.setDownloadingStatesToQueued(); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to update index.", e); |
| } |
| downloads.clear(); |
| thread.quit(); |
| synchronized (this) { |
| released = true; |
| notifyAll(); |
| } |
| } |
| |
| // Start and cancel tasks based on the current download and manager states. |
| |
| private void syncTasks() { |
| int accumulatingDownloadTaskCount = 0; |
| for (int i = 0; i < downloads.size(); i++) { |
| Download download = downloads.get(i); |
| @Nullable Task activeTask = activeTasks.get(download.request.id); |
| switch (download.state) { |
| case STATE_STOPPED: |
| syncStoppedDownload(activeTask); |
| break; |
| case STATE_QUEUED: |
| activeTask = syncQueuedDownload(activeTask, download); |
| break; |
| case STATE_DOWNLOADING: |
| Assertions.checkNotNull(activeTask); |
| syncDownloadingDownload(activeTask, download, accumulatingDownloadTaskCount); |
| break; |
| case STATE_REMOVING: |
| case STATE_RESTARTING: |
| syncRemovingDownload(activeTask, download); |
| break; |
| case STATE_COMPLETED: |
| case STATE_FAILED: |
| default: |
| throw new IllegalStateException(); |
| } |
| if (activeTask != null && !activeTask.isRemove) { |
| accumulatingDownloadTaskCount++; |
| } |
| } |
| } |
| |
| private void syncStoppedDownload(@Nullable Task activeTask) { |
| if (activeTask != null) { |
| // We have a task, which must be a download task. Cancel it. |
| Assertions.checkState(!activeTask.isRemove); |
| activeTask.cancel(/* released= */ false); |
| } |
| } |
| |
| @Nullable |
| @CheckResult |
| private Task syncQueuedDownload(@Nullable Task activeTask, Download download) { |
| if (activeTask != null) { |
| // We have a task, which must be a download task. If the download state is queued we need to |
| // cancel it and start a new one, since a new request has been merged into the download. |
| Assertions.checkState(!activeTask.isRemove); |
| activeTask.cancel(/* released= */ false); |
| return activeTask; |
| } |
| |
| if (!canDownloadsRun() || activeDownloadTaskCount >= maxParallelDownloads) { |
| return null; |
| } |
| |
| // We can start a download task. |
| download = putDownloadWithState(download, STATE_DOWNLOADING); |
| Downloader downloader = downloaderFactory.createDownloader(download.request); |
| activeTask = |
| new Task( |
| download.request, |
| downloader, |
| download.progress, |
| /* isRemove= */ false, |
| minRetryCount, |
| /* internalHandler= */ this); |
| activeTasks.put(download.request.id, activeTask); |
| if (activeDownloadTaskCount++ == 0) { |
| sendEmptyMessageDelayed(MSG_UPDATE_PROGRESS, UPDATE_PROGRESS_INTERVAL_MS); |
| } |
| activeTask.start(); |
| return activeTask; |
| } |
| |
| private void syncDownloadingDownload( |
| Task activeTask, Download download, int accumulatingDownloadTaskCount) { |
| Assertions.checkState(!activeTask.isRemove); |
| if (!canDownloadsRun() || accumulatingDownloadTaskCount >= maxParallelDownloads) { |
| putDownloadWithState(download, STATE_QUEUED); |
| activeTask.cancel(/* released= */ false); |
| } |
| } |
| |
| private void syncRemovingDownload(@Nullable Task activeTask, Download download) { |
| if (activeTask != null) { |
| if (!activeTask.isRemove) { |
| // Cancel the downloading task. |
| activeTask.cancel(/* released= */ false); |
| } |
| // The activeTask is either a remove task, or a downloading task that we just cancelled. In |
| // the latter case we need to wait for the task to stop before we start a remove task. |
| return; |
| } |
| |
| // We can start a remove task. |
| Downloader downloader = downloaderFactory.createDownloader(download.request); |
| activeTask = |
| new Task( |
| download.request, |
| downloader, |
| download.progress, |
| /* isRemove= */ true, |
| minRetryCount, |
| /* internalHandler= */ this); |
| activeTasks.put(download.request.id, activeTask); |
| activeTask.start(); |
| } |
| |
| // Task event processing. |
| |
| private void onContentLengthChanged(Task task) { |
| String downloadId = task.request.id; |
| long contentLength = task.contentLength; |
| Download download = |
| Assertions.checkNotNull(getDownload(downloadId, /* loadFromIndex= */ false)); |
| if (contentLength == download.contentLength || contentLength == C.LENGTH_UNSET) { |
| return; |
| } |
| putDownload( |
| new Download( |
| download.request, |
| download.state, |
| download.startTimeMs, |
| /* updateTimeMs= */ System.currentTimeMillis(), |
| contentLength, |
| download.stopReason, |
| download.failureReason, |
| download.progress)); |
| } |
| |
| private void onTaskStopped(Task task) { |
| String downloadId = task.request.id; |
| activeTasks.remove(downloadId); |
| |
| boolean isRemove = task.isRemove; |
| if (!isRemove && --activeDownloadTaskCount == 0) { |
| removeMessages(MSG_UPDATE_PROGRESS); |
| } |
| |
| if (task.isCanceled) { |
| syncTasks(); |
| return; |
| } |
| |
| @Nullable Throwable finalError = task.finalError; |
| if (finalError != null) { |
| Log.e(TAG, "Task failed: " + task.request + ", " + isRemove, finalError); |
| } |
| |
| Download download = |
| Assertions.checkNotNull(getDownload(downloadId, /* loadFromIndex= */ false)); |
| switch (download.state) { |
| case STATE_DOWNLOADING: |
| Assertions.checkState(!isRemove); |
| onDownloadTaskStopped(download, finalError); |
| break; |
| case STATE_REMOVING: |
| case STATE_RESTARTING: |
| Assertions.checkState(isRemove); |
| onRemoveTaskStopped(download); |
| break; |
| case STATE_QUEUED: |
| case STATE_STOPPED: |
| case STATE_COMPLETED: |
| case STATE_FAILED: |
| default: |
| throw new IllegalStateException(); |
| } |
| |
| syncTasks(); |
| } |
| |
| private void onDownloadTaskStopped(Download download, @Nullable Throwable finalError) { |
| download = |
| new Download( |
| download.request, |
| finalError == null ? STATE_COMPLETED : STATE_FAILED, |
| download.startTimeMs, |
| /* updateTimeMs= */ System.currentTimeMillis(), |
| download.contentLength, |
| download.stopReason, |
| finalError == null ? FAILURE_REASON_NONE : FAILURE_REASON_UNKNOWN, |
| download.progress); |
| // The download is now in a terminal state, so should not be in the downloads list. |
| downloads.remove(getDownloadIndex(download.request.id)); |
| // We still need to update the download index and main thread. |
| try { |
| downloadIndex.putDownload(download); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to update index.", e); |
| } |
| DownloadUpdate update = |
| new DownloadUpdate(download, /* isRemove= */ false, new ArrayList<>(downloads)); |
| mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); |
| } |
| |
| private void onRemoveTaskStopped(Download download) { |
| if (download.state == STATE_RESTARTING) { |
| putDownloadWithState( |
| download, download.stopReason == STOP_REASON_NONE ? STATE_QUEUED : STATE_STOPPED); |
| syncTasks(); |
| } else { |
| int removeIndex = getDownloadIndex(download.request.id); |
| downloads.remove(removeIndex); |
| try { |
| downloadIndex.removeDownload(download.request.id); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to remove from database"); |
| } |
| DownloadUpdate update = |
| new DownloadUpdate(download, /* isRemove= */ true, new ArrayList<>(downloads)); |
| mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); |
| } |
| } |
| |
| // Progress updates. |
| |
| private void updateProgress() { |
| for (int i = 0; i < downloads.size(); i++) { |
| Download download = downloads.get(i); |
| if (download.state == STATE_DOWNLOADING) { |
| try { |
| downloadIndex.putDownload(download); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to update index.", e); |
| } |
| } |
| } |
| sendEmptyMessageDelayed(MSG_UPDATE_PROGRESS, UPDATE_PROGRESS_INTERVAL_MS); |
| } |
| |
| // Helper methods. |
| |
| private boolean canDownloadsRun() { |
| return !downloadsPaused && notMetRequirements == 0; |
| } |
| |
| private Download putDownloadWithState(Download download, @Download.State int state) { |
| // Downloads in terminal states shouldn't be in the downloads list. This method cannot be used |
| // to set STATE_STOPPED either, because it doesn't have a stopReason argument. |
| Assertions.checkState( |
| state != STATE_COMPLETED && state != STATE_FAILED && state != STATE_STOPPED); |
| return putDownload(copyDownloadWithState(download, state)); |
| } |
| |
| private Download putDownload(Download download) { |
| // Downloads in terminal states shouldn't be in the downloads list. |
| Assertions.checkState(download.state != STATE_COMPLETED && download.state != STATE_FAILED); |
| int changedIndex = getDownloadIndex(download.request.id); |
| if (changedIndex == C.INDEX_UNSET) { |
| downloads.add(download); |
| Collections.sort(downloads, InternalHandler::compareStartTimes); |
| } else { |
| boolean needsSort = download.startTimeMs != downloads.get(changedIndex).startTimeMs; |
| downloads.set(changedIndex, download); |
| if (needsSort) { |
| Collections.sort(downloads, InternalHandler::compareStartTimes); |
| } |
| } |
| try { |
| downloadIndex.putDownload(download); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to update index.", e); |
| } |
| DownloadUpdate update = |
| new DownloadUpdate(download, /* isRemove= */ false, new ArrayList<>(downloads)); |
| mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); |
| return download; |
| } |
| |
| @Nullable |
| private Download getDownload(String id, boolean loadFromIndex) { |
| int index = getDownloadIndex(id); |
| if (index != C.INDEX_UNSET) { |
| return downloads.get(index); |
| } |
| if (loadFromIndex) { |
| try { |
| return downloadIndex.getDownload(id); |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to load download: " + id, e); |
| } |
| } |
| return null; |
| } |
| |
| private int getDownloadIndex(String id) { |
| for (int i = 0; i < downloads.size(); i++) { |
| Download download = downloads.get(i); |
| if (download.request.id.equals(id)) { |
| return i; |
| } |
| } |
| return C.INDEX_UNSET; |
| } |
| |
| private static Download copyDownloadWithState(Download download, @Download.State int state) { |
| return new Download( |
| download.request, |
| state, |
| download.startTimeMs, |
| /* updateTimeMs= */ System.currentTimeMillis(), |
| download.contentLength, |
| /* stopReason= */ 0, |
| FAILURE_REASON_NONE, |
| download.progress); |
| } |
| |
| private static int compareStartTimes(Download first, Download second) { |
| return Util.compareLong(first.startTimeMs, second.startTimeMs); |
| } |
| } |
| |
| private static class Task extends Thread implements Downloader.ProgressListener { |
| |
| private final DownloadRequest request; |
| private final Downloader downloader; |
| private final DownloadProgress downloadProgress; |
| private final boolean isRemove; |
| private final int minRetryCount; |
| |
| @Nullable private volatile InternalHandler internalHandler; |
| private volatile boolean isCanceled; |
| @Nullable private Throwable finalError; |
| |
| private long contentLength; |
| |
| private Task( |
| DownloadRequest request, |
| Downloader downloader, |
| DownloadProgress downloadProgress, |
| boolean isRemove, |
| int minRetryCount, |
| InternalHandler internalHandler) { |
| this.request = request; |
| this.downloader = downloader; |
| this.downloadProgress = downloadProgress; |
| this.isRemove = isRemove; |
| this.minRetryCount = minRetryCount; |
| this.internalHandler = internalHandler; |
| contentLength = C.LENGTH_UNSET; |
| } |
| |
| @SuppressWarnings("nullness:assignment.type.incompatible") |
| public void cancel(boolean released) { |
| if (released) { |
| // Download threads are GC roots for as long as they're running. The time taken for |
| // cancellation to complete depends on the implementation of the downloader being used. We |
| // null the handler reference here so that it doesn't prevent garbage collection of the |
| // download manager whilst cancellation is ongoing. |
| internalHandler = null; |
| } |
| if (!isCanceled) { |
| isCanceled = true; |
| downloader.cancel(); |
| interrupt(); |
| } |
| } |
| |
| // Methods running on download thread. |
| |
| @Override |
| public void run() { |
| try { |
| if (isRemove) { |
| downloader.remove(); |
| } else { |
| int errorCount = 0; |
| long errorPosition = C.LENGTH_UNSET; |
| while (!isCanceled) { |
| try { |
| downloader.download(/* progressListener= */ this); |
| break; |
| } catch (IOException e) { |
| if (!isCanceled) { |
| long bytesDownloaded = downloadProgress.bytesDownloaded; |
| if (bytesDownloaded != errorPosition) { |
| errorPosition = bytesDownloaded; |
| errorCount = 0; |
| } |
| if (++errorCount > minRetryCount) { |
| throw e; |
| } |
| Thread.sleep(getRetryDelayMillis(errorCount)); |
| } |
| } |
| } |
| } |
| } catch (Throwable e) { |
| finalError = e; |
| } |
| @Nullable Handler internalHandler = this.internalHandler; |
| if (internalHandler != null) { |
| internalHandler.obtainMessage(MSG_TASK_STOPPED, this).sendToTarget(); |
| } |
| } |
| |
| @Override |
| public void onProgress(long contentLength, long bytesDownloaded, float percentDownloaded) { |
| downloadProgress.bytesDownloaded = bytesDownloaded; |
| downloadProgress.percentDownloaded = percentDownloaded; |
| if (contentLength != this.contentLength) { |
| this.contentLength = contentLength; |
| @Nullable Handler internalHandler = this.internalHandler; |
| if (internalHandler != null) { |
| internalHandler.obtainMessage(MSG_CONTENT_LENGTH_CHANGED, this).sendToTarget(); |
| } |
| } |
| } |
| |
| private static int getRetryDelayMillis(int errorCount) { |
| return Math.min((errorCount - 1) * 1000, 5000); |
| } |
| } |
| |
| private static final class DownloadUpdate { |
| |
| public final Download download; |
| public final boolean isRemove; |
| public final List<Download> downloads; |
| |
| public DownloadUpdate(Download download, boolean isRemove, List<Download> downloads) { |
| this.download = download; |
| this.isRemove = isRemove; |
| this.downloads = downloads; |
| } |
| } |
| } |