diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java b/library/core/src/androidTest/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java new file mode 100644 index 0000000000..cfc5e1593e --- /dev/null +++ b/library/core/src/androidTest/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java @@ -0,0 +1,352 @@ +/* + * 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.os.ConditionVariable; +import android.support.annotation.Nullable; +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.offline.DownloadManager.DownloadListener; +import com.google.android.exoplayer2.offline.DownloadManager.DownloadTask; +import com.google.android.exoplayer2.offline.DownloadManager.DownloadTask.State; +import com.google.android.exoplayer2.upstream.DummyDataSource; +import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.util.ClosedSource; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.concurrent.Executors; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +/** Tests {@link DownloadManager}. */ +@ClosedSource(reason = "Not ready yet") +public class DownloadManagerTest extends InstrumentationTestCase { + + /* Used to check if condition becomes true in this time interval. */ + private static final int ASSERT_TRUE_TIMEOUT = 1000; + /* Used to check if condition stays false for this time interval. */ + private static final int ASSERT_FALSE_TIME = 1000; + + private DownloadManager downloadManager; + private ConditionVariable downloadFinishedCondition; + private Throwable downloadError; + + @Override + public void setUp() throws Exception { + super.setUp(); + setUpMockito(this); + + downloadManager = new DownloadManager( + new DownloaderConstructorHelper(Mockito.mock(Cache.class), DummyDataSource.FACTORY), + Executors.newCachedThreadPool()); + + downloadFinishedCondition = new ConditionVariable(); + downloadManager.setListener(new TestDownloadListener()); + } + + class TestDownloadListener implements DownloadListener { + @Override + public void onStateChange(DownloadManager downloadManager, DownloadTask downloadTask, int state, + Throwable error) { + if (state == DownloadTask.STATE_ERROR && downloadError == null) { + downloadError = error; + } + ((FakeDownloadAction) downloadTask.getDownloadAction()).onStateChange(); + } + + @Override + public void onTasksFinished(DownloadManager downloadManager) { + downloadFinishedCondition.open(); + } + } + + public void testDownloadActionRuns() throws Throwable { + doTestActionRuns(createDownloadAction("media 1")); + } + + public void testRemoveActionRuns() throws Throwable { + doTestActionRuns(createRemoveAction("media 1")); + } + + 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.finishAndAssertFinished(); + removeAction1.assertDoesNotStart(); + // downloadAction3 is post to DownloadManager but it waits for removeAction1 to finish. + downloadAction3.post().assertDoesNotStart(); + + // When downloadAction1 finishes, removeAction1 starts. + downloadAction1.finishAndAssertFinished(); + removeAction1.assertStarted(); + // downloadAction3 still waits removeAction1 + downloadAction3.assertDoesNotStart(); + + // removeAction2 is posted. removeAction1 and downloadAction3 is canceled so removeAction2 + // starts immediately. + removeAction2.post().assertStarted().finishAndAssertFinished(); + blockUntilTasksCompleteAndThrowAnyDownloadError(); + } + + public void testMultipleWaitingAction() 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(); + + removeAction1.finishAndAssertFinished(); + removeAction2.assertTaskState(DownloadTask.STATE_CANCELLED); + removeAction3.assertStarted().finishAndAssertFinished(); + + blockUntilTasksCompleteAndThrowAnyDownloadError(); + } + + private void doTestActionRuns(FakeDownloadAction action) throws Throwable { + action.post().assertStarted().finishAndAssertFinished(); + blockUntilTasksCompleteAndThrowAnyDownloadError(); + } + + private void doTestActionsRunSequentially(FakeDownloadAction action1, + FakeDownloadAction action2) throws Throwable { + action1.ignoreInterrupts().post().assertStarted(); + action2.post().assertDoesNotStart(); + + action1.finishAndAssertFinished(); + action2.assertStarted(); + + action2.finishAndAssertFinished(); + blockUntilTasksCompleteAndThrowAnyDownloadError(); + } + + private void doTestActionsRunInParallel(FakeDownloadAction action1, + FakeDownloadAction action2) throws Throwable { + action1.post().assertStarted(); + action2.post().assertStarted(); + action1.finishAndAssertFinished(); + action2.finishAndAssertFinished(); + blockUntilTasksCompleteAndThrowAnyDownloadError(); + } + + private FakeDownloadAction createDownloadAction(String mediaId) { + return new FakeDownloadAction(downloadManager, mediaId, false); + } + + private FakeDownloadAction createRemoveAction(String mediaId) { + return new FakeDownloadAction(downloadManager, mediaId, true); + } + + private void blockUntilTasksCompleteAndThrowAnyDownloadError() throws Throwable { + assertTrue(downloadFinishedCondition.block(ASSERT_TRUE_TIMEOUT)); + downloadFinishedCondition.close(); + if (downloadError != null) { + throw downloadError; + } + } + + /** + * Sets up Mockito for an instrumentation test. + */ + private static void setUpMockito(InstrumentationTestCase instrumentationTestCase) { + // Workaround for https://code.google.com/p/dexmaker/issues/detail?id=2. + System.setProperty("dexmaker.dexcache", + instrumentationTestCase.getInstrumentation().getTargetContext().getCacheDir().getPath()); + MockitoAnnotations.initMocks(instrumentationTestCase); + } + + private static class FakeDownloadAction extends DownloadAction { + + private final DownloadManager downloadManager; + private final String mediaId; + private final boolean removeAction; + private final FakeDownloader downloader; + private final ConditionVariable stateChanged; + private DownloadTask downloadTask; + + private FakeDownloadAction(DownloadManager downloadManager, String mediaId, + boolean removeAction) { + this.downloadManager = downloadManager; + this.mediaId = mediaId; + this.removeAction = removeAction; + this.downloader = new FakeDownloader(removeAction); + this.stateChanged = new ConditionVariable(); + } + + @Override + protected String getType() { + return "FakeDownloadAction"; + } + + @Override + protected void writeToStream(DataOutputStream output) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + protected 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 FakeDownloadAction post() throws DownloadException { + downloadTask = downloadManager.handleAction(this); + return this; + } + + private FakeDownloadAction assertDoesNotStart() { + assertFalse(downloader.started.block(ASSERT_FALSE_TIME)); + return this; + } + + private FakeDownloadAction assertStarted() { + assertTrue(downloader.started.block(ASSERT_TRUE_TIMEOUT)); + assertTaskState(DownloadTask.STATE_STARTED); + return this; + } + + private FakeDownloadAction assertTaskState(@State int state) { + assertTrue(stateChanged.block(ASSERT_TRUE_TIMEOUT)); + stateChanged.close(); + assertEquals(state, downloadTask.getState()); + return this; + } + + private FakeDownloadAction finishAndAssertFinished() { + downloader.finish.open(); + assertTaskState(DownloadTask.STATE_ENDED); + return this; + } + + private FakeDownloadAction ignoreInterrupts() { + downloader.ignoreInterrupts = true; + return this; + } + + private void onStateChange() { + stateChanged.open(); + } + } + + private static class FakeDownloader implements Downloader { + private final ConditionVariable started; + private final com.google.android.exoplayer2.util.ConditionVariable finish; + private final boolean removeAction; + private boolean ignoreInterrupts; + + private FakeDownloader(boolean removeAction) { + this.removeAction = removeAction; + this.started = new ConditionVariable(); + this.finish = 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 { + assertFalse(removeAction); + started.open(); + blockUntilFinish(); + } + + @Override + public void remove() throws InterruptedException { + assertTrue(removeAction); + started.open(); + blockUntilFinish(); + } + + private void blockUntilFinish() throws InterruptedException { + while (true){ + try { + finish.block(); + break; + } catch (InterruptedException e) { + if (!ignoreInterrupts) { + throw e; + } + } + } + } + + @Override + public long getDownloadedBytes() { + return 0; + } + + @Override + public float getDownloadPercentage() { + return 0; + } + } + +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadException.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadException.java index 730ce2d3e8..983727c14d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadException.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadException.java @@ -25,4 +25,9 @@ public final class DownloadException extends IOException { super(message); } + /** @param cause The cause for the exception. */ + public DownloadException(Throwable cause) { + super(cause); + } + } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java index b6c6914c50..550cdfbb47 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java @@ -16,8 +16,8 @@ package com.google.android.exoplayer2.util; /** - * A condition variable whose {@link #open()} and {@link #close()} methods return whether they - * resulted in a change of state. + * An interruptible condition variable whose {@link #open()} and {@link #close()} methods return + * whether they resulted in a change of state. */ public final class ConditionVariable { @@ -59,4 +59,21 @@ public final class ConditionVariable { } } + /** + * Blocks until the condition is opened or until timeout milliseconds have passed. + * + * @param timeout The maximum time to wait in milliseconds. + * @return true If the condition was opened, false if the call returns because of the timeout. + * @throws InterruptedException If the thread is interrupted. + */ + public synchronized boolean block(int timeout) throws InterruptedException { + long now = System.currentTimeMillis(); + long end = now + timeout; + while (!isOpen && now < end) { + wait(end - now); + now = System.currentTimeMillis(); + } + return isOpen; + } + }