Open source DownloadService, DownloadManager and related classes

Issue: #2643

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=184844484
This commit is contained in:
eguven 2018-01-24 02:54:44 -08:00 committed by Andrew Lewis
parent 340501246b
commit b3da82dc1c
26 changed files with 4590 additions and 0 deletions

View File

@ -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 ###

View File

@ -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) {

View File

@ -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

View File

@ -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'

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<manifest package="com.google.android.exoplayer2.ext.jobdispatcher"/>

View File

@ -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)}.
*
* <p>To use {@link JobDispatcherScheduler} application needs to have RECEIVE_BOOT_COMPLETED
* permission and you need to define JobDispatcherSchedulerService in your manifest:
*
* <pre>{@literal
* <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
*
* <service
* android:name="com.google.android.exoplayer2.ext.jobdispatcher.JobDispatcherScheduler$JobDispatcherSchedulerService"
* android:exported="false">
* <intent-filter>
* <action android:name="com.firebase.jobdispatcher.ACTION_EXECUTE"/>
* </intent-filter>
* </service>
* }</pre>
*
* The service to be scheduled must be defined in the manifest with an intent-filter:
*
* <pre>{@literal
* <service android:name="MyJobService"
* android:exported="false">
* <intent-filter>
* <action android:name="MyJobService.action"/>
* <category android:name="android.intent.category.DEFAULT"/>
* </intent-filter>
* </service>
* }</pre>
*
* <p>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 <a
* href="https://developers.google.com/android/reference/com/google/android/gms/common/GoogleApiAvailability#isGooglePlayServicesAvailable(android.content.Context)">GoogleApiAvailability</a>
*/
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;
}
}
}

View File

@ -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<Integer> 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<Integer> 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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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;
}
}

View File

@ -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.
*
* <p>By default downloads are stopped. Call {@link #startDownloads()} to start downloads.
*
* <p>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<DownloadTask> tasks;
private final ArrayList<DownloadTask> activeDownloadTasks;
private final Handler handler;
private final HandlerThread fileIOThread;
private final Handler fileIOHandler;
private final CopyOnWriteArraySet<DownloadListener> 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:
*
* <ul>
* <li>It hasn't started yet.
* <li>There are no preceding conflicting tasks.
* <li>If it's a download task then there are no preceding download tasks on hold and the
* maximum number of active downloads hasn't been reached.
* </ul>
*
* 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<DownloadTask> 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.
*
* <p>Transition map (vertical states are source states):
* <pre>
* +-------+-------+-----+---------+--------+--------+-----+
* |waiting|started|ended|canceling|canceled|stopping|error|
* +---------+-------+-------+-----+---------+--------+--------+-----+
* |waiting | | X | | X | | | |
* |started | | | X | X | | X | X |
* |canceling| | | | | X | | |
* |stopping | X | | | | | | |
* +---------+-------+-------+-----+---------+--------+--------+-----+
* </pre>
*/
@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);
}
}
}

View File

@ -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.
*
* <p>To start the service, create an instance of one of the subclasses of {@link DownloadAction}
* and call {@link #addDownloadAction(Context, Class, DownloadAction)} with it.
*/
public abstract class DownloadService extends Service implements DownloadManager.DownloadListener {
/** Use this action to initialize {@link DownloadManager}. */
public static final String ACTION_INIT =
"com.google.android.exoplayer.downloadService.action.INIT";
/** Use this action to add a {@link DownloadAction} to {@link DownloadManager} action queue. */
public static final String ACTION_ADD = "com.google.android.exoplayer.downloadService.action.ADD";
/** Use this action to make {@link DownloadManager} stop download tasks. */
private static final String ACTION_STOP =
"com.google.android.exoplayer.downloadService.action.STOP";
/** Use this action to make {@link DownloadManager} start download tasks. */
private static final String ACTION_START =
"com.google.android.exoplayer.downloadService.action.START";
/** A {@link DownloadAction} to be executed. */
public static final String DOWNLOAD_ACTION = "DownloadAction";
/** Default progress update interval in milliseconds. */
public static final long DEFAULT_PROGRESS_UPDATE_INTERVAL_MILLIS = 1000;
private static final String TAG = "DownloadService";
private static final boolean DEBUG = false;
// Keep requirementsWatcher and scheduler alive beyond DownloadService life span (until the app is
// killed) because it may take long time for Scheduler to start the service.
private static RequirementsWatcher requirementsWatcher;
private static Scheduler scheduler;
private final int notificationIdOffset;
private final long progressUpdateIntervalMillis;
private DownloadManager downloadManager;
private ProgressUpdater progressUpdater;
private int lastStartId;
/** @param notificationIdOffset Value to offset notification ids. Must be greater than 0. */
protected DownloadService(int notificationIdOffset) {
this(notificationIdOffset, DEFAULT_PROGRESS_UPDATE_INTERVAL_MILLIS);
}
/**
* @param notificationIdOffset Value to offset notification ids. Must be greater than 0.
* @param progressUpdateIntervalMillis {@link #onProgressUpdate(DownloadState[])} is called using
* this interval. If it's {@link C#TIME_UNSET}, then {@link
* #onProgressUpdate(DownloadState[])} isn't called.
*/
protected DownloadService(int notificationIdOffset, long progressUpdateIntervalMillis) {
this.notificationIdOffset = notificationIdOffset;
this.progressUpdateIntervalMillis = progressUpdateIntervalMillis;
}
/**
* Creates an {@link Intent} to be used to start this service and adds the {@link DownloadAction}
* to the {@link DownloadManager}.
*
* @param context A {@link Context} of the application calling this service.
* @param clazz Class object of DownloadService or subclass.
* @param downloadAction A {@link DownloadAction} to be executed.
* @return Created Intent.
*/
public static Intent createAddDownloadActionIntent(
Context context, Class<? extends DownloadService> clazz, DownloadAction downloadAction) {
return new Intent(context, clazz)
.setAction(ACTION_ADD)
.putExtra(DOWNLOAD_ACTION, downloadAction.toByteArray());
}
/**
* Adds a {@link DownloadAction} to the {@link DownloadManager}. This will start the download
* service if it was not running.
*
* @param context A {@link Context} of the application calling this service.
* @param clazz Class object of DownloadService or subclass.
* @param downloadAction A {@link DownloadAction} to be executed.
* @see #createAddDownloadActionIntent(Context, Class, DownloadAction)
*/
public static void addDownloadAction(
Context context, Class<? extends DownloadService> clazz, DownloadAction downloadAction) {
context.startService(createAddDownloadActionIntent(context, clazz, downloadAction));
}
@Override
public void onCreate() {
logd("onCreate");
downloadManager = getDownloadManager();
downloadManager.addListener(this);
if (requirementsWatcher == null) {
Requirements requirements = getRequirements();
if (requirements != null) {
scheduler = getScheduler();
RequirementsListener listener =
new RequirementsListener(getApplicationContext(), getClass(), scheduler);
requirementsWatcher =
new RequirementsWatcher(getApplicationContext(), listener, requirements);
requirementsWatcher.start();
}
}
progressUpdater = new ProgressUpdater(this, progressUpdateIntervalMillis);
}
@Override
public void onDestroy() {
logd("onDestroy");
progressUpdater.stop();
downloadManager.removeListener(this);
if (downloadManager.getTaskCount() == 0) {
if (requirementsWatcher != null) {
requirementsWatcher.stop();
requirementsWatcher = null;
}
if (scheduler != null) {
scheduler.cancel();
scheduler = null;
}
}
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
this.lastStartId = startId;
String intentAction = intent != null ? intent.getAction() : null;
if (intentAction == null) {
intentAction = ACTION_INIT;
}
logd("onStartCommand action: " + intentAction + " startId: " + startId);
switch (intentAction) {
case ACTION_INIT:
// Do nothing. DownloadManager and RequirementsWatcher is initialized. If there are download
// or remove tasks loaded from file, they will start if the requirements are met.
break;
case ACTION_ADD:
byte[] actionData = intent.getByteArrayExtra(DOWNLOAD_ACTION);
if (actionData == null) {
onCommandError(intent, new IllegalArgumentException("DownloadAction is missing."));
} else {
try {
onNewTask(intent, downloadManager.handleAction(actionData));
} catch (IOException e) {
onCommandError(intent, e);
}
}
break;
case ACTION_STOP:
downloadManager.stopDownloads();
break;
case ACTION_START:
downloadManager.startDownloads();
break;
default:
onCommandError(intent, new IllegalArgumentException("Unknown action: " + intentAction));
break;
}
if (downloadManager.isIdle()) {
onIdle(null);
}
return START_STICKY;
}
/**
* Returns a {@link DownloadManager} to be used to downloaded content. Called only once in the
* life cycle of the service.
*/
protected abstract DownloadManager getDownloadManager();
/**
* Returns a {@link Scheduler} which contains a job to initialize {@link DownloadService} when the
* requirements are met, or null. If not null, scheduler is used to start downloads even when the
* app isn't running.
*/
protected abstract @Nullable Scheduler getScheduler();
/** Returns requirements for downloads to take place, or null. */
protected abstract @Nullable Requirements getRequirements();
/** Called on error in start command. */
protected void onCommandError(Intent intent, Exception error) {
// Do nothing.
}
/** Called when a new task is added to the {@link DownloadManager}. */
protected void onNewTask(Intent intent, int taskId) {
// Do nothing.
}
/** Returns a notification channelId. See {@link NotificationChannel}. */
protected abstract String getNotificationChannelId();
/**
* Helper method which calls {@link #startForeground(int, Notification)} with {@code
* notificationIdOffset} and {@code foregroundNotification}.
*/
public void startForeground(Notification foregroundNotification) {
// logd("start foreground");
startForeground(notificationIdOffset, foregroundNotification);
}
/**
* Sets/replaces or cancels the notification for the given id.
*
* @param id A unique id for the notification. This value is offset by {@code
* notificationIdOffset}.
* @param notification If not null, it's showed, replacing any previous notification. Otherwise
* any previous notification is canceled.
*/
public void setNotification(int id, @Nullable Notification notification) {
NotificationManager notificationManager =
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (notification != null) {
notificationManager.notify(notificationIdOffset + 1 + id, notification);
} else {
notificationManager.cancel(notificationIdOffset + 1 + id);
}
}
/**
* Override this method to get notified.
*
* <p>{@inheritDoc}
*/
@CallSuper
@Override
public void onStateChange(DownloadManager downloadManager, DownloadState downloadState) {
if (downloadState.state == DownloadState.STATE_STARTED) {
progressUpdater.start();
}
}
/**
* Override this method to get notified.
*
* <p>{@inheritDoc}
*/
@CallSuper
@Override
public void onIdle(DownloadManager downloadManager) {
// Make sure startForeground is called before stopping.
if (Util.SDK_INT >= 26) {
Builder notificationBuilder = new Builder(this, getNotificationChannelId());
Notification foregroundNotification = notificationBuilder.build();
startForeground(foregroundNotification);
}
boolean stopSelfResult = stopSelfResult(lastStartId);
logd("stopSelf(" + lastStartId + ") result: " + stopSelfResult);
}
/** Override this method to get notified on every second while there are active downloads. */
protected void onProgressUpdate(DownloadState[] activeDownloadTasks) {
// Do nothing.
}
private void logd(String message) {
if (DEBUG) {
Log.d(TAG, message);
}
}
private static final class ProgressUpdater implements Runnable {
private final DownloadService downloadService;
private final long progressUpdateIntervalMillis;
private final Handler handler;
private boolean stopped;
public ProgressUpdater(DownloadService downloadService, long progressUpdateIntervalMillis) {
this.downloadService = downloadService;
this.progressUpdateIntervalMillis = progressUpdateIntervalMillis;
this.handler = new Handler(Looper.getMainLooper());
stopped = true;
}
@Override
public void run() {
DownloadState[] activeDownloadTasks =
downloadService.downloadManager.getActiveDownloadStates();
if (activeDownloadTasks.length > 0) {
downloadService.onProgressUpdate(activeDownloadTasks);
if (progressUpdateIntervalMillis != C.TIME_UNSET) {
handler.postDelayed(this, progressUpdateIntervalMillis);
}
} else {
stop();
}
}
public void stop() {
stopped = true;
handler.removeCallbacks(this);
}
public void start() {
if (stopped) {
stopped = false;
if (progressUpdateIntervalMillis != C.TIME_UNSET) {
handler.post(this);
}
}
}
}
private static final class RequirementsListener implements RequirementsWatcher.Listener {
private final Context context;
private final Class<? extends DownloadService> serviceClass;
private final Scheduler scheduler;
private RequirementsListener(
Context context, Class<? extends DownloadService> serviceClass, Scheduler scheduler) {
this.context = context;
this.serviceClass = serviceClass;
this.scheduler = scheduler;
}
@Override
public void requirementsMet(RequirementsWatcher requirementsWatcher) {
startServiceWithAction(DownloadService.ACTION_START);
if (scheduler != null) {
scheduler.cancel();
}
}
@Override
public void requirementsNotMet(RequirementsWatcher requirementsWatcher) {
startServiceWithAction(DownloadService.ACTION_STOP);
if (scheduler != null) {
if (!scheduler.schedule()) {
Log.e(TAG, "Scheduling downloads failed.");
}
}
}
private void startServiceWithAction(String action) {
Intent intent = new Intent(context, serviceClass).setAction(action);
if (Util.SDK_INT >= 26) {
context.startForegroundService(intent);
} else {
context.startService(intent);
}
}
}
}

View File

@ -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;
}
}

View File

@ -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 <K> The type of the representation key object.
*/
public abstract class SegmentDownloadAction<K> extends DownloadAction {
/**
* Base class for {@link SegmentDownloadAction} {@link Deserializer}s.
*
* @param <K> The type of the representation key object.
*/
protected abstract static class SegmentDownloadActionDeserializer<K> 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;
}
}

View File

@ -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)}.
*
* <p>To use {@link PlatformScheduler} application needs to have RECEIVE_BOOT_COMPLETED permission
* and you need to define PlatformSchedulerService in your manifest:
*
* <pre>{@literal
* <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
*
* <service android:name="com.google.android.exoplayer2.util.scheduler.PlatformScheduler$PlatformSchedulerService"
* android:permission="android.permission.BIND_JOB_SERVICE"
* android:exported="true"/>
* }</pre>
*
* The service to be scheduled must be defined in the manifest with an intent-filter:
*
* <pre>{@literal
* <service android:name="MyJobService"
* android:exported="false">
* <intent-filter>
* <action android:name="MyJobService.action"/>
* <category android:name="android.intent.category.DEFAULT"/>
* </intent-filter>
* </service>
* }</pre>
*/
@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;
}
}
}

View File

@ -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.
*
* <p>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" : "")
+ '}';
}
}

View File

@ -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);
}
}
}

View File

@ -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();
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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);
}
});
}
}

View File

@ -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);
}
}
}

View File

@ -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<RepresentationKey> {
public static final Deserializer DESERIALIZER =
new SegmentDownloadActionDeserializer<RepresentationKey>() {
@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);
}
}

View File

@ -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<String> {
public static final Deserializer DESERIALIZER = new SegmentDownloadActionDeserializer<String>() {
@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);
}
}

View File

@ -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<TrackKey> {
public static final Deserializer DESERIALIZER =
new SegmentDownloadActionDeserializer<TrackKey>() {
@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);
}
}

View File

@ -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<Throwable> 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;
}
}