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 @@ + + + + diff --git a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java new file mode 100644 index 00000000000..908e7f26c71 --- /dev/null +++ b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java @@ -0,0 +1,200 @@ +/* + * 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. + */ +package com.google.android.exoplayer2.ext.jobdispatcher; + +import android.app.Notification; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import com.firebase.jobdispatcher.Constraint; +import com.firebase.jobdispatcher.FirebaseJobDispatcher; +import com.firebase.jobdispatcher.GooglePlayDriver; +import com.firebase.jobdispatcher.Job; +import com.firebase.jobdispatcher.Job.Builder; +import com.firebase.jobdispatcher.JobParameters; +import com.firebase.jobdispatcher.JobService; +import com.firebase.jobdispatcher.Lifetime; +import com.google.android.exoplayer2.util.Util; +import com.google.android.exoplayer2.util.scheduler.Requirements; +import com.google.android.exoplayer2.util.scheduler.Scheduler; + +/** + * A {@link Scheduler} which uses {@link com.firebase.jobdispatcher.FirebaseJobDispatcher} to + * schedule a {@link Service} to be started when its requirements are met. The started service must + * call {@link Service#startForeground(int, Notification)} to make itself a foreground service upon + * being started, as documented by {@link Service#startForegroundService(Intent)}. + * + *

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 states; + + private FakeDownloadAction(String mediaId, boolean removeAction) { + super(mediaId); + this.mediaId = mediaId; + this.removeAction = removeAction; + this.downloader = new FakeDownloader(removeAction); + this.states = new ArrayBlockingQueue<>(10); + } + + @Override + protected String getType() { + return "FakeDownloadAction"; + } + + @Override + protected void writeToStream(DataOutputStream output) throws IOException { + // do nothing. + } + + @Override + public boolean isRemoveAction() { + return removeAction; + } + + @Override + protected boolean isSameMedia(DownloadAction other) { + return other instanceof FakeDownloadAction + && mediaId.equals(((FakeDownloadAction) other).mediaId); + } + + @Override + protected Downloader createDownloader(DownloaderConstructorHelper downloaderConstructorHelper) { + return downloader; + } + + private FakeDownloader getFakeDownloader() { + return downloader; + } + + private FakeDownloadAction post() throws Throwable { + runTestOnUiThread( + new Runnable() { + @Override + public void run() { + downloadManager.handleAction(FakeDownloadAction.this); + } + }); + return this; + } + + private FakeDownloadAction assertDoesNotStart() { + assertThat(downloader.started.block(ASSERT_FALSE_TIME)).isFalse(); + return this; + } + + private FakeDownloadAction assertStarted() { + downloader.assertStarted(ASSERT_TRUE_TIMEOUT); + return assertState(DownloadState.STATE_STARTED); + } + + private FakeDownloadAction assertEnded() { + return assertState(DownloadState.STATE_ENDED); + } + + private FakeDownloadAction assertError() { + return assertState(DownloadState.STATE_ERROR); + } + + private FakeDownloadAction assertCancelled() { + return assertState(DownloadState.STATE_CANCELED); + } + + private FakeDownloadAction assertStopped() { + assertState(DownloadState.STATE_STOPPING); + return assertState(DownloadState.STATE_WAITING); + } + + private FakeDownloadAction assertState(@State int expectedState) { + ArrayList receivedStates = new ArrayList<>(); + while (true) { + Integer state = null; + try { + state = states.poll(ASSERT_TRUE_TIMEOUT, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + fail(e.getMessage()); + } + if (state != null) { + if (expectedState == state) { + return this; + } + receivedStates.add(state); + } else { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < receivedStates.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(DownloadState.getStateString(receivedStates.get(i))); + } + fail( + String.format( + Locale.US, + "expected:<%s> but was:<%s>", + DownloadState.getStateString(expectedState), + sb)); + } + } + } + + private FakeDownloadAction unblock() { + downloader.unblock(); + return this; + } + + private FakeDownloadAction ignoreInterrupts() { + downloader.ignoreInterrupts = true; + return this; + } + + private void onStateChange(int state) { + states.add(state); + } + } + + private static class FakeDownloader implements Downloader { + private final ConditionVariable started; + private final com.google.android.exoplayer2.util.ConditionVariable blocker; + private final boolean removeAction; + private boolean ignoreInterrupts; + private volatile boolean enableDownloadIOException; + private volatile int downloadedBytes = C.LENGTH_UNSET; + + private FakeDownloader(boolean removeAction) { + this.removeAction = removeAction; + this.started = new ConditionVariable(); + this.blocker = new com.google.android.exoplayer2.util.ConditionVariable(); + } + + @Override + public void init() throws InterruptedException, IOException { + // do nothing. + } + + @Override + public void download(@Nullable ProgressListener listener) + throws InterruptedException, IOException { + assertThat(removeAction).isFalse(); + started.open(); + block(); + if (enableDownloadIOException) { + throw new IOException(); + } + } + + @Override + public void remove() throws InterruptedException { + assertThat(removeAction).isTrue(); + started.open(); + block(); + } + + private void block() throws InterruptedException { + try { + while (true) { + try { + blocker.block(); + break; + } catch (InterruptedException e) { + if (!ignoreInterrupts) { + throw e; + } + } + } + } finally { + blocker.close(); + } + } + + private FakeDownloader assertStarted(int timeout) { + assertThat(started.block(timeout)).isTrue(); + started.close(); + return this; + } + + private FakeDownloader unblock() { + blocker.open(); + return this; + } + + @Override + public long getDownloadedBytes() { + return downloadedBytes; + } + + @Override + public float getDownloadPercentage() { + return Float.NaN; + } + } + +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java new file mode 100644 index 00000000000..0724755ac77 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java @@ -0,0 +1,96 @@ +/* + * 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 com.google.android.exoplayer2.offline.DownloadAction.Deserializer; +import com.google.android.exoplayer2.util.AtomicFile; +import com.google.android.exoplayer2.util.Util; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * Stores and loads {@link DownloadAction}s to/from a file. + */ +public final class ActionFile { + + private final AtomicFile atomicFile; + private final File actionFile; + + /** + * @param actionFile File to be used to store and load {@link DownloadAction}s. + */ + public ActionFile(File actionFile) { + this.actionFile = actionFile; + atomicFile = new AtomicFile(actionFile); + } + + /** + * Loads {@link DownloadAction}s from file. + * + * @param deserializers {@link Deserializer}s to deserialize DownloadActions. + * @return Loaded DownloadActions. If the action file doesn't exists returns an empty array. + * @throws IOException If there is an error during loading. + */ + public DownloadAction[] load(Deserializer... deserializers) throws IOException { + if (!actionFile.exists()) { + return new DownloadAction[0]; + } + InputStream inputStream = null; + try { + inputStream = atomicFile.openRead(); + DataInputStream dataInputStream = new DataInputStream(inputStream); + int version = dataInputStream.readInt(); + if (version > DownloadAction.MASTER_VERSION) { + throw new IOException("Not supported action file version: " + version); + } + int actionCount = dataInputStream.readInt(); + DownloadAction[] actions = new DownloadAction[actionCount]; + for (int i = 0; i < actionCount; i++) { + actions[i] = DownloadAction.deserializeFromStream(deserializers, dataInputStream, version); + } + return actions; + } finally { + Util.closeQuietly(inputStream); + } + } + + /** + * Stores {@link DownloadAction}s to file. + * + * @param downloadActions DownloadActions to store to file. + * @throws IOException If there is an error during storing. + */ + public void store(DownloadAction... downloadActions) throws IOException { + DataOutputStream output = null; + try { + output = new DataOutputStream(atomicFile.startWrite()); + output.writeInt(DownloadAction.MASTER_VERSION); + output.writeInt(downloadActions.length); + for (DownloadAction action : downloadActions) { + DownloadAction.serializeToStream(action, output); + } + atomicFile.endWrite(output); + // Avoid calling close twice. + output = null; + } finally { + Util.closeQuietly(output); + } + } + +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java new file mode 100644 index 00000000000..eee9f0530ce --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java @@ -0,0 +1,164 @@ +/* + * 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 java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** Contains the necessary parameters for a download or remove action. */ +public abstract class DownloadAction { + + /** + * Master version for all {@link DownloadAction} serialization/deserialization implementations. On + * each change on any {@link DownloadAction} serialization format this version needs to be + * increased. + */ + public static final int MASTER_VERSION = 0; + + /** Used to deserialize {@link DownloadAction}s. */ + public interface Deserializer { + + /** Returns the type string of the {@link DownloadAction}. This string should be unique. */ + String getType(); + + /** + * Deserializes a {@link DownloadAction} from the {@code input}. + * + * @param version Version of the data. + * @param input DataInputStream to read data from. + * @see DownloadAction#writeToStream(DataOutputStream) + * @see DownloadAction#MASTER_VERSION + */ + DownloadAction readFromStream(int version, DataInputStream input) throws IOException; + } + + /** + * 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. + * @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 tasks; + private final ArrayList activeDownloadTasks; + private final Handler handler; + private final HandlerThread fileIOThread; + private final Handler fileIOHandler; + private final CopyOnWriteArraySet listeners; + + private int nextTaskId; + private boolean actionFileLoadCompleted; + private boolean released; + private boolean downloadsStopped; + + /** + * Constructs a {@link DownloadManager}. + * + * @param constructorHelper A {@link DownloaderConstructorHelper} to create {@link Downloader}s + * for downloading data. + * @param maxActiveDownloadTasks Max number of download tasks to be started in parallel. + * @param minRetryCount The minimum number of times the downloads must be retried before failing. + * @param actionSaveFile File to save active actions. + * @param deserializers Used to deserialize {@link DownloadAction}s. + */ + public DownloadManager( + DownloaderConstructorHelper constructorHelper, + int maxActiveDownloadTasks, + int minRetryCount, + String actionSaveFile, + Deserializer... deserializers) { + this.downloaderConstructorHelper = constructorHelper; + this.maxActiveDownloadTasks = maxActiveDownloadTasks; + this.minRetryCount = minRetryCount; + this.actionFile = new ActionFile(new File(actionSaveFile)); + this.deserializers = deserializers; + this.downloadsStopped = true; + + tasks = new ArrayList<>(); + activeDownloadTasks = new ArrayList<>(); + handler = new Handler(Looper.getMainLooper()); + + fileIOThread = new HandlerThread("DownloadManager file i/o"); + fileIOThread.start(); + fileIOHandler = new Handler(fileIOThread.getLooper()); + + listeners = new CopyOnWriteArraySet<>(); + + loadActions(); + logd("DownloadManager is created"); + } + + /** + * Stops all of the tasks and releases resources. If the action file isn't up to date, + * waits for the changes to be written. + */ + public void release() { + released = true; + for (int i = 0; i < tasks.size(); i++) { + tasks.get(i).stop(); + } + final ConditionVariable fileIOFinishedCondition = new ConditionVariable(); + fileIOHandler.post(new Runnable() { + @Override + public void run() { + fileIOFinishedCondition.open(); + } + }); + fileIOFinishedCondition.block(); + fileIOThread.quit(); + logd("DownloadManager is released"); + } + + /** Stops all of the download tasks. Call {@link #startDownloads()} to restart tasks. */ + public void stopDownloads() { + if (!downloadsStopped) { + downloadsStopped = true; + for (int i = 0; i < activeDownloadTasks.size(); i++) { + activeDownloadTasks.get(i).stop(); + } + logd("Downloads are stopping"); + } + } + + /** Starts the download tasks. */ + public void startDownloads() { + if (downloadsStopped) { + downloadsStopped = false; + maybeStartTasks(); + logd("Downloads are started"); + } + } + + /** + * Adds a {@link DownloadListener}. + * + * @param listener The listener to be added. + */ + public void addListener(DownloadListener listener) { + listeners.add(listener); + } + + /** + * Removes a {@link DownloadListener}. + * + * @param listener The listener to be removed. + */ + public void removeListener(DownloadListener listener) { + listeners.remove(listener); + } + + /** + * Deserializes one {@link DownloadAction} from {@code actionData} and calls {@link + * #handleAction(DownloadAction)}. + * + * @param actionData Serialized {@link DownloadAction} data. + * @return The task id. + * @throws IOException If an error occurs during handling action. + */ + public int handleAction(byte[] actionData) throws IOException { + ByteArrayInputStream input = new ByteArrayInputStream(actionData); + DownloadAction action = DownloadAction.deserializeFromStream(deserializers, input); + return handleAction(action); + } + + /** + * Handles the given {@link DownloadAction}. A task is created and added to the task queue. If + * it's a remove action then this method cancels any download tasks which works on the same media + * immediately. + * + * @param downloadAction Action to be executed. + * @return The task id. + */ + public int handleAction(DownloadAction downloadAction) { + DownloadTask downloadTask = createDownloadTask(downloadAction); + saveActions(); + if (downloadsStopped && !downloadAction.isRemoveAction()) { + logd("Can't start the task as downloads are stopped", downloadTask); + } else { + maybeStartTasks(); + } + return downloadTask.id; + } + + private DownloadTask createDownloadTask(DownloadAction downloadAction) { + DownloadTask downloadTask = new DownloadTask(nextTaskId++, this, downloadAction, minRetryCount); + tasks.add(downloadTask); + logd("Task is added", downloadTask); + notifyListenersTaskStateChange(downloadTask); + return downloadTask; + } + + /** Returns number of tasks. */ + public int getTaskCount() { + return tasks.size(); + } + + /** Returns a {@link DownloadTask} for a task. */ + public DownloadState getDownloadState(int taskId) { + for (int i = 0; i < tasks.size(); i++) { + DownloadTask task = tasks.get(i); + if (task.id == taskId) { + return task.getDownloadState(); + } + } + return null; + } + + /** Returns {@link DownloadState}s for all tasks. */ + public DownloadState[] getDownloadStates() { + return getDownloadStates(tasks); + } + + /** Returns an array of {@link DownloadState}s for active download tasks. */ + public DownloadState[] getActiveDownloadStates() { + return getDownloadStates(activeDownloadTasks); + } + + /** Returns whether there are no active tasks. */ + public boolean isIdle() { + if (!actionFileLoadCompleted) { + return false; + } + for (int i = 0; i < tasks.size(); i++) { + if (tasks.get(i).isRunning()) { + return false; + } + } + return true; + } + + /** + * Iterates through the task queue and starts any task if all of the following are true: + * + *

+ * + * 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 tasks) { + DownloadState[] states = new DownloadState[tasks.size()]; + for (int i = 0; i < tasks.size(); i++) { + DownloadTask task = tasks.get(i); + states[i] = task.getDownloadState(); + } + return states; + } + + /** Represents state of a download task. */ + public static final class DownloadState { + + /** + * Task states. + * + *

Transition map (vertical states are source states): + *

+     *           +-------+-------+-----+---------+--------+--------+-----+
+     *           |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. + * + *

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 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 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 serviceClass; + private final Scheduler scheduler; + + private RequirementsListener( + Context context, Class 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 The type of the representation key object. + */ +public abstract class SegmentDownloadAction extends DownloadAction { + + /** + * Base class for {@link SegmentDownloadAction} {@link Deserializer}s. + * + * @param The type of the representation key object. + */ + protected abstract static class SegmentDownloadActionDeserializer implements Deserializer { + + @Override + public DownloadAction readFromStream(int version, DataInputStream input) throws IOException { + Uri manifestUri = Uri.parse(input.readUTF()); + String data = input.readUTF(); + int keyCount = input.readInt(); + boolean removeAction = keyCount == -1; + K[] keys; + if (removeAction) { + keys = null; + } else { + keys = createKeyArray(keyCount); + for (int i = 0; i < keyCount; i++) { + keys[i] = readKey(input); + } + } + return createDownloadAction(manifestUri, removeAction, data, keys); + } + + /** Deserializes a key from the {@code input}. */ + protected abstract K readKey(DataInputStream input) throws IOException; + + /** Returns a key array. */ + protected abstract K[] createKeyArray(int keyCount); + + /** Returns a {@link DownloadAction}. */ + protected abstract DownloadAction createDownloadAction(Uri manifestUri, boolean removeAction, + String data, K[] keys); + + } + + protected final Uri manifestUri; + protected final K[] keys; + private final boolean removeAction; + + /** + * @param manifestUri The {@link Uri} of the manifest to be downloaded. + * @param removeAction Whether the data will be removed. If {@code false} it will be downloaded. + * @param data Optional custom data for this action. If null, an empty string is used. + * @param keys Keys of representations to be downloaded. If empty or null, all representations are + * downloaded. If {@code removeAction} is true, this is ignored. + */ + protected SegmentDownloadAction(Uri manifestUri, boolean removeAction, String data, K[] keys) { + super(data); + Assertions.checkArgument(!removeAction || keys == null || keys.length == 0); + this.manifestUri = manifestUri; + this.keys = keys; + this.removeAction = removeAction; + } + + @Override + public final boolean isRemoveAction() { + return removeAction; + } + + @Override + public final void writeToStream(DataOutputStream output) throws IOException { + output.writeUTF(manifestUri.toString()); + output.writeUTF(getData()); + if (isRemoveAction()) { + output.writeInt(-1); + } else { + output.writeInt(keys.length); + for (K key : keys) { + writeKey(output, key); + } + } + } + + /** Serializes the {@code key} into the {@code output}. */ + protected abstract void writeKey(DataOutputStream output, K key) throws IOException; + + + @Override + public boolean isSameMedia(DownloadAction other) { + return other instanceof SegmentDownloadAction + && manifestUri.equals(((SegmentDownloadAction) other).manifestUri); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!super.equals(o)) { + return false; + } + SegmentDownloadAction that = (SegmentDownloadAction) o; + return manifestUri.equals(that.manifestUri) + && (keys == null || keys.length == 0 + ? (that.keys == null || that.keys.length == 0) + : (that.keys != null + && that.keys.length == keys.length + && Arrays.asList(keys).containsAll(Arrays.asList(that.keys)))); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + manifestUri.hashCode(); + result = 31 * result + Arrays.hashCode(keys); + return result; + } + +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/PlatformScheduler.java b/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/PlatformScheduler.java new file mode 100644 index 00000000000..2bc0034c9bb --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/scheduler/PlatformScheduler.java @@ -0,0 +1,195 @@ +/* + * 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.app.Notification; +import android.app.Service; +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.PersistableBundle; +import android.util.Log; +import com.google.android.exoplayer2.util.Util; + +/** + * A {@link Scheduler} which uses {@link android.app.job.JobScheduler} to schedule a {@link Service} + * to be started when its requirements are met. The started service must call {@link + * Service#startForeground(int, Notification)} to make itself a foreground service upon being + * started, as documented by {@link Service#startForegroundService(Intent)}. + * + *

To use {@link PlatformScheduler} application needs to have RECEIVE_BOOT_COMPLETED permission + * and you need to define PlatformSchedulerService in your manifest: + * + *

{@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. + * + *

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 { + + public static final Deserializer DESERIALIZER = + new SegmentDownloadActionDeserializer() { + + @Override + public String getType() { + return TYPE; + } + + @Override + protected RepresentationKey readKey(DataInputStream input) throws IOException { + return new RepresentationKey(input.readInt(), input.readInt(), input.readInt()); + } + + @Override + protected RepresentationKey[] createKeyArray(int keyCount) { + return new RepresentationKey[keyCount]; + } + + @Override + protected DownloadAction createDownloadAction(Uri manifestUri, boolean removeAction, + String data, RepresentationKey[] keys) { + return new DashDownloadAction(manifestUri, removeAction, data, keys); + } + + }; + + private static final String TYPE = "DashDownloadAction"; + + /** @see SegmentDownloadAction#SegmentDownloadAction(Uri, boolean, String, Object[]) */ + public DashDownloadAction(Uri manifestUri, boolean removeAction, String data, + RepresentationKey... keys) { + super(manifestUri, removeAction, data, keys); + } + + @Override + protected String getType() { + return TYPE; + } + + @Override + protected DashDownloader createDownloader(DownloaderConstructorHelper constructorHelper) { + DashDownloader downloader = new DashDownloader(manifestUri, constructorHelper); + if (!isRemoveAction()) { + downloader.selectRepresentations(keys); + } + return downloader; + } + + @Override + protected void writeKey(DataOutputStream output, RepresentationKey key) throws IOException { + output.writeInt(key.periodIndex); + output.writeInt(key.adaptationSetIndex); + output.writeInt(key.representationIndex); + } + +} diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadAction.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadAction.java new file mode 100644 index 00000000000..9787ffb0d6c --- /dev/null +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadAction.java @@ -0,0 +1,80 @@ +/* + * 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.hls.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 java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +/** An action to download or remove downloaded HLS streams. */ +public final class HlsDownloadAction extends SegmentDownloadAction { + + public static final Deserializer DESERIALIZER = new SegmentDownloadActionDeserializer() { + + @Override + public String getType() { + return TYPE; + } + + @Override + protected String readKey(DataInputStream input) throws IOException { + return input.readUTF(); + } + + @Override + protected String[] createKeyArray(int keyCount) { + return new String[0]; + } + + @Override + protected DownloadAction createDownloadAction(Uri manifestUri, boolean removeAction, + String data, String[] keys) { + return new HlsDownloadAction(manifestUri, removeAction, data, keys); + } + + }; + + private static final String TYPE = "HlsDownloadAction"; + + /** @see SegmentDownloadAction#SegmentDownloadAction(Uri, boolean, String, Object[]) */ + public HlsDownloadAction(Uri manifestUri, boolean removeAction, String data, String... keys) { + super(manifestUri, removeAction, data, keys); + } + + @Override + protected String getType() { + return TYPE; + } + + @Override + protected HlsDownloader createDownloader(DownloaderConstructorHelper constructorHelper) { + HlsDownloader downloader = new HlsDownloader(manifestUri, constructorHelper); + if (!isRemoveAction()) { + downloader.selectRepresentations(keys); + } + return downloader; + } + + @Override + protected void writeKey(DataOutputStream output, String key) throws IOException { + output.writeUTF(key); + } + +} diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloadAction.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloadAction.java new file mode 100644 index 00000000000..1aabe51237f --- /dev/null +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloadAction.java @@ -0,0 +1,83 @@ +/* + * 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.smoothstreaming.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.smoothstreaming.manifest.TrackKey; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +/** An action to download or remove downloaded SmoothStreaming streams. */ +public final class SsDownloadAction extends SegmentDownloadAction { + + public static final Deserializer DESERIALIZER = + new SegmentDownloadActionDeserializer() { + + @Override + public String getType() { + return TYPE; + } + + @Override + protected TrackKey readKey(DataInputStream input) throws IOException { + return new TrackKey(input.readInt(), input.readInt()); + } + + @Override + protected TrackKey[] createKeyArray(int keyCount) { + return new TrackKey[keyCount]; + } + + @Override + protected DownloadAction createDownloadAction(Uri manifestUri, boolean removeAction, + String data, TrackKey[] keys) { + return new SsDownloadAction(manifestUri, removeAction, data, keys); + } + + }; + + private static final String TYPE = "SsDownloadAction"; + + /** @see SegmentDownloadAction#SegmentDownloadAction(Uri, boolean, String, Object[]) */ + public SsDownloadAction(Uri manifestUri, boolean removeAction, String data, TrackKey... keys) { + super(manifestUri, removeAction, data, keys); + } + + @Override + protected String getType() { + return TYPE; + } + + @Override + protected SsDownloader createDownloader(DownloaderConstructorHelper constructorHelper) { + SsDownloader downloader = new SsDownloader(manifestUri, constructorHelper); + if (!isRemoveAction()) { + downloader.selectRepresentations(keys); + } + return downloader; + } + + @Override + protected void writeKey(DataOutputStream output, TrackKey key) throws IOException { + output.writeInt(key.streamElementIndex); + output.writeInt(key.trackIndex); + } + +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java new file mode 100644 index 00000000000..0b7aa0d719b --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java @@ -0,0 +1,114 @@ +/* + * 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. + */ +package com.google.android.exoplayer2.ui; + +import android.app.Notification; +import android.app.Notification.BigTextStyle; +import android.app.Notification.Builder; +import android.content.Context; +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.offline.DownloadAction; +import com.google.android.exoplayer2.offline.DownloadManager; +import com.google.android.exoplayer2.offline.DownloadManager.DownloadState; +import com.google.android.exoplayer2.util.ErrorMessageProvider; +import com.google.android.exoplayer2.util.Util; + +/** Helper class to create notifications for downloads using {@link DownloadManager}. */ +public final class DownloadNotificationUtil { + + private DownloadNotificationUtil() {} + + /** + * Returns a notification for the given {@link DownloadState}, or null if no notification should + * be displayed. + * + * @param downloadState State of the download. + * @param context Used to access resources. + * @param smallIcon A small icon for the notifications. + * @param channelId The id of the notification channel to use. Only required for API level 26 and + * above. + * @param errorMessageProvider An optional {@link ErrorMessageProvider} for translating download + * errors into readable error messages. + * @return A notification for the given {@link DownloadState}, or null if no notification should + * be displayed. + */ + public static @Nullable Notification createNotification( + DownloadState downloadState, + Context context, + int smallIcon, + String channelId, + @Nullable ErrorMessageProvider errorMessageProvider) { + DownloadAction downloadAction = downloadState.downloadAction; + if (downloadAction.isRemoveAction() || downloadState.state == DownloadState.STATE_CANCELED) { + return null; + } + + Builder notificationBuilder = new Builder(context); + if (Util.SDK_INT >= 26) { + notificationBuilder.setChannelId(channelId); + } + notificationBuilder.setSmallIcon(smallIcon); + + int titleStringId = getTitleStringId(downloadState); + notificationBuilder.setContentTitle(context.getResources().getString(titleStringId)); + + if (downloadState.isRunning()) { + notificationBuilder.setOngoing(true); + float percentage = downloadState.downloadPercentage; + boolean indeterminate = Float.isNaN(percentage); + notificationBuilder.setProgress(100, indeterminate ? 0 : (int) percentage, indeterminate); + } + + String message; + if (downloadState.error != null && errorMessageProvider != null) { + message = errorMessageProvider.getErrorMessage(downloadState.error).second; + } else { + message = downloadAction.getData(); + } + + if (Util.SDK_INT >= 16) { + notificationBuilder.setStyle(new BigTextStyle().bigText(message)); + } else { + notificationBuilder.setContentText(message); + } + return notificationBuilder.getNotification(); + } + + private static int getTitleStringId(DownloadState downloadState) { + int titleStringId; + switch (downloadState.state) { + case DownloadState.STATE_WAITING: + titleStringId = R.string.exo_download_queued; + break; + case DownloadState.STATE_STARTED: + case DownloadState.STATE_STOPPING: + case DownloadState.STATE_CANCELING: + titleStringId = R.string.exo_downloading; + break; + case DownloadState.STATE_ENDED: + titleStringId = R.string.exo_download_completed; + break; + case DownloadState.STATE_ERROR: + titleStringId = R.string.exo_download_failed; + break; + case DownloadState.STATE_CANCELED: + default: + // Never happens. + throw new IllegalStateException(); + } + return titleStringId; + } +}