diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 01ddda93f89..1171d35411c 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -89,6 +89,10 @@
* `EventLogger` moved from the demo app into the core library.
* Fix ANR issue on Huawei P8 Lite
([#3724](https://github.com/google/ExoPlayer/issues/3724)).
+* Fix potential NPE when removing media sources from a
+ DynamicConcatenatingMediaSource
+ ([#3796](https://github.com/google/ExoPlayer/issues/3796)).
+* Open source DownloadService, DownloadManager and related classes.
### 2.6.1 ###
diff --git a/core_settings.gradle b/core_settings.gradle
index 20a7c87bde2..5687f193962 100644
--- a/core_settings.gradle
+++ b/core_settings.gradle
@@ -35,6 +35,7 @@ include modulePrefix + 'extension-opus'
include modulePrefix + 'extension-vp9'
include modulePrefix + 'extension-rtmp'
include modulePrefix + 'extension-leanback'
+include modulePrefix + 'extension-jobdispatcher'
project(modulePrefix + 'library').projectDir = new File(rootDir, 'library/all')
project(modulePrefix + 'library-core').projectDir = new File(rootDir, 'library/core')
@@ -54,6 +55,7 @@ project(modulePrefix + 'extension-opus').projectDir = new File(rootDir, 'extensi
project(modulePrefix + 'extension-vp9').projectDir = new File(rootDir, 'extensions/vp9')
project(modulePrefix + 'extension-rtmp').projectDir = new File(rootDir, 'extensions/rtmp')
project(modulePrefix + 'extension-leanback').projectDir = new File(rootDir, 'extensions/leanback')
+project(modulePrefix + 'extension-jobdispatcher').projectDir = new File(rootDir, 'extensions/jobdispatcher')
if (gradle.ext.has('exoplayerIncludeCronetExtension')
&& gradle.ext.exoplayerIncludeCronetExtension) {
diff --git a/extensions/jobdispatcher/README.md b/extensions/jobdispatcher/README.md
new file mode 100644
index 00000000000..d9efc770123
--- /dev/null
+++ b/extensions/jobdispatcher/README.md
@@ -0,0 +1,23 @@
+# ExoPlayer Firebase JobDispatcher extension #
+
+This extension provides a Scheduler implementation which uses [Firebase JobDispatcher][].
+
+[Firebase JobDispatcher]: https://github.com/firebase/firebase-jobdispatcher-android
+
+## Getting the extension ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+compile 'com.google.android.exoplayer:extension-jobdispatcher:rX.X.X'
+```
+
+where `rX.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
diff --git a/extensions/jobdispatcher/build.gradle b/extensions/jobdispatcher/build.gradle
new file mode 100644
index 00000000000..fd5fce9ec85
--- /dev/null
+++ b/extensions/jobdispatcher/build.gradle
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2018 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.
+ */
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion project.ext.compileSdkVersion
+ buildToolsVersion project.ext.buildToolsVersion
+
+ defaultConfig {
+ minSdkVersion project.ext.minSdkVersion
+ targetSdkVersion project.ext.targetSdkVersion
+ }
+}
+
+dependencies {
+ compile project(modulePrefix + 'library-core')
+ compile 'com.firebase:firebase-jobdispatcher:0.8.5'
+}
+
+ext {
+ javadocTitle = 'Firebase JobDispatcher extension'
+}
+apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-jobdispatcher'
+ releaseDescription = 'Firebase JobDispatcher extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/jobdispatcher/src/main/AndroidManifest.xml b/extensions/jobdispatcher/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..306a087e6c8
--- /dev/null
+++ b/extensions/jobdispatcher/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
To use {@link JobDispatcherScheduler} application needs to have RECEIVE_BOOT_COMPLETED + * permission and you need to define JobDispatcherSchedulerService in your manifest: + * + *
{@literal + *+ * + * The service to be scheduled must be defined in the manifest with an intent-filter: + * + *+ * + * + * + * }+ * + *+ *
{@literal + *+ * + *+ * + * }+ * + *+ * + *
This Scheduler uses Google Play services but does not do any availability checks. Any uses
+ * should be guarded with a call to {@code
+ * GoogleApiAvailability#isGooglePlayServicesAvailable(android.content.Context)}
+ *
+ * @see GoogleApiAvailability
+ */
+public final class JobDispatcherScheduler implements Scheduler {
+
+ private static final String TAG = "JobDispatcherScheduler";
+ private static final String SERVICE_ACTION = "SERVICE_ACTION";
+ private static final String SERVICE_PACKAGE = "SERVICE_PACKAGE";
+ private static final String REQUIREMENTS = "REQUIREMENTS";
+
+ private final String jobTag;
+ private final Job job;
+ private final FirebaseJobDispatcher jobDispatcher;
+
+ /**
+ * @param context Used to create a {@link FirebaseJobDispatcher} service.
+ * @param requirements The requirements to execute the job.
+ * @param jobTag Unique tag for the job. Using the same tag as a previous job can cause that job
+ * to be replaced or canceled.
+ * @param serviceAction The action which the service will be started with.
+ * @param servicePackage The package of the service which contains the logic of the job.
+ */
+ public JobDispatcherScheduler(
+ Context context,
+ Requirements requirements,
+ String jobTag,
+ String serviceAction,
+ String servicePackage) {
+ this.jobDispatcher = new FirebaseJobDispatcher(new GooglePlayDriver(context));
+ this.jobTag = jobTag;
+ this.job = buildJob(jobDispatcher, requirements, jobTag, serviceAction, servicePackage);
+ }
+
+ @Override
+ public boolean schedule() {
+ int result = jobDispatcher.schedule(job);
+ logd("Scheduling JobDispatcher job: " + jobTag + " result: " + result);
+ return result == FirebaseJobDispatcher.SCHEDULE_RESULT_SUCCESS;
+ }
+
+ @Override
+ public boolean cancel() {
+ int result = jobDispatcher.cancel(jobTag);
+ logd("Canceling JobDispatcher job: " + jobTag + " result: " + result);
+ return result == FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS;
+ }
+
+ private static Job buildJob(
+ FirebaseJobDispatcher dispatcher,
+ Requirements requirements,
+ String tag,
+ String serviceAction,
+ String servicePackage) {
+ Builder builder =
+ dispatcher
+ .newJobBuilder()
+ .setService(JobDispatcherSchedulerService.class) // the JobService that will be called
+ .setTag(tag);
+
+ switch (requirements.getRequiredNetworkType()) {
+ case Requirements.NETWORK_TYPE_NONE:
+ // do nothing.
+ break;
+ case Requirements.NETWORK_TYPE_ANY:
+ builder.addConstraint(Constraint.ON_ANY_NETWORK);
+ break;
+ case Requirements.NETWORK_TYPE_UNMETERED:
+ builder.addConstraint(Constraint.ON_UNMETERED_NETWORK);
+ break;
+ default:
+ throw new UnsupportedOperationException();
+ }
+
+ if (requirements.isIdleRequired()) {
+ builder.addConstraint(Constraint.DEVICE_IDLE);
+ }
+ if (requirements.isChargingRequired()) {
+ builder.addConstraint(Constraint.DEVICE_CHARGING);
+ }
+ builder.setLifetime(Lifetime.FOREVER).setReplaceCurrent(true);
+
+ // Extras, work duration.
+ Bundle extras = new Bundle();
+ extras.putString(SERVICE_ACTION, serviceAction);
+ extras.putString(SERVICE_PACKAGE, servicePackage);
+ extras.putInt(REQUIREMENTS, requirements.getRequirementsData());
+
+ builder.setExtras(extras);
+ return builder.build();
+ }
+
+ private static void logd(String message) {
+ if (DEBUG) {
+ Log.d(TAG, message);
+ }
+ }
+
+ /** A {@link JobService} to start a service if the requirements are met. */
+ public static final class JobDispatcherSchedulerService extends JobService {
+ @Override
+ public boolean onStartJob(JobParameters params) {
+ logd("JobDispatcherSchedulerService is started");
+ Bundle extras = params.getExtras();
+ Requirements requirements = new Requirements(extras.getInt(REQUIREMENTS));
+ if (requirements.checkRequirements(this)) {
+ logd("requirements are met");
+ String serviceAction = extras.getString(SERVICE_ACTION);
+ String servicePackage = extras.getString(SERVICE_PACKAGE);
+ Intent intent = new Intent(serviceAction).setPackage(servicePackage);
+ logd("starting service action: " + serviceAction + " package: " + servicePackage);
+ if (Util.SDK_INT >= 26) {
+ startForegroundService(intent);
+ } else {
+ startService(intent);
+ }
+ } else {
+ logd("requirements are not met");
+ jobFinished(params, /* needsReschedule */ true);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters params) {
+ return false;
+ }
+ }
+}
diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java b/library/core/src/androidTest/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java
new file mode 100644
index 00000000000..85af6c32cd1
--- /dev/null
+++ b/library/core/src/androidTest/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java
@@ -0,0 +1,707 @@
+/*
+ * 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.common.truth.Truth.assertThat;
+
+import android.os.ConditionVariable;
+import android.support.annotation.Nullable;
+import android.test.InstrumentationTestCase;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.offline.DownloadManager.DownloadListener;
+import com.google.android.exoplayer2.offline.DownloadManager.DownloadState;
+import com.google.android.exoplayer2.offline.DownloadManager.DownloadState.State;
+import com.google.android.exoplayer2.testutil.MockitoUtil;
+import com.google.android.exoplayer2.upstream.DummyDataSource;
+import com.google.android.exoplayer2.upstream.cache.Cache;
+import com.google.android.exoplayer2.util.Util;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Locale;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.TimeUnit;
+import org.mockito.Mockito;
+
+/** Tests {@link DownloadManager}. */
+public class DownloadManagerTest extends InstrumentationTestCase {
+
+ /* Used to check if condition becomes true in this time interval. */
+ private static final int ASSERT_TRUE_TIMEOUT = 10000;
+ /* Used to check if condition stays false for this time interval. */
+ private static final int ASSERT_FALSE_TIME = 1000;
+ /* Maximum retry delay in DownloadManager. */
+ private static final int MAX_RETRY_DELAY = 5000;
+
+ private static final int MIN_RETRY_COUNT = 3;
+
+ private DownloadManager downloadManager;
+ private File actionFile;
+ private TestDownloadListener testDownloadListener;
+
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ MockitoUtil.setUpMockito(this);
+
+ actionFile = Util.createTempFile(getInstrumentation().getContext(), "ExoPlayerTest");
+ testDownloadListener = new TestDownloadListener();
+ setUpDownloadManager(100);
+ }
+
+ @Override
+ public void tearDown() throws Exception {
+ releaseDownloadManager();
+ actionFile.delete();
+ super.tearDown();
+ }
+
+ private void setUpDownloadManager(final int maxActiveDownloadTasks) throws Exception {
+ if (downloadManager != null) {
+ releaseDownloadManager();
+ }
+ try {
+ runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ downloadManager =
+ new DownloadManager(
+ new DownloaderConstructorHelper(
+ Mockito.mock(Cache.class), DummyDataSource.FACTORY),
+ maxActiveDownloadTasks,
+ MIN_RETRY_COUNT,
+ actionFile.getAbsolutePath());
+ downloadManager.addListener(testDownloadListener);
+ downloadManager.startDownloads();
+ }
+ });
+ } catch (Throwable throwable) {
+ throw new Exception(throwable);
+ }
+ }
+
+ private void releaseDownloadManager() throws Exception {
+ try {
+ runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ downloadManager.release();
+ }
+ });
+ } catch (Throwable throwable) {
+ throw new Exception(throwable);
+ }
+ }
+
+ public void testDownloadActionRuns() throws Throwable {
+ doTestActionRuns(createDownloadAction("media 1"));
+ }
+
+ public void testRemoveActionRuns() throws Throwable {
+ doTestActionRuns(createRemoveAction("media 1"));
+ }
+
+ public void testDownloadRetriesThenFails() throws Throwable {
+ FakeDownloadAction downloadAction = createDownloadAction("media 1");
+ downloadAction.post();
+ FakeDownloader fakeDownloader = downloadAction.getFakeDownloader();
+ fakeDownloader.enableDownloadIOException = true;
+ for (int i = 0; i <= MIN_RETRY_COUNT; i++) {
+ fakeDownloader.assertStarted(MAX_RETRY_DELAY).unblock();
+ }
+ downloadAction.assertError();
+ testDownloadListener.clearDownloadError();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ public void testDownloadNoRetryWhenCancelled() throws Throwable {
+ FakeDownloadAction downloadAction = createDownloadAction("media 1").ignoreInterrupts();
+ downloadAction.getFakeDownloader().enableDownloadIOException = true;
+ downloadAction.post().assertStarted();
+
+ FakeDownloadAction removeAction = createRemoveAction("media 1").post();
+
+ downloadAction.unblock().assertCancelled();
+ removeAction.unblock();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ public void testDownloadRetriesThenContinues() throws Throwable {
+ FakeDownloadAction downloadAction = createDownloadAction("media 1");
+ downloadAction.post();
+ FakeDownloader fakeDownloader = downloadAction.getFakeDownloader();
+ fakeDownloader.enableDownloadIOException = true;
+ for (int i = 0; i <= MIN_RETRY_COUNT; i++) {
+ fakeDownloader.assertStarted(MAX_RETRY_DELAY);
+ if (i == MIN_RETRY_COUNT) {
+ fakeDownloader.enableDownloadIOException = false;
+ }
+ fakeDownloader.unblock();
+ }
+ downloadAction.assertEnded();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ @SuppressWarnings({"NonAtomicVolatileUpdate", "NonAtomicOperationOnVolatileField"})
+ public void testDownloadRetryCountResetsOnProgress() throws Throwable {
+ FakeDownloadAction downloadAction = createDownloadAction("media 1");
+ downloadAction.post();
+ FakeDownloader fakeDownloader = downloadAction.getFakeDownloader();
+ fakeDownloader.enableDownloadIOException = true;
+ fakeDownloader.downloadedBytes = 0;
+ for (int i = 0; i <= MIN_RETRY_COUNT + 10; i++) {
+ fakeDownloader.assertStarted(MAX_RETRY_DELAY);
+ fakeDownloader.downloadedBytes++;
+ if (i == MIN_RETRY_COUNT + 10) {
+ fakeDownloader.enableDownloadIOException = false;
+ }
+ fakeDownloader.unblock();
+ }
+ downloadAction.assertEnded();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ public void testDifferentMediaDownloadActionsStartInParallel() throws Throwable {
+ doTestActionsRunInParallel(createDownloadAction("media 1"),
+ createDownloadAction("media 2"));
+ }
+
+ public void testDifferentMediaDifferentActionsStartInParallel() throws Throwable {
+ doTestActionsRunInParallel(createDownloadAction("media 1"),
+ createRemoveAction("media 2"));
+ }
+
+ public void testSameMediaDownloadActionsStartInParallel() throws Throwable {
+ doTestActionsRunInParallel(createDownloadAction("media 1"),
+ createDownloadAction("media 1"));
+ }
+
+ public void testSameMediaRemoveActionWaitsDownloadAction() throws Throwable {
+ doTestActionsRunSequentially(createDownloadAction("media 1"),
+ createRemoveAction("media 1"));
+ }
+
+ public void testSameMediaDownloadActionWaitsRemoveAction() throws Throwable {
+ doTestActionsRunSequentially(createRemoveAction("media 1"),
+ createDownloadAction("media 1"));
+ }
+
+ public void testSameMediaRemoveActionWaitsRemoveAction() throws Throwable {
+ doTestActionsRunSequentially(createRemoveAction("media 1"),
+ createRemoveAction("media 1"));
+ }
+
+ public void testSameMediaMultipleActions() throws Throwable {
+ FakeDownloadAction downloadAction1 = createDownloadAction("media 1").ignoreInterrupts();
+ FakeDownloadAction downloadAction2 = createDownloadAction("media 1").ignoreInterrupts();
+ FakeDownloadAction removeAction1 = createRemoveAction("media 1");
+ FakeDownloadAction downloadAction3 = createDownloadAction("media 1");
+ FakeDownloadAction removeAction2 = createRemoveAction("media 1");
+
+ // Two download actions run in parallel.
+ downloadAction1.post().assertStarted();
+ downloadAction2.post().assertStarted();
+ // removeAction1 is added. It interrupts the two download actions' threads but they are
+ // configured to ignore it so removeAction1 doesn't start.
+ removeAction1.post().assertDoesNotStart();
+
+ // downloadAction2 finishes but it isn't enough to start removeAction1.
+ downloadAction2.unblock().assertCancelled();
+ removeAction1.assertDoesNotStart();
+ // downloadAction3 is post to DownloadManager but it waits for removeAction1 to finish.
+ downloadAction3.post().assertDoesNotStart();
+
+ // When downloadAction1 finishes, removeAction1 starts.
+ downloadAction1.unblock().assertCancelled();
+ removeAction1.assertStarted();
+ // downloadAction3 still waits removeAction1
+ downloadAction3.assertDoesNotStart();
+
+ // removeAction2 is posted. removeAction1 and downloadAction3 is canceled so removeAction2
+ // starts immediately.
+ removeAction2.post();
+ removeAction1.assertCancelled();
+ downloadAction3.assertCancelled();
+ removeAction2.assertStarted().unblock().assertEnded();
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ public void testMultipleRemoveActionWaitsLastCancelsAllOther() throws Throwable {
+ FakeDownloadAction removeAction1 = createRemoveAction("media 1").ignoreInterrupts();
+ FakeDownloadAction removeAction2 = createRemoveAction("media 1");
+ FakeDownloadAction removeAction3 = createRemoveAction("media 1");
+
+ removeAction1.post().assertStarted();
+ removeAction2.post().assertDoesNotStart();
+ removeAction3.post().assertDoesNotStart();
+
+ removeAction2.assertCancelled();
+
+ removeAction1.unblock().assertCancelled();
+ removeAction3.assertStarted().unblock().assertEnded();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ public void testGetTasks() throws Throwable {
+ FakeDownloadAction removeAction = createRemoveAction("media 1");
+ FakeDownloadAction downloadAction1 = createDownloadAction("media 1");
+ FakeDownloadAction downloadAction2 = createDownloadAction("media 1");
+
+ removeAction.post().assertStarted();
+ downloadAction1.post().assertDoesNotStart();
+ downloadAction2.post().assertDoesNotStart();
+
+ DownloadState[] states = downloadManager.getDownloadStates();
+ assertThat(states).hasLength(3);
+ assertThat(states[0].downloadAction).isEqualTo(removeAction);
+ assertThat(states[1].downloadAction).isEqualTo(downloadAction1);
+ assertThat(states[2].downloadAction).isEqualTo(downloadAction2);
+ }
+
+ public void testMultipleWaitingDownloadActionStartsInParallel() throws Throwable {
+ FakeDownloadAction removeAction = createRemoveAction("media 1");
+ FakeDownloadAction downloadAction1 = createDownloadAction("media 1");
+ FakeDownloadAction downloadAction2 = createDownloadAction("media 1");
+
+ removeAction.post().assertStarted();
+ downloadAction1.post().assertDoesNotStart();
+ downloadAction2.post().assertDoesNotStart();
+
+ removeAction.unblock().assertEnded();
+ downloadAction1.assertStarted();
+ downloadAction2.assertStarted();
+ downloadAction1.unblock().assertEnded();
+ downloadAction2.unblock().assertEnded();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ public void testDifferentMediaDownloadActionsPreserveOrder() throws Throwable {
+ FakeDownloadAction removeAction = createRemoveAction("media 1").ignoreInterrupts();
+ FakeDownloadAction downloadAction1 = createDownloadAction("media 1");
+ FakeDownloadAction downloadAction2 = createDownloadAction("media 2");
+
+ removeAction.post().assertStarted();
+ downloadAction1.post().assertDoesNotStart();
+ downloadAction2.post().assertDoesNotStart();
+
+ removeAction.unblock().assertEnded();
+ downloadAction1.assertStarted();
+ downloadAction2.assertStarted();
+ downloadAction1.unblock().assertEnded();
+ downloadAction2.unblock().assertEnded();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ public void testDifferentMediaRemoveActionsDoNotPreserveOrder() throws Throwable {
+ FakeDownloadAction downloadAction = createDownloadAction("media 1").ignoreInterrupts();
+ FakeDownloadAction removeAction1 = createRemoveAction("media 1");
+ FakeDownloadAction removeAction2 = createRemoveAction("media 2");
+
+ downloadAction.post().assertStarted();
+ removeAction1.post().assertDoesNotStart();
+ removeAction2.post().assertStarted();
+
+ downloadAction.unblock().assertCancelled();
+ removeAction2.unblock().assertEnded();
+
+ removeAction1.assertStarted();
+ removeAction1.unblock().assertEnded();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ public void testStopAndResume() throws Throwable {
+ FakeDownloadAction download1Action = createDownloadAction("media 1");
+ FakeDownloadAction remove2Action = createRemoveAction("media 2");
+ FakeDownloadAction download2Action = createDownloadAction("media 2");
+ FakeDownloadAction remove1Action = createRemoveAction("media 1");
+ FakeDownloadAction download3Action = createDownloadAction("media 3");
+
+ download1Action.post().assertStarted();
+ remove2Action.post().assertStarted();
+ download2Action.post().assertDoesNotStart();
+
+ runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ downloadManager.stopDownloads();
+ }
+ });
+
+ download1Action.assertStopped();
+
+ // remove actions aren't stopped.
+ remove2Action.unblock().assertEnded();
+ // Although remove2Action is finished, download2Action doesn't start.
+ download2Action.assertDoesNotStart();
+
+ // When a new remove action is added, it cancels stopped download actions with the same media.
+ remove1Action.post();
+ download1Action.assertCancelled();
+ remove1Action.assertStarted().unblock().assertEnded();
+
+ // New download actions can be added but they don't start.
+ download3Action.post().assertDoesNotStart();
+
+ runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ downloadManager.startDownloads();
+ }
+ });
+
+ download2Action.assertStarted().unblock().assertEnded();
+ download3Action.assertStarted().unblock().assertEnded();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ public void testResumeBeforeTotallyStopped() throws Throwable {
+ setUpDownloadManager(2);
+ FakeDownloadAction download1Action = createDownloadAction("media 1").ignoreInterrupts();
+ FakeDownloadAction download2Action = createDownloadAction("media 2");
+ FakeDownloadAction download3Action = createDownloadAction("media 3");
+
+ download1Action.post().assertStarted();
+ download2Action.post().assertStarted();
+ // download3Action doesn't start as DM was configured to run two downloads in parallel.
+ download3Action.post().assertDoesNotStart();
+
+ runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ downloadManager.stopDownloads();
+ }
+ });
+
+ // download1Action doesn't stop yet as it ignores interrupts.
+ download2Action.assertStopped();
+
+ runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ downloadManager.startDownloads();
+ }
+ });
+
+ // download2Action starts immediately.
+ download2Action.assertStarted();
+
+ // download3Action doesn't start as download1Action still holds its slot.
+ download3Action.assertDoesNotStart();
+
+ // when unblocked download1Action stops and starts immediately.
+ download1Action.unblock().assertStopped().assertStarted();
+
+ download1Action.unblock();
+ download2Action.unblock();
+ download3Action.unblock();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ private void doTestActionRuns(FakeDownloadAction action) throws Throwable {
+ action.post().assertStarted().unblock().assertEnded();
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ private void doTestActionsRunSequentially(FakeDownloadAction action1,
+ FakeDownloadAction action2) throws Throwable {
+ action1.ignoreInterrupts().post().assertStarted();
+ action2.post().assertDoesNotStart();
+
+ action1.unblock();
+ action2.assertStarted();
+
+ action2.unblock().assertEnded();
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ private void doTestActionsRunInParallel(FakeDownloadAction action1,
+ FakeDownloadAction action2) throws Throwable {
+ action1.post().assertStarted();
+ action2.post().assertStarted();
+ action1.unblock().assertEnded();
+ action2.unblock().assertEnded();
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ private FakeDownloadAction createDownloadAction(String mediaId) {
+ return new FakeDownloadAction(mediaId, false);
+ }
+
+ private FakeDownloadAction createRemoveAction(String mediaId) {
+ return new FakeDownloadAction(mediaId, true);
+ }
+
+ private static final class TestDownloadListener implements DownloadListener {
+
+ private ConditionVariable downloadFinishedCondition;
+ private Throwable downloadError;
+
+ private TestDownloadListener() {
+ downloadFinishedCondition = new ConditionVariable();
+ }
+
+ @Override
+ public void onStateChange(DownloadManager downloadManager, DownloadState downloadState) {
+ if (downloadState.state == DownloadState.STATE_ERROR && downloadError == null) {
+ downloadError = downloadState.error;
+ }
+ ((FakeDownloadAction) downloadState.downloadAction).onStateChange(downloadState.state);
+ }
+
+ @Override
+ public void onIdle(DownloadManager downloadManager) {
+ downloadFinishedCondition.open();
+ }
+
+ private void clearDownloadError() {
+ this.downloadError = null;
+ }
+
+ private void blockUntilTasksCompleteAndThrowAnyDownloadError() throws Throwable {
+ assertThat(downloadFinishedCondition.block(ASSERT_TRUE_TIMEOUT)).isTrue();
+ downloadFinishedCondition.close();
+ if (downloadError != null) {
+ throw new Exception(downloadError);
+ }
+ }
+
+ }
+
+ private class FakeDownloadAction extends DownloadAction {
+
+ private final String mediaId;
+ private final boolean removeAction;
+ private final FakeDownloader downloader;
+ private final BlockingQueue The caller is responsible for closing the given {@link InputStream}.
+ *
+ * @param deserializers Array of {@link Deserializer}s to deserialize a {@link DownloadAction}.
+ * @param input Input stream to read serialized data.
+ * @return The deserialized {@link DownloadAction}.
+ * @throws IOException If there is an IO error from {@code input} or the action type isn't
+ * supported by any of the {@code deserializers}.
+ */
+ public static DownloadAction deserializeFromStream(
+ Deserializer[] deserializers, InputStream input) throws IOException {
+ return deserializeFromStream(deserializers, input, MASTER_VERSION);
+ }
+
+ /**
+ * Deserializes one {@code action} which was serialized by {@link
+ * #serializeToStream(DownloadAction, OutputStream)} from the {@code input} using one of the
+ * {@link Deserializer}s which supports the type of the action.
+ *
+ * The caller is responsible for closing the given {@link InputStream}.
+ *
+ * @param deserializers Array of {@link Deserializer}s to deserialize a {@link DownloadAction}.
+ * @param input Input stream to read serialized data.
+ * @param version Master version of the serialization. See {@link DownloadAction#MASTER_VERSION}.
+ * @return The deserialized {@link DownloadAction}.
+ * @throws IOException If there is an IO error from {@code input}.
+ * @throws DownloadException If the action type isn't supported by any of the {@code
+ * deserializers}.
+ */
+ public static DownloadAction deserializeFromStream(
+ Deserializer[] deserializers, InputStream input, int version) throws IOException {
+ // Don't close the stream as it closes the underlying stream too.
+ DataInputStream dataInputStream = new DataInputStream(input);
+ String type = dataInputStream.readUTF();
+ for (Deserializer deserializer : deserializers) {
+ if (type.equals(deserializer.getType())) {
+ return deserializer.readFromStream(version, dataInputStream);
+ }
+ }
+ throw new DownloadException("No Deserializer can be found to parse the data.");
+ }
+
+ /** Serializes {@code action} type and data into the {@code output}. */
+ public static void serializeToStream(DownloadAction action, OutputStream output)
+ throws IOException {
+ // Don't close the stream as it closes the underlying stream too.
+ DataOutputStream dataOutputStream = new DataOutputStream(output);
+ dataOutputStream.writeUTF(action.getType());
+ action.writeToStream(dataOutputStream);
+ dataOutputStream.flush();
+ }
+
+ private final String data;
+
+ /** @param data Optional custom data for this action. If null, an empty string is used. */
+ protected DownloadAction(String data) {
+ this.data = data != null ? data : "";
+ }
+
+ /** Serializes itself into a byte array. */
+ public final byte[] toByteArray() {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try {
+ serializeToStream(this, output);
+ } catch (IOException e) {
+ // ByteArrayOutputStream shouldn't throw IOException.
+ throw new IllegalStateException();
+ }
+ return output.toByteArray();
+ }
+
+ /** Returns custom data for this action. */
+ public final String getData() {
+ return data;
+ }
+
+ /** Returns whether this is a remove action or a download action. */
+ public abstract boolean isRemoveAction();
+
+ /** Returns the type string of the {@link DownloadAction}. This string should be unique. */
+ protected abstract String getType();
+
+ /** Serializes itself into the {@code output}. */
+ protected abstract void writeToStream(DataOutputStream output) throws IOException;
+
+ /** Returns whether this is action is for the same media as the {@code other}. */
+ protected abstract boolean isSameMedia(DownloadAction other);
+
+ /** Creates a {@link Downloader} with the given parameters. */
+ protected abstract Downloader createDownloader(
+ DownloaderConstructorHelper downloaderConstructorHelper);
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ DownloadAction that = (DownloadAction) o;
+ return data.equals(that.data) && isRemoveAction() == that.isRemoveAction();
+ }
+
+ @Override
+ public int hashCode() {
+ int result = data.hashCode();
+ result = 31 * result + (isRemoveAction() ? 1 : 0);
+ return result;
+ }
+
+}
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java
new file mode 100644
index 00000000000..2df2069a898
--- /dev/null
+++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java
@@ -0,0 +1,717 @@
+/*
+ * 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.DownloadManager.DownloadState.STATE_CANCELED;
+import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_CANCELING;
+import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_ENDED;
+import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_ERROR;
+import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_STARTED;
+import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_STOPPING;
+import static com.google.android.exoplayer2.offline.DownloadManager.DownloadState.STATE_WAITING;
+
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.support.annotation.IntDef;
+import android.util.Log;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.offline.DownloadAction.Deserializer;
+import com.google.android.exoplayer2.offline.DownloadManager.DownloadState.State;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+/**
+ * Manages multiple stream download and remove requests.
+ *
+ * By default downloads are stopped. Call {@link #startDownloads()} to start downloads.
+ *
+ * WARNING: Methods of this class must be called only on the main thread of the application.
+ */
+public final class DownloadManager {
+
+ /**
+ * Listener for download events. Listener methods are called on the main thread of the
+ * application.
+ */
+ public interface DownloadListener {
+ /**
+ * Called on download state change.
+ *
+ * @param downloadManager The reporting instance.
+ * @param downloadState The download task.
+ */
+ void onStateChange(DownloadManager downloadManager, DownloadState downloadState);
+
+ /**
+ * Called when there is no active task left.
+ *
+ * @param downloadManager The reporting instance.
+ */
+ void onIdle(DownloadManager downloadManager);
+ }
+
+ private static final String TAG = "DownloadManager";
+ private static final boolean DEBUG = false;
+
+ private final DownloaderConstructorHelper downloaderConstructorHelper;
+ private final int maxActiveDownloadTasks;
+ private final int minRetryCount;
+ private final ActionFile actionFile;
+ private final DownloadAction.Deserializer[] deserializers;
+ private final ArrayList Transition map (vertical states are source states):
+ * To start the service, create an instance of one of the subclasses of {@link DownloadAction}
+ * and call {@link #addDownloadAction(Context, Class, DownloadAction)} with it.
+ */
+public abstract class DownloadService extends Service implements DownloadManager.DownloadListener {
+
+ /** Use this action to initialize {@link DownloadManager}. */
+ public static final String ACTION_INIT =
+ "com.google.android.exoplayer.downloadService.action.INIT";
+
+ /** Use this action to add a {@link DownloadAction} to {@link DownloadManager} action queue. */
+ public static final String ACTION_ADD = "com.google.android.exoplayer.downloadService.action.ADD";
+
+ /** Use this action to make {@link DownloadManager} stop download tasks. */
+ private static final String ACTION_STOP =
+ "com.google.android.exoplayer.downloadService.action.STOP";
+
+ /** Use this action to make {@link DownloadManager} start download tasks. */
+ private static final String ACTION_START =
+ "com.google.android.exoplayer.downloadService.action.START";
+
+ /** A {@link DownloadAction} to be executed. */
+ public static final String DOWNLOAD_ACTION = "DownloadAction";
+
+ /** Default progress update interval in milliseconds. */
+ public static final long DEFAULT_PROGRESS_UPDATE_INTERVAL_MILLIS = 1000;
+
+ private static final String TAG = "DownloadService";
+ private static final boolean DEBUG = false;
+
+ // Keep requirementsWatcher and scheduler alive beyond DownloadService life span (until the app is
+ // killed) because it may take long time for Scheduler to start the service.
+ private static RequirementsWatcher requirementsWatcher;
+ private static Scheduler scheduler;
+
+ private final int notificationIdOffset;
+ private final long progressUpdateIntervalMillis;
+
+ private DownloadManager downloadManager;
+ private ProgressUpdater progressUpdater;
+ private int lastStartId;
+
+ /** @param notificationIdOffset Value to offset notification ids. Must be greater than 0. */
+ protected DownloadService(int notificationIdOffset) {
+ this(notificationIdOffset, DEFAULT_PROGRESS_UPDATE_INTERVAL_MILLIS);
+ }
+
+ /**
+ * @param notificationIdOffset Value to offset notification ids. Must be greater than 0.
+ * @param progressUpdateIntervalMillis {@link #onProgressUpdate(DownloadState[])} is called using
+ * this interval. If it's {@link C#TIME_UNSET}, then {@link
+ * #onProgressUpdate(DownloadState[])} isn't called.
+ */
+ protected DownloadService(int notificationIdOffset, long progressUpdateIntervalMillis) {
+ this.notificationIdOffset = notificationIdOffset;
+ this.progressUpdateIntervalMillis = progressUpdateIntervalMillis;
+ }
+
+ /**
+ * Creates an {@link Intent} to be used to start this service and adds the {@link DownloadAction}
+ * to the {@link DownloadManager}.
+ *
+ * @param context A {@link Context} of the application calling this service.
+ * @param clazz Class object of DownloadService or subclass.
+ * @param downloadAction A {@link DownloadAction} to be executed.
+ * @return Created Intent.
+ */
+ public static Intent createAddDownloadActionIntent(
+ Context context, Class extends DownloadService> clazz, DownloadAction downloadAction) {
+ return new Intent(context, clazz)
+ .setAction(ACTION_ADD)
+ .putExtra(DOWNLOAD_ACTION, downloadAction.toByteArray());
+ }
+
+ /**
+ * Adds a {@link DownloadAction} to the {@link DownloadManager}. This will start the download
+ * service if it was not running.
+ *
+ * @param context A {@link Context} of the application calling this service.
+ * @param clazz Class object of DownloadService or subclass.
+ * @param downloadAction A {@link DownloadAction} to be executed.
+ * @see #createAddDownloadActionIntent(Context, Class, DownloadAction)
+ */
+ public static void addDownloadAction(
+ Context context, Class extends DownloadService> clazz, DownloadAction downloadAction) {
+ context.startService(createAddDownloadActionIntent(context, clazz, downloadAction));
+ }
+
+ @Override
+ public void onCreate() {
+ logd("onCreate");
+ downloadManager = getDownloadManager();
+ downloadManager.addListener(this);
+
+ if (requirementsWatcher == null) {
+ Requirements requirements = getRequirements();
+ if (requirements != null) {
+ scheduler = getScheduler();
+ RequirementsListener listener =
+ new RequirementsListener(getApplicationContext(), getClass(), scheduler);
+ requirementsWatcher =
+ new RequirementsWatcher(getApplicationContext(), listener, requirements);
+ requirementsWatcher.start();
+ }
+ }
+
+ progressUpdater = new ProgressUpdater(this, progressUpdateIntervalMillis);
+ }
+
+ @Override
+ public void onDestroy() {
+ logd("onDestroy");
+ progressUpdater.stop();
+ downloadManager.removeListener(this);
+ if (downloadManager.getTaskCount() == 0) {
+ if (requirementsWatcher != null) {
+ requirementsWatcher.stop();
+ requirementsWatcher = null;
+ }
+ if (scheduler != null) {
+ scheduler.cancel();
+ scheduler = null;
+ }
+ }
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ this.lastStartId = startId;
+ String intentAction = intent != null ? intent.getAction() : null;
+ if (intentAction == null) {
+ intentAction = ACTION_INIT;
+ }
+ logd("onStartCommand action: " + intentAction + " startId: " + startId);
+ switch (intentAction) {
+ case ACTION_INIT:
+ // Do nothing. DownloadManager and RequirementsWatcher is initialized. If there are download
+ // or remove tasks loaded from file, they will start if the requirements are met.
+ break;
+ case ACTION_ADD:
+ byte[] actionData = intent.getByteArrayExtra(DOWNLOAD_ACTION);
+ if (actionData == null) {
+ onCommandError(intent, new IllegalArgumentException("DownloadAction is missing."));
+ } else {
+ try {
+ onNewTask(intent, downloadManager.handleAction(actionData));
+ } catch (IOException e) {
+ onCommandError(intent, e);
+ }
+ }
+ break;
+ case ACTION_STOP:
+ downloadManager.stopDownloads();
+ break;
+ case ACTION_START:
+ downloadManager.startDownloads();
+ break;
+ default:
+ onCommandError(intent, new IllegalArgumentException("Unknown action: " + intentAction));
+ break;
+ }
+ if (downloadManager.isIdle()) {
+ onIdle(null);
+ }
+ return START_STICKY;
+ }
+
+ /**
+ * Returns a {@link DownloadManager} to be used to downloaded content. Called only once in the
+ * life cycle of the service.
+ */
+ protected abstract DownloadManager getDownloadManager();
+
+ /**
+ * Returns a {@link Scheduler} which contains a job to initialize {@link DownloadService} when the
+ * requirements are met, or null. If not null, scheduler is used to start downloads even when the
+ * app isn't running.
+ */
+ protected abstract @Nullable Scheduler getScheduler();
+
+ /** Returns requirements for downloads to take place, or null. */
+ protected abstract @Nullable Requirements getRequirements();
+
+ /** Called on error in start command. */
+ protected void onCommandError(Intent intent, Exception error) {
+ // Do nothing.
+ }
+
+ /** Called when a new task is added to the {@link DownloadManager}. */
+ protected void onNewTask(Intent intent, int taskId) {
+ // Do nothing.
+ }
+
+ /** Returns a notification channelId. See {@link NotificationChannel}. */
+ protected abstract String getNotificationChannelId();
+
+ /**
+ * Helper method which calls {@link #startForeground(int, Notification)} with {@code
+ * notificationIdOffset} and {@code foregroundNotification}.
+ */
+ public void startForeground(Notification foregroundNotification) {
+ // logd("start foreground");
+ startForeground(notificationIdOffset, foregroundNotification);
+ }
+
+ /**
+ * Sets/replaces or cancels the notification for the given id.
+ *
+ * @param id A unique id for the notification. This value is offset by {@code
+ * notificationIdOffset}.
+ * @param notification If not null, it's showed, replacing any previous notification. Otherwise
+ * any previous notification is canceled.
+ */
+ public void setNotification(int id, @Nullable Notification notification) {
+ NotificationManager notificationManager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (notification != null) {
+ notificationManager.notify(notificationIdOffset + 1 + id, notification);
+ } else {
+ notificationManager.cancel(notificationIdOffset + 1 + id);
+ }
+ }
+
+ /**
+ * Override this method to get notified.
+ *
+ * {@inheritDoc}
+ */
+ @CallSuper
+ @Override
+ public void onStateChange(DownloadManager downloadManager, DownloadState downloadState) {
+ if (downloadState.state == DownloadState.STATE_STARTED) {
+ progressUpdater.start();
+ }
+ }
+
+ /**
+ * Override this method to get notified.
+ *
+ * {@inheritDoc}
+ */
+ @CallSuper
+ @Override
+ public void onIdle(DownloadManager downloadManager) {
+ // Make sure startForeground is called before stopping.
+ if (Util.SDK_INT >= 26) {
+ Builder notificationBuilder = new Builder(this, getNotificationChannelId());
+ Notification foregroundNotification = notificationBuilder.build();
+ startForeground(foregroundNotification);
+ }
+ boolean stopSelfResult = stopSelfResult(lastStartId);
+ logd("stopSelf(" + lastStartId + ") result: " + stopSelfResult);
+ }
+
+ /** Override this method to get notified on every second while there are active downloads. */
+ protected void onProgressUpdate(DownloadState[] activeDownloadTasks) {
+ // Do nothing.
+ }
+
+ private void logd(String message) {
+ if (DEBUG) {
+ Log.d(TAG, message);
+ }
+ }
+
+ private static final class ProgressUpdater implements Runnable {
+
+ private final DownloadService downloadService;
+ private final long progressUpdateIntervalMillis;
+ private final Handler handler;
+ private boolean stopped;
+
+ public ProgressUpdater(DownloadService downloadService, long progressUpdateIntervalMillis) {
+ this.downloadService = downloadService;
+ this.progressUpdateIntervalMillis = progressUpdateIntervalMillis;
+ this.handler = new Handler(Looper.getMainLooper());
+ stopped = true;
+ }
+
+ @Override
+ public void run() {
+ DownloadState[] activeDownloadTasks =
+ downloadService.downloadManager.getActiveDownloadStates();
+ if (activeDownloadTasks.length > 0) {
+ downloadService.onProgressUpdate(activeDownloadTasks);
+ if (progressUpdateIntervalMillis != C.TIME_UNSET) {
+ handler.postDelayed(this, progressUpdateIntervalMillis);
+ }
+ } else {
+ stop();
+ }
+ }
+
+ public void stop() {
+ stopped = true;
+ handler.removeCallbacks(this);
+ }
+
+ public void start() {
+ if (stopped) {
+ stopped = false;
+ if (progressUpdateIntervalMillis != C.TIME_UNSET) {
+ handler.post(this);
+ }
+ }
+ }
+
+ }
+
+ private static final class RequirementsListener implements RequirementsWatcher.Listener {
+
+ private final Context context;
+ private final Class extends DownloadService> serviceClass;
+ private final Scheduler scheduler;
+
+ private RequirementsListener(
+ Context context, Class extends DownloadService> serviceClass, Scheduler scheduler) {
+ this.context = context;
+ this.serviceClass = serviceClass;
+ this.scheduler = scheduler;
+ }
+
+ @Override
+ public void requirementsMet(RequirementsWatcher requirementsWatcher) {
+ startServiceWithAction(DownloadService.ACTION_START);
+ if (scheduler != null) {
+ scheduler.cancel();
+ }
+ }
+
+ @Override
+ public void requirementsNotMet(RequirementsWatcher requirementsWatcher) {
+ startServiceWithAction(DownloadService.ACTION_STOP);
+ if (scheduler != null) {
+ if (!scheduler.schedule()) {
+ Log.e(TAG, "Scheduling downloads failed.");
+ }
+ }
+ }
+
+ private void startServiceWithAction(String action) {
+ Intent intent = new Intent(context, serviceClass).setAction(action);
+ if (Util.SDK_INT >= 26) {
+ context.startForegroundService(intent);
+ } else {
+ context.startService(intent);
+ }
+ }
+ }
+}
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadAction.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadAction.java
new file mode 100644
index 00000000000..7c9549cd3a9
--- /dev/null
+++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloadAction.java
@@ -0,0 +1,122 @@
+/*
+ * 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 android.support.annotation.Nullable;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+
+/** An action to download or remove downloaded progressive streams. */
+public final class ProgressiveDownloadAction extends DownloadAction {
+
+ public static final Deserializer DESERIALIZER = new Deserializer() {
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ @Override
+ public ProgressiveDownloadAction readFromStream(int version, DataInputStream input)
+ throws IOException {
+ return new ProgressiveDownloadAction(input.readUTF(),
+ input.readBoolean() ? input.readUTF() : null, input.readBoolean(), input.readUTF());
+ }
+
+ };
+
+ private static final String TYPE = "ProgressiveDownloadAction";
+
+ private final String uri;
+ private final @Nullable String customCacheKey;
+ private final boolean removeAction;
+
+ /**
+ * @param uri Uri of the data to be downloaded.
+ * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache
+ * indexing. May be null.
+ * @param removeAction Whether the data should be downloaded or removed.
+ * @param data Optional custom data for this action. If null, an empty string is used.
+ */
+ public ProgressiveDownloadAction(String uri, @Nullable String customCacheKey,
+ boolean removeAction, String data) {
+ super(data);
+ this.uri = Assertions.checkNotNull(uri);
+ this.customCacheKey = customCacheKey;
+ this.removeAction = removeAction;
+ }
+
+ @Override
+ public boolean isRemoveAction() {
+ return removeAction;
+ }
+
+ @Override
+ protected ProgressiveDownloader createDownloader(DownloaderConstructorHelper constructorHelper) {
+ return new ProgressiveDownloader(uri, customCacheKey, constructorHelper);
+ }
+
+ @Override
+ protected String getType() {
+ return TYPE;
+ }
+
+ @Override
+ protected void writeToStream(DataOutputStream output) throws IOException {
+ output.writeUTF(uri);
+ boolean customCacheKeyAvailable = customCacheKey != null;
+ output.writeBoolean(customCacheKeyAvailable);
+ if (customCacheKeyAvailable) {
+ output.writeUTF(customCacheKey);
+ }
+ output.writeBoolean(isRemoveAction());
+ output.writeUTF(getData());
+ }
+
+ @Override
+ protected boolean isSameMedia(DownloadAction other) {
+ if (!(other instanceof ProgressiveDownloadAction)) {
+ return false;
+ }
+ ProgressiveDownloadAction action = (ProgressiveDownloadAction) other;
+ return customCacheKey != null ? customCacheKey.equals(action.customCacheKey)
+ : uri.equals(action.uri);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!super.equals(o)) {
+ return false;
+ }
+ ProgressiveDownloadAction that = (ProgressiveDownloadAction) o;
+ return uri.equals(that.uri) && Util.areEqual(customCacheKey, that.customCacheKey);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = super.hashCode();
+ result = 31 * result + uri.hashCode();
+ result = 31 * result + (customCacheKey != null ? customCacheKey.hashCode() : 0);
+ return result;
+ }
+
+}
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloadAction.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloadAction.java
new file mode 100644
index 00000000000..b77ac5bad8f
--- /dev/null
+++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloadAction.java
@@ -0,0 +1,142 @@
+/*
+ * 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 android.net.Uri;
+import com.google.android.exoplayer2.util.Assertions;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ * {@link DownloadAction} for {@link SegmentDownloader}s.
+ *
+ * @param To use {@link PlatformScheduler} application needs to have RECEIVE_BOOT_COMPLETED permission
+ * and you need to define PlatformSchedulerService in your manifest:
+ *
+ * To use network type requirement, application needs to have ACCESS_NETWORK_STATE permission.
+ */
+public final class Requirements {
+
+ /** Network types. */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ NETWORK_TYPE_NONE,
+ NETWORK_TYPE_ANY,
+ NETWORK_TYPE_UNMETERED,
+ NETWORK_TYPE_NOT_ROAMING,
+ NETWORK_TYPE_METERED,
+ })
+ public @interface NetworkType {}
+ /** This job doesn't require network connectivity. */
+ public static final int NETWORK_TYPE_NONE = 0;
+ /** This job requires network connectivity. */
+ public static final int NETWORK_TYPE_ANY = 1;
+ /** This job requires network connectivity that is unmetered. */
+ public static final int NETWORK_TYPE_UNMETERED = 2;
+ /** This job requires network connectivity that is not roaming. */
+ public static final int NETWORK_TYPE_NOT_ROAMING = 3;
+ /** This job requires metered connectivity such as most cellular data networks. */
+ public static final int NETWORK_TYPE_METERED = 4;
+ /** This job requires the device to be idle. */
+ private static final int DEVICE_IDLE = 8;
+ /** This job requires the device to be charging. */
+ private static final int DEVICE_CHARGING = 16;
+
+ private static final int NETWORK_TYPE_MASK = 7;
+
+ private static final String TAG = "Requirements";
+
+ private static final String[] NETWORK_TYPE_STRINGS;
+
+ static {
+ if (Scheduler.DEBUG) {
+ NETWORK_TYPE_STRINGS =
+ new String[] {
+ "NETWORK_TYPE_NONE",
+ "NETWORK_TYPE_ANY",
+ "NETWORK_TYPE_UNMETERED",
+ "NETWORK_TYPE_NOT_ROAMING",
+ "NETWORK_TYPE_METERED"
+ };
+ } else {
+ NETWORK_TYPE_STRINGS = null;
+ }
+ }
+
+ private final int requirements;
+
+ /**
+ * @param networkType Required network type.
+ * @param charging Whether the device should be charging.
+ * @param idle Whether the device should be idle.
+ */
+ public Requirements(@NetworkType int networkType, boolean charging, boolean idle) {
+ this(networkType | (charging ? DEVICE_CHARGING : 0) | (idle ? DEVICE_IDLE : 0));
+ }
+
+ /** @param requirementsData The value returned by {@link #getRequirementsData()}. */
+ public Requirements(int requirementsData) {
+ this.requirements = requirementsData;
+ }
+
+ /** Returns required network type. */
+ public int getRequiredNetworkType() {
+ return requirements & NETWORK_TYPE_MASK;
+ }
+
+ /** Returns whether the device should be charging. */
+ public boolean isChargingRequired() {
+ return (requirements & DEVICE_CHARGING) != 0;
+ }
+
+ /** Returns whether the device should be idle. */
+ public boolean isIdleRequired() {
+ return (requirements & DEVICE_IDLE) != 0;
+ }
+
+ /**
+ * Returns whether the requirements are met.
+ *
+ * @param context Any context.
+ */
+ public boolean checkRequirements(Context context) {
+ return checkNetworkRequirements(context)
+ && checkChargingRequirement(context)
+ && checkIdleRequirement(context);
+ }
+
+ /** Returns the encoded requirements data which can be used with {@link #Requirements(int)}. */
+ public int getRequirementsData() {
+ return requirements;
+ }
+
+ private boolean checkNetworkRequirements(Context context) {
+ int networkRequirement = getRequiredNetworkType();
+ if (networkRequirement == NETWORK_TYPE_NONE) {
+ return true;
+ }
+ ConnectivityManager connectivityManager =
+ (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
+ if (networkInfo == null || !networkInfo.isConnected()) {
+ logd("No network info or no connection.");
+ return false;
+ } else if (Util.SDK_INT >= 23) {
+ // TODO Check internet connectivity using http://clients3.google.com/generate_204 on API
+ // levels prior to 23.
+ Network activeNetwork = connectivityManager.getActiveNetwork();
+ if (activeNetwork == null) {
+ logd("No active network.");
+ return false;
+ }
+ NetworkCapabilities networkCapabilities =
+ connectivityManager.getNetworkCapabilities(activeNetwork);
+ if (networkCapabilities == null
+ || !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
+ logd("Net capability isn't validated.");
+ return false;
+ }
+ }
+ boolean activeNetworkMetered = connectivityManager.isActiveNetworkMetered();
+ switch (networkRequirement) {
+ case NETWORK_TYPE_ANY:
+ return true;
+ case NETWORK_TYPE_UNMETERED:
+ if (activeNetworkMetered) {
+ logd("Network is metered.");
+ }
+ return !activeNetworkMetered;
+ case NETWORK_TYPE_NOT_ROAMING:
+ boolean roaming = networkInfo.isRoaming();
+ if (roaming) {
+ logd("Roaming.");
+ }
+ return !roaming;
+ case NETWORK_TYPE_METERED:
+ if (!activeNetworkMetered) {
+ logd("Network isn't metered.");
+ }
+ return activeNetworkMetered;
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ private boolean checkChargingRequirement(Context context) {
+ if (!isChargingRequired()) {
+ return true;
+ }
+ Intent batteryStatus =
+ context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
+ if (batteryStatus == null) {
+ return false;
+ }
+ int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
+ return status == BatteryManager.BATTERY_STATUS_CHARGING
+ || status == BatteryManager.BATTERY_STATUS_FULL;
+ }
+
+ private boolean checkIdleRequirement(Context context) {
+ if (!isIdleRequired()) {
+ return true;
+ }
+ PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+ return Util.SDK_INT >= 23
+ ? !powerManager.isDeviceIdleMode()
+ : Util.SDK_INT >= 20 ? !powerManager.isInteractive() : !powerManager.isScreenOn();
+ }
+
+ private static void logd(String message) {
+ if (Scheduler.DEBUG) {
+ Log.d(TAG, message);
+ }
+ }
+
+ @Override
+ public String toString() {
+ if (!Scheduler.DEBUG) {
+ return super.toString();
+ }
+ return "requirements{"
+ + NETWORK_TYPE_STRINGS[getRequiredNetworkType()]
+ + (isChargingRequired() ? ",charging" : "")
+ + (isIdleRequired() ? ",idle" : "")
+ + '}';
+ }
+}
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/RequirementsWatcher.java
new file mode 100644
index 00000000000..05a1e7a4937
--- /dev/null
+++ b/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/RequirementsWatcher.java
@@ -0,0 +1,211 @@
+/*
+ * 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.util.scheduler;
+
+import android.annotation.TargetApi;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.PowerManager;
+import android.support.annotation.RequiresApi;
+import android.util.Log;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+
+/**
+ * Watches whether the {@link Requirements} are met and notifies the {@link Listener} on changes.
+ */
+public final class RequirementsWatcher {
+
+ /**
+ * Notified when RequirementsWatcher instance first created and on changes whether the {@link
+ * Requirements} are met.
+ */
+ public interface Listener {
+
+ /**
+ * Called when the requirements are met.
+ *
+ * @param requirementsWatcher Calling instance.
+ */
+ void requirementsMet(RequirementsWatcher requirementsWatcher);
+
+ /**
+ * Called when the requirements are not met.
+ *
+ * @param requirementsWatcher Calling instance.
+ */
+ void requirementsNotMet(RequirementsWatcher requirementsWatcher);
+ }
+
+ private static final String TAG = "RequirementsWatcher";
+
+ private final Context context;
+ private final Listener listener;
+ private final Requirements requirements;
+ private DeviceStatusChangeReceiver receiver;
+
+ private boolean requirementsWereMet;
+ private CapabilityValidatedCallback networkCallback;
+
+ /**
+ * @param context Used to register for broadcasts.
+ * @param listener Notified whether the {@link Requirements} are met.
+ * @param requirements The requirements to watch.
+ */
+ public RequirementsWatcher(Context context, Listener listener, Requirements requirements) {
+ this.requirements = requirements;
+ this.listener = listener;
+ this.context = context;
+ logd(this + " created");
+ }
+
+ /**
+ * Starts watching for changes. Must be called from a thread that has an associated {@link
+ * Looper}. Listener methods are called on the caller thread.
+ */
+ public void start() {
+ Assertions.checkNotNull(Looper.myLooper());
+
+ checkRequirements(true);
+
+ IntentFilter filter = new IntentFilter();
+ if (requirements.getRequiredNetworkType() != Requirements.NETWORK_TYPE_NONE) {
+ if (Util.SDK_INT >= 23) {
+ registerNetworkCallbackV23();
+ } else {
+ filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
+ }
+ }
+ if (requirements.isChargingRequired()) {
+ filter.addAction(Intent.ACTION_POWER_CONNECTED);
+ filter.addAction(Intent.ACTION_POWER_DISCONNECTED);
+ }
+ if (requirements.isIdleRequired()) {
+ if (Util.SDK_INT >= 23) {
+ filter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED);
+ } else {
+ filter.addAction(Intent.ACTION_SCREEN_ON);
+ filter.addAction(Intent.ACTION_SCREEN_OFF);
+ }
+ }
+ receiver = new DeviceStatusChangeReceiver();
+ context.registerReceiver(receiver, filter, null, new Handler());
+ logd(this + " started");
+ }
+
+ /** Stops watching for changes. */
+ public void stop() {
+ context.unregisterReceiver(receiver);
+ receiver = null;
+ if (networkCallback != null) {
+ unregisterNetworkCallback();
+ }
+ logd(this + " stopped");
+ }
+
+ /** Returns watched {@link Requirements}. */
+ public Requirements getRequirements() {
+ return requirements;
+ }
+
+ @Override
+ public String toString() {
+ if (!Scheduler.DEBUG) {
+ return super.toString();
+ }
+ return "RequirementsWatcher{" + requirements + '}';
+ }
+
+ @TargetApi(23)
+ private void registerNetworkCallbackV23() {
+ ConnectivityManager connectivityManager =
+ (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkRequest request =
+ new NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ .build();
+ networkCallback = new CapabilityValidatedCallback();
+ connectivityManager.registerNetworkCallback(request, networkCallback);
+ }
+
+ private void unregisterNetworkCallback() {
+ if (Util.SDK_INT >= 21) {
+ ConnectivityManager connectivityManager =
+ (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ connectivityManager.unregisterNetworkCallback(networkCallback);
+ networkCallback = null;
+ }
+ }
+
+ private void checkRequirements(boolean force) {
+ boolean requirementsAreMet = requirements.checkRequirements(context);
+ if (!force) {
+ if (requirementsAreMet == requirementsWereMet) {
+ logd("requirementsAreMet is still " + requirementsAreMet);
+ return;
+ }
+ }
+ requirementsWereMet = requirementsAreMet;
+ if (requirementsAreMet) {
+ logd("start job");
+ listener.requirementsMet(this);
+ } else {
+ logd("stop job");
+ listener.requirementsNotMet(this);
+ }
+ }
+
+ private static void logd(String message) {
+ if (Scheduler.DEBUG) {
+ Log.d(TAG, message);
+ }
+ }
+
+ private class DeviceStatusChangeReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (!isInitialStickyBroadcast()) {
+ logd(RequirementsWatcher.this + " received " + intent.getAction());
+ checkRequirements(false);
+ }
+ }
+ }
+
+ @RequiresApi(api = 21)
+ private final class CapabilityValidatedCallback extends ConnectivityManager.NetworkCallback {
+ @Override
+ public void onAvailable(Network network) {
+ super.onAvailable(network);
+ logd(RequirementsWatcher.this + " NetworkCallback.onAvailable");
+ checkRequirements(false);
+ }
+
+ @Override
+ public void onLost(Network network) {
+ super.onLost(network);
+ logd(RequirementsWatcher.this + " NetworkCallback.onLost");
+ checkRequirements(false);
+ }
+ }
+}
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/Scheduler.java b/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/Scheduler.java
new file mode 100644
index 00000000000..395c4c70909
--- /dev/null
+++ b/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/Scheduler.java
@@ -0,0 +1,39 @@
+/*
+ * 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.util.scheduler;
+
+/**
+ * Implementer of this interface schedules one implementation specific job to be run when some
+ * requirements are met even if the app isn't running.
+ */
+public interface Scheduler {
+
+ /*package*/ boolean DEBUG = false;
+
+ /**
+ * Schedules the job to be run when the requirements are met.
+ *
+ * @return Whether the job scheduled successfully.
+ */
+ boolean schedule();
+
+ /**
+ * Cancels any previous schedule.
+ *
+ * @return Whether the job cancelled successfully.
+ */
+ boolean cancel();
+}
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java
new file mode 100644
index 00000000000..6b22126725f
--- /dev/null
+++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java
@@ -0,0 +1,252 @@
+/*
+ * 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.common.truth.Truth.assertThat;
+
+import com.google.android.exoplayer2.offline.DownloadAction.Deserializer;
+import com.google.android.exoplayer2.util.Util;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/**
+ * Unit tests for {@link ProgressiveDownloadAction}.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = Config.TARGET_SDK, manifest = Config.NONE)
+public class ActionFileTest {
+
+ private File tempFile;
+
+ @Before
+ public void setUp() throws Exception {
+ tempFile = Util.createTempFile(RuntimeEnvironment.application, "ExoPlayerTest");
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ tempFile.delete();
+ }
+
+ @Test
+ public void testLoadNoDataThrowsIOException() throws Exception {
+ try {
+ loadActions(new Object[] {});
+ Assert.fail();
+ } catch (IOException e) {
+ // Expected exception.
+ }
+ }
+
+ @Test
+ public void testLoadIncompleteHeaderThrowsIOException() throws Exception {
+ try {
+ loadActions(new Object[] {DownloadAction.MASTER_VERSION});
+ Assert.fail();
+ } catch (IOException e) {
+ // Expected exception.
+ }
+ }
+
+ @Test
+ public void testLoadCompleteHeaderZeroAction() throws Exception {
+ DownloadAction[] actions =
+ loadActions(new Object[] {DownloadAction.MASTER_VERSION, /*action count*/0});
+ assertThat(actions).isNotNull();
+ assertThat(actions).hasLength(0);
+ }
+
+ @Test
+ public void testLoadAction() throws Exception {
+ DownloadAction[] actions = loadActions(
+ new Object[] {DownloadAction.MASTER_VERSION, /*action count*/1, /*action 1*/"type2", 321},
+ new FakeDeserializer("type2"));
+ assertThat(actions).isNotNull();
+ assertThat(actions).hasLength(1);
+ assertAction(actions[0], "type2", DownloadAction.MASTER_VERSION, 321);
+ }
+
+ @Test
+ public void testLoadActions() throws Exception {
+ DownloadAction[] actions = loadActions(
+ new Object[] {DownloadAction.MASTER_VERSION, /*action count*/2, /*action 1*/"type1", 123,
+ /*action 2*/"type2", 321}, // Action 2
+ new FakeDeserializer("type1"), new FakeDeserializer("type2"));
+ assertThat(actions).isNotNull();
+ assertThat(actions).hasLength(2);
+ assertAction(actions[0], "type1", DownloadAction.MASTER_VERSION, 123);
+ assertAction(actions[1], "type2", DownloadAction.MASTER_VERSION, 321);
+ }
+
+ @Test
+ public void testLoadNotSupportedVersion() throws Exception {
+ try {
+ loadActions(new Object[] {DownloadAction.MASTER_VERSION + 1, /*action count*/1,
+ /*action 1*/"type2", 321}, new FakeDeserializer("type2"));
+ Assert.fail();
+ } catch (IOException e) {
+ // Expected exception.
+ }
+ }
+
+ @Test
+ public void testLoadNotSupportedType() throws Exception {
+ try {
+ loadActions(new Object[] {DownloadAction.MASTER_VERSION, /*action count*/1,
+ /*action 1*/"type2", 321}, new FakeDeserializer("type1"));
+ Assert.fail();
+ } catch (DownloadException e) {
+ // Expected exception.
+ }
+ }
+
+ @Test
+ public void testStoreAndLoadNoActions() throws Exception {
+ doTestSerializationRoundTrip(new DownloadAction[0]);
+ }
+
+ @Test
+ public void testStoreAndLoadActions() throws Exception {
+ doTestSerializationRoundTrip(new DownloadAction[] {
+ new FakeDownloadAction("type1", DownloadAction.MASTER_VERSION, 123),
+ new FakeDownloadAction("type2", DownloadAction.MASTER_VERSION, 321),
+ }, new FakeDeserializer("type1"), new FakeDeserializer("type2"));
+ }
+
+ private void doTestSerializationRoundTrip(DownloadAction[] actions,
+ Deserializer... deserializers) throws IOException {
+ ActionFile actionFile = new ActionFile(tempFile);
+ actionFile.store(actions);
+ assertThat(actionFile.load(deserializers)).isEqualTo(actions);
+ }
+
+ private DownloadAction[] loadActions(Object[] values, Deserializer... deserializers)
+ throws IOException {
+ FileOutputStream fileOutputStream = new FileOutputStream(tempFile);
+ DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
+ try {
+ for (Object value : values) {
+ if (value instanceof Integer) {
+ dataOutputStream.writeInt((Integer) value); // Action count
+ } else if (value instanceof String) {
+ dataOutputStream.writeUTF((String) value); // Action count
+ } else {
+ throw new IllegalArgumentException();
+ }
+ }
+ } finally {
+ dataOutputStream.close();
+ }
+ return new ActionFile(tempFile).load(deserializers);
+ }
+
+ private static void assertAction(DownloadAction action, String type, int version, int data) {
+ assertThat(action).isInstanceOf(FakeDownloadAction.class);
+ assertThat(action.getType()).isEqualTo(type);
+ assertThat(((FakeDownloadAction) action).version).isEqualTo(version);
+ assertThat(((FakeDownloadAction) action).data).isEqualTo(data);
+ }
+
+ private static class FakeDeserializer implements Deserializer {
+ final String type;
+
+ FakeDeserializer(String type) {
+ this.type = type;
+ }
+
+ @Override
+ public String getType() {
+ return type;
+ }
+
+ @Override
+ public DownloadAction readFromStream(int version, DataInputStream input) throws IOException {
+ return new FakeDownloadAction(type, version, input.readInt());
+ }
+ }
+
+ private static class FakeDownloadAction extends DownloadAction {
+ final String type;
+ final int version;
+ final int data;
+
+ private FakeDownloadAction(String type, int version, int data) {
+ super(null);
+ this.type = type;
+ this.version = version;
+ this.data = data;
+ }
+
+ @Override
+ protected String getType() {
+ return type;
+ }
+
+ @Override
+ protected void writeToStream(DataOutputStream output) throws IOException {
+ output.writeInt(data);
+ }
+
+ @Override
+ public boolean isRemoveAction() {
+ return false;
+ }
+
+ @Override
+ protected boolean isSameMedia(DownloadAction other) {
+ return false;
+ }
+
+ @Override
+ protected Downloader createDownloader(DownloaderConstructorHelper downloaderConstructorHelper) {
+ return null;
+ }
+
+ // auto generated code
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ FakeDownloadAction that = (FakeDownloadAction) o;
+ return version == that.version && data == that.data && type.equals(that.type);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = type.hashCode();
+ result = 31 * result + version;
+ result = 31 * result + data;
+ return result;
+ }
+ }
+
+}
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/ProgressiveDownloadActionTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/ProgressiveDownloadActionTest.java
new file mode 100644
index 00000000000..62f9cf3d7f6
--- /dev/null
+++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/ProgressiveDownloadActionTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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.common.truth.Truth.assertThat;
+
+import com.google.android.exoplayer2.upstream.DummyDataSource;
+import com.google.android.exoplayer2.upstream.cache.Cache;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Unit tests for {@link ProgressiveDownloadAction}.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = Config.TARGET_SDK, manifest = Config.NONE)
+public class ProgressiveDownloadActionTest {
+
+ @Test
+ public void testDownloadActionIsNotRemoveAction() throws Exception {
+ ProgressiveDownloadAction action = new ProgressiveDownloadAction("uri", null, false, null);
+ assertThat(action.isRemoveAction()).isFalse();
+ }
+
+ @Test
+ public void testRemoveActionIsRemoveAction() throws Exception {
+ ProgressiveDownloadAction action2 = new ProgressiveDownloadAction("uri", null, true, null);
+ assertThat(action2.isRemoveAction()).isTrue();
+ }
+
+ @Test
+ public void testCreateDownloader() throws Exception {
+ MockitoAnnotations.initMocks(this);
+ ProgressiveDownloadAction action = new ProgressiveDownloadAction("uri", null, false, null);
+ DownloaderConstructorHelper constructorHelper = new DownloaderConstructorHelper(
+ Mockito.mock(Cache.class), DummyDataSource.FACTORY);
+ assertThat(action.createDownloader(constructorHelper)).isNotNull();
+ }
+
+ @Test
+ public void testSameUriCacheKeyDifferentAction_IsSameMedia() throws Exception {
+ ProgressiveDownloadAction action1 = new ProgressiveDownloadAction("uri", null, true, null);
+ ProgressiveDownloadAction action2 = new ProgressiveDownloadAction("uri", null, false, null);
+ assertThat(action1.isSameMedia(action2)).isTrue();
+ }
+
+ @Test
+ public void testNullCacheKeyDifferentUriAction_IsNotSameMedia() throws Exception {
+ ProgressiveDownloadAction action3 = new ProgressiveDownloadAction("uri2", null, true, null);
+ ProgressiveDownloadAction action4 = new ProgressiveDownloadAction("uri", null, false, null);
+ assertThat(action3.isSameMedia(action4)).isFalse();
+ }
+
+ @Test
+ public void testSameCacheKeyDifferentUriAction_IsSameMedia() throws Exception {
+ ProgressiveDownloadAction action5 = new ProgressiveDownloadAction("uri2", "key", true, null);
+ ProgressiveDownloadAction action6 = new ProgressiveDownloadAction("uri", "key", false, null);
+ assertThat(action5.isSameMedia(action6)).isTrue();
+ }
+
+ @Test
+ public void testSameUriDifferentCacheKeyAction_IsNotSameMedia() throws Exception {
+ ProgressiveDownloadAction action7 = new ProgressiveDownloadAction("uri", "key", true, null);
+ ProgressiveDownloadAction action8 = new ProgressiveDownloadAction("uri", "key2", false, null);
+ assertThat(action7.isSameMedia(action8)).isFalse();
+ }
+
+ @Test
+ public void testEquals() throws Exception {
+ ProgressiveDownloadAction action1 = new ProgressiveDownloadAction("uri", null, true, null);
+ assertThat(action1.equals(action1)).isTrue();
+
+ ProgressiveDownloadAction action2 = new ProgressiveDownloadAction("uri", null, true, null);
+ ProgressiveDownloadAction action3 = new ProgressiveDownloadAction("uri", null, true, null);
+ assertThat(action2.equals(action3)).isTrue();
+
+ ProgressiveDownloadAction action4 = new ProgressiveDownloadAction("uri", null, true, null);
+ ProgressiveDownloadAction action5 = new ProgressiveDownloadAction("uri", null, false, null);
+ assertThat(action4.equals(action5)).isFalse();
+
+ ProgressiveDownloadAction action6 = new ProgressiveDownloadAction("uri", null, true, null);
+ ProgressiveDownloadAction action7 = new ProgressiveDownloadAction("uri", "key", true, null);
+ assertThat(action6.equals(action7)).isFalse();
+
+ ProgressiveDownloadAction action8 = new ProgressiveDownloadAction("uri", "key2", true, null);
+ ProgressiveDownloadAction action9 = new ProgressiveDownloadAction("uri", "key", true, null);
+ assertThat(action8.equals(action9)).isFalse();
+
+ ProgressiveDownloadAction action10 = new ProgressiveDownloadAction("uri", null, true, null);
+ ProgressiveDownloadAction action11 = new ProgressiveDownloadAction("uri2", null, true, null);
+ assertThat(action10.equals(action11)).isFalse();
+ }
+
+ @Test
+ public void testSerializerGetType() throws Exception {
+ ProgressiveDownloadAction action = new ProgressiveDownloadAction("uri", null, false, null);
+ assertThat(action.getType()).isNotNull();
+ }
+
+ @Test
+ public void testSerializerWriteRead() throws Exception {
+ doTestSerializationRoundTrip(new ProgressiveDownloadAction("uri1", null, false, null));
+ doTestSerializationRoundTrip(new ProgressiveDownloadAction("uri2", "key", true, null));
+ }
+
+ private static void doTestSerializationRoundTrip(ProgressiveDownloadAction action1)
+ throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ DataOutputStream output = new DataOutputStream(out);
+ action1.writeToStream(output);
+
+ ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray());
+ DataInputStream input = new DataInputStream(in);
+ DownloadAction action2 =
+ ProgressiveDownloadAction.DESERIALIZER.readFromStream(DownloadAction.MASTER_VERSION, input);
+
+ assertThat(action2).isEqualTo(action1);
+ }
+
+}
diff --git a/library/dash/src/androidTest/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java b/library/dash/src/androidTest/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java
new file mode 100644
index 00000000000..996dad3d2b0
--- /dev/null
+++ b/library/dash/src/androidTest/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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.source.dash.offline;
+
+import static com.google.android.exoplayer2.source.dash.offline.DashDownloadTestData.TEST_MPD;
+import static com.google.android.exoplayer2.source.dash.offline.DashDownloadTestData.TEST_MPD_URI;
+import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCacheEmpty;
+import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCachedData;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.os.ConditionVariable;
+import android.test.InstrumentationTestCase;
+import android.test.UiThreadTest;
+import com.google.android.exoplayer2.offline.DownloadManager;
+import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
+import com.google.android.exoplayer2.source.dash.manifest.RepresentationKey;
+import com.google.android.exoplayer2.testutil.FakeDataSet;
+import com.google.android.exoplayer2.testutil.FakeDataSource;
+import com.google.android.exoplayer2.testutil.MockitoUtil;
+import com.google.android.exoplayer2.testutil.TestUtil;
+import com.google.android.exoplayer2.upstream.DataSource.Factory;
+import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
+import com.google.android.exoplayer2.upstream.cache.SimpleCache;
+import com.google.android.exoplayer2.util.Util;
+import java.io.File;
+
+/**
+ * Tests {@link DownloadManager}.
+ */
+public class DownloadManagerDashTest extends InstrumentationTestCase {
+
+ private static final int ASSERT_TRUE_TIMEOUT = 1000;
+
+ private SimpleCache cache;
+ private File tempFolder;
+ private FakeDataSet fakeDataSet;
+ private DownloadManager downloadManager;
+ private RepresentationKey fakeRepresentationKey1;
+ private RepresentationKey fakeRepresentationKey2;
+ private TestDownloadListener downloadListener;
+ private File actionFile;
+
+ @UiThreadTest
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ Context context = getInstrumentation().getContext();
+ tempFolder = Util.createTempDirectory(context, "ExoPlayerTest");
+ File cacheFolder = new File(tempFolder, "cache");
+ cacheFolder.mkdir();
+ cache = new SimpleCache(cacheFolder, new NoOpCacheEvictor());
+ MockitoUtil.setUpMockito(this);
+ fakeDataSet = new FakeDataSet()
+ .setData(TEST_MPD_URI, TEST_MPD)
+ .setRandomData("audio_init_data", 10)
+ .setRandomData("audio_segment_1", 4)
+ .setRandomData("audio_segment_2", 5)
+ .setRandomData("audio_segment_3", 6)
+ .setRandomData("text_segment_1", 1)
+ .setRandomData("text_segment_2", 2)
+ .setRandomData("text_segment_3", 3);
+
+ fakeRepresentationKey1 = new RepresentationKey(0, 0, 0);
+ fakeRepresentationKey2 = new RepresentationKey(0, 1, 0);
+ actionFile = new File(tempFolder, "actionFile");
+ createDownloadManager();
+ }
+
+ @UiThreadTest
+ @Override
+ public void tearDown() throws Exception {
+ downloadManager.release();
+ Util.recursiveDelete(tempFolder);
+ super.tearDown();
+ }
+
+ // Disabled due to flakiness.
+ public void disabledTestSaveAndLoadActionFile() throws Throwable {
+ // Configure fakeDataSet to block until interrupted when TEST_MPD is read.
+ fakeDataSet.newData(TEST_MPD_URI)
+ .appendReadAction(new Runnable() {
+ @SuppressWarnings("InfiniteLoopStatement")
+ @Override
+ public void run() {
+ try {
+ // Wait until interrupted.
+ while (true) {
+ Thread.sleep(100000);
+ }
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ })
+ .appendReadData(TEST_MPD)
+ .endData();
+
+ // Run DM accessing code on UI/main thread as it should be. Also not to block handling of loaded
+ // actions.
+ runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ // Setup an Action and immediately release the DM.
+ handleDownloadAction(fakeRepresentationKey1, fakeRepresentationKey2);
+ downloadManager.release();
+
+ assertThat(actionFile.exists()).isTrue();
+ assertThat(actionFile.length()).isGreaterThan(0L);
+
+ assertCacheEmpty(cache);
+
+ // Revert fakeDataSet to normal.
+ fakeDataSet.setData(TEST_MPD_URI, TEST_MPD);
+
+ createDownloadManager();
+ }
+ });
+
+ // Block on the test thread.
+ blockUntilTasksCompleteAndThrowAnyDownloadError();
+ assertCachedData(cache, fakeDataSet);
+ }
+
+ public void testHandleDownloadAction() throws Throwable {
+ handleDownloadAction(fakeRepresentationKey1, fakeRepresentationKey2);
+ blockUntilTasksCompleteAndThrowAnyDownloadError();
+ assertCachedData(cache, fakeDataSet);
+ }
+
+ public void testHandleMultipleDownloadAction() throws Throwable {
+ handleDownloadAction(fakeRepresentationKey1);
+ handleDownloadAction(fakeRepresentationKey2);
+ blockUntilTasksCompleteAndThrowAnyDownloadError();
+ assertCachedData(cache, fakeDataSet);
+ }
+
+ public void testHandleInterferingDownloadAction() throws Throwable {
+ fakeDataSet
+ .newData("audio_segment_2")
+ .appendReadAction(
+ new Runnable() {
+ @Override
+ public void run() {
+ handleDownloadAction(fakeRepresentationKey2);
+ }
+ })
+ .appendReadData(TestUtil.buildTestData(5))
+ .endData();
+
+ handleDownloadAction(fakeRepresentationKey1);
+
+ blockUntilTasksCompleteAndThrowAnyDownloadError();
+ assertCachedData(cache, fakeDataSet);
+ }
+
+ public void testHandleRemoveAction() throws Throwable {
+ handleDownloadAction(fakeRepresentationKey1);
+
+ blockUntilTasksCompleteAndThrowAnyDownloadError();
+
+ handleRemoveAction();
+
+ blockUntilTasksCompleteAndThrowAnyDownloadError();
+
+ assertCacheEmpty(cache);
+ }
+
+ // Disabled due to flakiness.
+ public void disabledTestHandleRemoveActionBeforeDownloadFinish() throws Throwable {
+ handleDownloadAction(fakeRepresentationKey1);
+ handleRemoveAction();
+
+ blockUntilTasksCompleteAndThrowAnyDownloadError();
+
+ assertCacheEmpty(cache);
+ }
+
+ public void testHandleInterferingRemoveAction() throws Throwable {
+ final ConditionVariable downloadInProgressCondition = new ConditionVariable();
+ fakeDataSet.newData("audio_segment_2")
+ .appendReadAction(new Runnable() {
+ @Override
+ public void run() {
+ downloadInProgressCondition.open();
+ }
+ })
+ .appendReadData(TestUtil.buildTestData(5))
+ .endData();
+
+ handleDownloadAction(fakeRepresentationKey1);
+
+ assertThat(downloadInProgressCondition.block(ASSERT_TRUE_TIMEOUT)).isTrue();
+
+ handleRemoveAction();
+
+ blockUntilTasksCompleteAndThrowAnyDownloadError();
+
+ assertCacheEmpty(cache);
+ }
+
+ private void blockUntilTasksCompleteAndThrowAnyDownloadError() throws Throwable {
+ downloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+ }
+
+ private void handleDownloadAction(RepresentationKey... keys) {
+ downloadManager.handleAction(new DashDownloadAction(TEST_MPD_URI, false, null, keys));
+ }
+
+ private void handleRemoveAction() {
+ downloadManager.handleAction(new DashDownloadAction(TEST_MPD_URI, true, null));
+ }
+
+ private void createDownloadManager() {
+ Factory fakeDataSourceFactory = new FakeDataSource.Factory(null).setFakeDataSet(fakeDataSet);
+ downloadManager =
+ new DownloadManager(
+ new DownloaderConstructorHelper(cache, fakeDataSourceFactory),
+ 1,
+ 3,
+ actionFile.getAbsolutePath(),
+ DashDownloadAction.DESERIALIZER);
+
+ downloadListener = new TestDownloadListener(downloadManager, this);
+ downloadManager.addListener(downloadListener);
+ downloadManager.startDownloads();
+ }
+
+}
diff --git a/library/dash/src/androidTest/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java b/library/dash/src/androidTest/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java
new file mode 100644
index 00000000000..8da54ff89e7
--- /dev/null
+++ b/library/dash/src/androidTest/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java
@@ -0,0 +1,212 @@
+/*
+ * 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.source.dash.offline;
+
+import static com.google.android.exoplayer2.source.dash.offline.DashDownloadTestData.TEST_MPD;
+import static com.google.android.exoplayer2.source.dash.offline.DashDownloadTestData.TEST_MPD_URI;
+import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCacheEmpty;
+import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCachedData;
+
+import android.content.Context;
+import android.content.Intent;
+import android.test.InstrumentationTestCase;
+import com.google.android.exoplayer2.offline.DownloadManager;
+import com.google.android.exoplayer2.offline.DownloadService;
+import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
+import com.google.android.exoplayer2.source.dash.manifest.RepresentationKey;
+import com.google.android.exoplayer2.testutil.FakeDataSet;
+import com.google.android.exoplayer2.testutil.FakeDataSource;
+import com.google.android.exoplayer2.testutil.TestUtil;
+import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
+import com.google.android.exoplayer2.upstream.cache.SimpleCache;
+import com.google.android.exoplayer2.util.ConditionVariable;
+import com.google.android.exoplayer2.util.Util;
+import com.google.android.exoplayer2.util.scheduler.Requirements;
+import com.google.android.exoplayer2.util.scheduler.Scheduler;
+import java.io.File;
+
+/**
+ * Unit tests for {@link DownloadService}.
+ */
+public class DownloadServiceDashTest extends InstrumentationTestCase {
+
+ private SimpleCache cache;
+ private File tempFolder;
+ private FakeDataSet fakeDataSet;
+ private RepresentationKey fakeRepresentationKey1;
+ private RepresentationKey fakeRepresentationKey2;
+ private Context context;
+ private DownloadService dashDownloadService;
+ private ConditionVariable pauseDownloadCondition;
+ private TestDownloadListener testDownloadListener;
+
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ tempFolder = Util.createTempDirectory(getInstrumentation().getContext(), "ExoPlayerTest");
+ cache = new SimpleCache(tempFolder, new NoOpCacheEvictor());
+
+ Runnable pauseAction = new Runnable() {
+ @Override
+ public void run() {
+ if (pauseDownloadCondition != null) {
+ try {
+ pauseDownloadCondition.block();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ };
+ fakeDataSet = new FakeDataSet()
+ .setData(TEST_MPD_URI, TEST_MPD)
+ .newData("audio_init_data")
+ .appendReadAction(pauseAction)
+ .appendReadData(TestUtil.buildTestData(10))
+ .endData()
+ .setRandomData("audio_segment_1", 4)
+ .setRandomData("audio_segment_2", 5)
+ .setRandomData("audio_segment_3", 6)
+ .setRandomData("text_segment_1", 1)
+ .setRandomData("text_segment_2", 2)
+ .setRandomData("text_segment_3", 3);
+ DataSource.Factory fakeDataSourceFactory = new FakeDataSource.Factory(null)
+ .setFakeDataSet(fakeDataSet);
+ fakeRepresentationKey1 = new RepresentationKey(0, 0, 0);
+ fakeRepresentationKey2 = new RepresentationKey(0, 1, 0);
+
+ context = getInstrumentation().getContext();
+
+ File actionFile = Util.createTempFile(context, "ExoPlayerTest");
+ actionFile.delete();
+ final DownloadManager dashDownloadManager =
+ new DownloadManager(
+ new DownloaderConstructorHelper(cache, fakeDataSourceFactory),
+ 1,
+ 3,
+ actionFile.getAbsolutePath(),
+ DashDownloadAction.DESERIALIZER);
+ testDownloadListener = new TestDownloadListener(dashDownloadManager, this);
+ dashDownloadManager.addListener(testDownloadListener);
+ dashDownloadManager.startDownloads();
+
+ try {
+ runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ dashDownloadService =
+ new DownloadService(101010) {
+
+ @Override
+ protected DownloadManager getDownloadManager() {
+ return dashDownloadManager;
+ }
+
+ @Override
+ protected String getNotificationChannelId() {
+ return null;
+ }
+
+ @Override
+ protected Scheduler getScheduler() {
+ return null;
+ }
+
+ @Override
+ protected Requirements getRequirements() {
+ return null;
+ }
+ };
+ dashDownloadService.onCreate();
+ }
+ });
+ } catch (Throwable throwable) {
+ throw new Exception(throwable);
+ }
+ }
+
+ @Override
+ public void tearDown() throws Exception {
+ try {
+ runTestOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ dashDownloadService.onDestroy();
+ }
+ });
+ } catch (Throwable throwable) {
+ throw new Exception(throwable);
+ }
+ Util.recursiveDelete(tempFolder);
+ super.tearDown();
+ }
+
+ public void testMultipleDownloadAction() throws Throwable {
+ downloadKeys(fakeRepresentationKey1);
+ downloadKeys(fakeRepresentationKey2);
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+
+ assertCachedData(cache, fakeDataSet);
+ }
+
+ public void testRemoveAction() throws Throwable {
+ downloadKeys(fakeRepresentationKey1, fakeRepresentationKey2);
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+
+ removeAll();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+
+ assertCacheEmpty(cache);
+ }
+
+ public void testRemoveBeforeDownloadComplete() throws Throwable {
+ pauseDownloadCondition = new ConditionVariable();
+ downloadKeys(fakeRepresentationKey1, fakeRepresentationKey2);
+
+ removeAll();
+
+ testDownloadListener.blockUntilTasksCompleteAndThrowAnyDownloadError();
+
+ assertCacheEmpty(cache);
+ }
+
+ private void removeAll() throws Throwable {
+ callDownloadServiceOnStart(new DashDownloadAction(TEST_MPD_URI, true, null));
+ }
+
+ private void downloadKeys(RepresentationKey... keys) throws Throwable {
+ callDownloadServiceOnStart(new DashDownloadAction(TEST_MPD_URI, false, null, keys));
+ }
+
+ private void callDownloadServiceOnStart(final DashDownloadAction action) throws Throwable {
+ runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ Intent startIntent =
+ DownloadService.createAddDownloadActionIntent(
+ context, DownloadService.class, action);
+ dashDownloadService.onStartCommand(startIntent, 0, 0);
+ }
+ });
+ }
+
+}
diff --git a/library/dash/src/androidTest/java/com/google/android/exoplayer2/source/dash/offline/TestDownloadListener.java b/library/dash/src/androidTest/java/com/google/android/exoplayer2/source/dash/offline/TestDownloadListener.java
new file mode 100644
index 00000000000..2e6688fe071
--- /dev/null
+++ b/library/dash/src/androidTest/java/com/google/android/exoplayer2/source/dash/offline/TestDownloadListener.java
@@ -0,0 +1,74 @@
+/*
+ * 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.source.dash.offline;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.test.InstrumentationTestCase;
+import com.google.android.exoplayer2.offline.DownloadManager;
+import com.google.android.exoplayer2.offline.DownloadManager.DownloadListener;
+import com.google.android.exoplayer2.offline.DownloadManager.DownloadState;
+
+/** A {@link DownloadListener} for testing. */
+/*package*/ final class TestDownloadListener implements DownloadListener {
+
+ private static final int TIMEOUT = 1000;
+
+ private final DownloadManager downloadManager;
+ private final InstrumentationTestCase testCase;
+ private final android.os.ConditionVariable downloadFinishedCondition;
+ private Throwable downloadError;
+
+ public TestDownloadListener(DownloadManager downloadManager, InstrumentationTestCase testCase) {
+ this.downloadManager = downloadManager;
+ this.testCase = testCase;
+ this.downloadFinishedCondition = new android.os.ConditionVariable();
+ }
+
+ @Override
+ public void onStateChange(DownloadManager downloadManager, DownloadState downloadState) {
+ if (downloadState.state == DownloadState.STATE_ERROR && downloadError == null) {
+ downloadError = downloadState.error;
+ }
+ }
+
+ @Override
+ public void onIdle(DownloadManager downloadManager) {
+ downloadFinishedCondition.open();
+ }
+
+ /**
+ * Blocks until all remove and download tasks are complete and throws an exception if there was an
+ * error.
+ */
+ public void blockUntilTasksCompleteAndThrowAnyDownloadError() throws Throwable {
+ testCase.runTestOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (downloadManager.isIdle()) {
+ downloadFinishedCondition.open();
+ } else {
+ downloadFinishedCondition.close();
+ }
+ }
+ });
+ assertThat(downloadFinishedCondition.block(TIMEOUT)).isTrue();
+ if (downloadError != null) {
+ throw new Exception(downloadError);
+ }
+ }
+}
diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadAction.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadAction.java
new file mode 100644
index 00000000000..ed87a835cf7
--- /dev/null
+++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadAction.java
@@ -0,0 +1,85 @@
+/*
+ * 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.source.dash.offline;
+
+import android.net.Uri;
+import com.google.android.exoplayer2.offline.DownloadAction;
+import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
+import com.google.android.exoplayer2.offline.SegmentDownloadAction;
+import com.google.android.exoplayer2.source.dash.manifest.RepresentationKey;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+
+/** An action to download or remove downloaded DASH streams. */
+public final class DashDownloadAction extends SegmentDownloadAction
+ *
+ *
+ * If the task is a remove action then preceding conflicting tasks are canceled.
+ */
+ private void maybeStartTasks() {
+ if (released) {
+ return;
+ }
+
+ boolean skipDownloadActions = downloadsStopped
+ || activeDownloadTasks.size() == maxActiveDownloadTasks;
+ for (int i = 0; i < tasks.size(); i++) {
+ DownloadTask downloadTask = tasks.get(i);
+ if (!downloadTask.canStart()) {
+ continue;
+ }
+
+ DownloadAction downloadAction = downloadTask.downloadAction;
+ boolean removeAction = downloadAction.isRemoveAction();
+ if (!removeAction && skipDownloadActions) {
+ continue;
+ }
+
+ boolean canStartTask = true;
+ for (int j = 0; j < i; j++) {
+ DownloadTask task = tasks.get(j);
+ if (task.downloadAction.isSameMedia(downloadAction)) {
+ if (removeAction) {
+ canStartTask = false;
+ logd(downloadTask + " clashes with " + task);
+ task.cancel();
+ // Continue loop to cancel any other preceding clashing tasks.
+ } else if (task.downloadAction.isRemoveAction()) {
+ canStartTask = false;
+ skipDownloadActions = true;
+ break;
+ }
+ }
+ }
+
+ if (canStartTask) {
+ downloadTask.start();
+ if (!removeAction) {
+ activeDownloadTasks.add(downloadTask);
+ skipDownloadActions = activeDownloadTasks.size() == maxActiveDownloadTasks;
+ }
+ }
+ }
+ }
+
+ private void maybeNotifyListenersIdle() {
+ if (!isIdle()) {
+ return;
+ }
+ logd("Notify idle state");
+ for (DownloadListener listener : listeners) {
+ listener.onIdle(this);
+ }
+ }
+
+ private void onTaskStateChange(DownloadTask downloadTask) {
+ if (released) {
+ return;
+ }
+ logd("Task state is changed", downloadTask);
+ boolean stopped = !downloadTask.isRunning();
+ if (stopped) {
+ activeDownloadTasks.remove(downloadTask);
+ }
+ notifyListenersTaskStateChange(downloadTask);
+ if (downloadTask.isFinished()) {
+ tasks.remove(downloadTask);
+ saveActions();
+ }
+ if (stopped) {
+ maybeStartTasks();
+ maybeNotifyListenersIdle();
+ }
+ }
+
+ private void notifyListenersTaskStateChange(DownloadTask downloadTask) {
+ DownloadState downloadState = downloadTask.getDownloadState();
+ for (DownloadListener listener : listeners) {
+ listener.onStateChange(this, downloadState);
+ }
+ }
+
+ private void loadActions() {
+ fileIOHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ DownloadAction[] loadedActions;
+ try {
+ loadedActions = actionFile.load(DownloadManager.this.deserializers);
+ logd("Action file is loaded.");
+ } catch (Throwable e) {
+ Log.e(TAG, "Action file loading failed.", e);
+ loadedActions = new DownloadAction[0];
+ }
+ final DownloadAction[] actions = loadedActions;
+ handler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ try {
+ for (DownloadAction action : actions) {
+ createDownloadTask(action);
+ }
+ logd("Tasks are created.");
+ maybeStartTasks();
+ } finally {
+ actionFileLoadCompleted = true;
+ maybeNotifyListenersIdle();
+ }
+ }
+ });
+ }
+ });
+ }
+
+ private void saveActions() {
+ if (!actionFileLoadCompleted || released) {
+ return;
+ }
+ final DownloadAction[] actions = new DownloadAction[tasks.size()];
+ for (int i = 0; i < tasks.size(); i++) {
+ actions[i] = tasks.get(i).downloadAction;
+ }
+ fileIOHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ actionFile.store(actions);
+ logd("Actions persisted.");
+ } catch (IOException e) {
+ Log.e(TAG, "Persisting actions failed.", e);
+ }
+ }
+ });
+ }
+
+ private void logd(String message) {
+ if (DEBUG) {
+ Log.d(TAG, message);
+ }
+ }
+
+ private void logd(String message, DownloadTask task) {
+ logd(message + ": " + task);
+ }
+
+ private static DownloadState[] getDownloadStates(ArrayList
+ * +-------+-------+-----+---------+--------+--------+-----+
+ * |waiting|started|ended|canceling|canceled|stopping|error|
+ * +---------+-------+-------+-----+---------+--------+--------+-----+
+ * |waiting | | X | | X | | | |
+ * |started | | | X | X | | X | X |
+ * |canceling| | | | | X | | |
+ * |stopping | X | | | | | | |
+ * +---------+-------+-------+-----+---------+--------+--------+-----+
+ *
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({STATE_WAITING, STATE_STARTED, STATE_ENDED, STATE_CANCELING, STATE_CANCELED,
+ STATE_STOPPING, STATE_ERROR})
+ public @interface State {}
+ /** The task is waiting to be started. */
+ public static final int STATE_WAITING = 0;
+ /** The task is currently started. */
+ public static final int STATE_STARTED = 1;
+ /** The task completed. */
+ public static final int STATE_ENDED = 2;
+ /** The task is about to be canceled. */
+ public static final int STATE_CANCELING = 3;
+ /** The task was canceled. */
+ public static final int STATE_CANCELED = 4;
+ /** The task is about to be stopped. */
+ public static final int STATE_STOPPING = 5;
+ /** The task failed. */
+ public static final int STATE_ERROR = 6;
+
+ /** Returns the state string for the given state value. */
+ public static String getStateString(@State int state) {
+ switch (state) {
+ case STATE_WAITING:
+ return "WAITING";
+ case STATE_STARTED:
+ return "STARTED";
+ case STATE_ENDED:
+ return "ENDED";
+ case STATE_CANCELING:
+ return "CANCELING";
+ case STATE_CANCELED:
+ return "CANCELED";
+ case STATE_STOPPING:
+ return "STOPPING";
+ case STATE_ERROR:
+ return "ERROR";
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ /** Unique id of the task. */
+ public final int taskId;
+ /** The {@link DownloadAction} which is being executed. */
+ public final DownloadAction downloadAction;
+ /** The state of the task. See {@link State}. */
+ public final @State int state;
+ /**
+ * The download percentage, or {@link Float#NaN} if it can't be calculated or the task is for
+ * removing.
+ */
+ public final float downloadPercentage;
+ /**
+ * The downloaded bytes, or {@link C#LENGTH_UNSET} if it hasn't been calculated yet or the task
+ * is for removing.
+ */
+ public final long downloadedBytes;
+ /** If {@link #state} is {@link #STATE_ERROR} then this is the cause, otherwise null. */
+ public final Throwable error;
+
+ private DownloadState(
+ int taskId,
+ DownloadAction downloadAction,
+ int state,
+ float downloadPercentage,
+ long downloadedBytes,
+ Throwable error) {
+ this.taskId = taskId;
+ this.downloadAction = downloadAction;
+ this.state = state;
+ this.downloadPercentage = downloadPercentage;
+ this.downloadedBytes = downloadedBytes;
+ this.error = error;
+ }
+
+ /** Returns whether the task is finished. */
+ public boolean isFinished() {
+ return state == STATE_ERROR || state == STATE_ENDED || state == STATE_CANCELED;
+ }
+ }
+
+ private static final class DownloadTask implements Runnable {
+
+ private final int id;
+ private final DownloadManager downloadManager;
+ private final DownloadAction downloadAction;
+ private final int minRetryCount;
+ private volatile @State int currentState;
+ private volatile Downloader downloader;
+ private Thread thread;
+ private Throwable error;
+
+ private DownloadTask(
+ int id, DownloadManager downloadManager, DownloadAction downloadAction, int minRetryCount) {
+ this.id = id;
+ this.downloadManager = downloadManager;
+ this.downloadAction = downloadAction;
+ this.currentState = STATE_WAITING;
+ this.minRetryCount = minRetryCount;
+ }
+
+ public DownloadState getDownloadState() {
+ return new DownloadState(
+ id, downloadAction, currentState, getDownloadPercentage(), getDownloadedBytes(), error);
+ }
+
+ /** Returns the {@link DownloadAction}. */
+ public DownloadAction getDownloadAction() {
+ return downloadAction;
+ }
+
+ /** Returns the state of the task. */
+ public @State int getState() {
+ return currentState;
+ }
+
+ /** Returns whether the task is finished. */
+ public boolean isFinished() {
+ return currentState == STATE_ERROR || currentState == STATE_ENDED
+ || currentState == STATE_CANCELED;
+ }
+
+ /** Returns whether the task is running. */
+ public boolean isRunning() {
+ return currentState == STATE_STARTED
+ || currentState == STATE_STOPPING
+ || currentState == STATE_CANCELING;
+ }
+
+ /**
+ * Returns the download percentage, or {@link Float#NaN} if it can't be calculated yet. This
+ * value can be an estimation.
+ */
+ public float getDownloadPercentage() {
+ return downloader != null ? downloader.getDownloadPercentage() : Float.NaN;
+ }
+
+ /**
+ * Returns the total number of downloaded bytes, or {@link C#LENGTH_UNSET} if it hasn't been
+ * calculated yet.
+ */
+ public long getDownloadedBytes() {
+ return downloader != null ? downloader.getDownloadedBytes() : C.LENGTH_UNSET;
+ }
+
+ @Override
+ public String toString() {
+ if (!DEBUG) {
+ return super.toString();
+ }
+ return downloadAction.getType()
+ + ' '
+ + (downloadAction.isRemoveAction() ? "remove" : "download")
+ + ' '
+ + downloadAction.getData()
+ + ' '
+ + DownloadState.getStateString(currentState);
+ }
+
+ private void start() {
+ if (changeStateAndNotify(STATE_WAITING, STATE_STARTED)) {
+ thread = new Thread(this);
+ thread.start();
+ }
+ }
+
+ private boolean canStart() {
+ return currentState == STATE_WAITING;
+ }
+
+ private void cancel() {
+ if (changeStateAndNotify(STATE_WAITING, STATE_CANCELING)) {
+ downloadManager.handler.post(new Runnable() {
+ @Override
+ public void run() {
+ changeStateAndNotify(STATE_CANCELING, STATE_CANCELED);
+ }
+ });
+ } else if (changeStateAndNotify(STATE_STARTED, STATE_CANCELING)) {
+ thread.interrupt();
+ }
+ }
+
+ private void stop() {
+ if (changeStateAndNotify(STATE_STARTED, STATE_STOPPING)) {
+ downloadManager.logd("Stopping", this);
+ thread.interrupt();
+ }
+ }
+
+ private boolean changeStateAndNotify(@State int oldState, @State int newState) {
+ return changeStateAndNotify(oldState, newState, null);
+ }
+
+ private boolean changeStateAndNotify(@State int oldState, @State int newState,
+ Throwable error) {
+ if (currentState != oldState) {
+ return false;
+ }
+ currentState = newState;
+ this.error = error;
+ downloadManager.onTaskStateChange(DownloadTask.this);
+ return true;
+ }
+
+ /* Methods running on download thread. */
+
+ @Override
+ public void run() {
+ downloadManager.logd("Task is started", DownloadTask.this);
+ Throwable error = null;
+ try {
+ downloader = downloadAction.createDownloader(downloadManager.downloaderConstructorHelper);
+ if (downloadAction.isRemoveAction()) {
+ downloader.remove();
+ } else {
+ int errorCount = 0;
+ long errorPosition = C.LENGTH_UNSET;
+ while (true) {
+ try {
+ downloader.download(null);
+ break;
+ } catch (IOException e) {
+ long downloadedBytes = downloader.getDownloadedBytes();
+ if (downloadedBytes != errorPosition) {
+ downloadManager.logd(
+ "Reset error count. downloadedBytes = " + downloadedBytes, this);
+ errorPosition = downloadedBytes;
+ errorCount = 0;
+ }
+ if (currentState != STATE_STARTED || ++errorCount > minRetryCount) {
+ throw e;
+ }
+ downloadManager.logd("Download error. Retry " + errorCount, this);
+ Thread.sleep(getRetryDelayMillis(errorCount));
+ }
+ }
+ }
+ } catch (Throwable e){
+ error = e;
+ }
+ final Throwable finalError = error;
+ downloadManager.handler.post(new Runnable() {
+ @Override
+ public void run() {
+ if (changeStateAndNotify(STATE_STARTED,
+ finalError != null ? STATE_ERROR : STATE_ENDED, finalError)
+ || changeStateAndNotify(STATE_CANCELING, STATE_CANCELED)
+ || changeStateAndNotify(STATE_STOPPING, STATE_WAITING)) {
+ return;
+ }
+ throw new IllegalStateException();
+ }
+ });
+ }
+
+ private int getRetryDelayMillis(int errorCount) {
+ return Math.min((errorCount - 1) * 1000, 5000);
+ }
+ }
+
+}
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java
new file mode 100644
index 00000000000..b6899299c94
--- /dev/null
+++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java
@@ -0,0 +1,396 @@
+/*
+ * 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 android.app.Notification;
+import android.app.Notification.Builder;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.support.annotation.CallSuper;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.offline.DownloadManager.DownloadState;
+import com.google.android.exoplayer2.util.Util;
+import com.google.android.exoplayer2.util.scheduler.Requirements;
+import com.google.android.exoplayer2.util.scheduler.RequirementsWatcher;
+import com.google.android.exoplayer2.util.scheduler.Scheduler;
+import java.io.IOException;
+
+/**
+ * A {@link Service} that downloads streams in the background.
+ *
+ * {@literal
+ *
+ *
+ * The service to be scheduled must be defined in the manifest with an intent-filter:
+ *
+ * {@literal
+ *
+ */
+@TargetApi(21)
+public final class PlatformScheduler implements Scheduler {
+
+ private static final String TAG = "PlatformScheduler";
+ private static final String SERVICE_ACTION = "SERVICE_ACTION";
+ private static final String SERVICE_PACKAGE = "SERVICE_PACKAGE";
+ private static final String REQUIREMENTS = "REQUIREMENTS";
+
+ private final int jobId;
+ private final JobInfo jobInfo;
+ private final JobScheduler jobScheduler;
+
+ /**
+ * @param context Used to access to {@link JobScheduler} service.
+ * @param requirements The requirements to execute the job.
+ * @param jobId Unique identifier for the job. Using the same id as a previous job can cause that
+ * job to be replaced or canceled.
+ * @param serviceAction The action which the service will be started with.
+ * @param servicePackage The package of the service which contains the logic of the job.
+ */
+ public PlatformScheduler(
+ Context context,
+ Requirements requirements,
+ int jobId,
+ String serviceAction,
+ String servicePackage) {
+ this.jobId = jobId;
+ this.jobInfo = buildJobInfo(context, requirements, jobId, serviceAction, servicePackage);
+ this.jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
+ }
+
+ @Override
+ public boolean schedule() {
+ int result = jobScheduler.schedule(jobInfo);
+ logd("Scheduling JobScheduler job: " + jobId + " result: " + result);
+ return result == JobScheduler.RESULT_SUCCESS;
+ }
+
+ @Override
+ public boolean cancel() {
+ logd("Canceling JobScheduler job: " + jobId);
+ jobScheduler.cancel(jobId);
+ return true;
+ }
+
+ private static JobInfo buildJobInfo(
+ Context context,
+ Requirements requirements,
+ int jobId,
+ String serviceAction,
+ String servicePackage) {
+ JobInfo.Builder builder =
+ new JobInfo.Builder(jobId, new ComponentName(context, PlatformSchedulerService.class));
+
+ int networkType;
+ switch (requirements.getRequiredNetworkType()) {
+ case Requirements.NETWORK_TYPE_NONE:
+ networkType = JobInfo.NETWORK_TYPE_NONE;
+ break;
+ case Requirements.NETWORK_TYPE_ANY:
+ networkType = JobInfo.NETWORK_TYPE_ANY;
+ break;
+ case Requirements.NETWORK_TYPE_UNMETERED:
+ networkType = JobInfo.NETWORK_TYPE_UNMETERED;
+ break;
+ case Requirements.NETWORK_TYPE_NOT_ROAMING:
+ if (Util.SDK_INT >= 24) {
+ networkType = JobInfo.NETWORK_TYPE_NOT_ROAMING;
+ } else {
+ throw new UnsupportedOperationException();
+ }
+ break;
+ case Requirements.NETWORK_TYPE_METERED:
+ if (Util.SDK_INT >= 26) {
+ networkType = JobInfo.NETWORK_TYPE_METERED;
+ } else {
+ throw new UnsupportedOperationException();
+ }
+ break;
+ default:
+ throw new UnsupportedOperationException();
+ }
+
+ builder.setRequiredNetworkType(networkType);
+ builder.setRequiresDeviceIdle(requirements.isIdleRequired());
+ builder.setRequiresCharging(requirements.isChargingRequired());
+ builder.setPersisted(true);
+
+ // Extras, work duration.
+ PersistableBundle extras = new PersistableBundle();
+ extras.putString(SERVICE_ACTION, serviceAction);
+ extras.putString(SERVICE_PACKAGE, servicePackage);
+ extras.putInt(REQUIREMENTS, requirements.getRequirementsData());
+
+ builder.setExtras(extras);
+ return builder.build();
+ }
+
+ private static void logd(String message) {
+ if (DEBUG) {
+ Log.d(TAG, message);
+ }
+ }
+
+ /** A {@link JobService} to start a service if the requirements are met. */
+ public static final class PlatformSchedulerService extends JobService {
+ @Override
+ public boolean onStartJob(JobParameters params) {
+ logd("PlatformSchedulerService is started");
+ PersistableBundle extras = params.getExtras();
+ Requirements requirements = new Requirements(extras.getInt(REQUIREMENTS));
+ if (requirements.checkRequirements(this)) {
+ logd("requirements are met");
+ String serviceAction = extras.getString(SERVICE_ACTION);
+ String servicePackage = extras.getString(SERVICE_PACKAGE);
+ Intent intent = new Intent(serviceAction).setPackage(servicePackage);
+ logd("starting service action: " + serviceAction + " package: " + servicePackage);
+ if (Util.SDK_INT >= 26) {
+ startForegroundService(intent);
+ } else {
+ startService(intent);
+ }
+ } else {
+ logd("requirements are not met");
+ jobFinished(params, /* needsReschedule */ true);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters params) {
+ return false;
+ }
+ }
+}
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/Requirements.java
new file mode 100644
index 00000000000..80895241c2a
--- /dev/null
+++ b/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/Requirements.java
@@ -0,0 +1,226 @@
+/*
+ * 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.util.scheduler;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkInfo;
+import android.os.BatteryManager;
+import android.os.PowerManager;
+import android.support.annotation.IntDef;
+import android.util.Log;
+import com.google.android.exoplayer2.util.Util;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Defines a set of device state requirements.
+ *
+ *