diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/library/core/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java new file mode 100644 index 0000000000..110819d2dc --- /dev/null +++ b/library/core/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java @@ -0,0 +1,297 @@ +/* + * 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.upstream.cache; + +import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCacheEmpty; +import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCachedData; + +import android.net.Uri; +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.testutil.FakeDataSource.FakeDataSet; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.cache.CacheUtil.CachingCounters; +import com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.File; +import org.mockito.Answers; +import org.mockito.Mock; + +/** + * Tests {@link CacheUtil}. + */ +public class CacheUtilTest extends InstrumentationTestCase { + + /** + * Abstract fake Cache implementation used by the test. This class must be public so Mockito can + * create a proxy for it. + */ + public abstract static class AbstractFakeCache implements Cache { + // This array is set to alternating length of cached and not cached regions in tests: + // spansAndGaps = {, , + // , , ... } + // Ideally it should end with a cached region but it shouldn't matter for any code. + private int[] spansAndGaps; + private long contentLength; + + private void init() { + spansAndGaps = new int[]{}; + contentLength = C.LENGTH_UNSET; + } + + @Override + public long getCachedBytes(String key, long position, long length) { + for (int i = 0; i < spansAndGaps.length; i++) { + int spanOrGap = spansAndGaps[i]; + if (position < spanOrGap) { + long left = Math.min(spanOrGap - position, length); + return (i & 1) == 1 ? -left : left; + } + position -= spanOrGap; + } + return -length; + } + + @Override + public long getContentLength(String key) { + return contentLength; + } + } + + @Mock(answer = Answers.CALLS_REAL_METHODS) private AbstractFakeCache mockCache; + private File tempFolder; + private SimpleCache cache; + + @Override + public void setUp() throws Exception { + super.setUp(); + TestUtil.setUpMockito(this); + mockCache.init(); + tempFolder = Util.createTempDirectory(getInstrumentation().getContext(), "ExoPlayerTest"); + cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + } + + @Override + public void tearDown() throws Exception { + Util.recursiveDelete(tempFolder); + super.tearDown(); + } + + public void testGenerateKey() throws Exception { + assertNotNull(CacheUtil.generateKey(Uri.EMPTY)); + + Uri testUri = Uri.parse("test"); + String key = CacheUtil.generateKey(testUri); + assertNotNull(key); + + // Should generate the same key for the same input + assertEquals(key, CacheUtil.generateKey(testUri)); + + // Should generate different key for different input + assertFalse(key.equals(CacheUtil.generateKey(Uri.parse("test2")))); + } + + public void testGetKey() throws Exception { + Uri testUri = Uri.parse("test"); + String key = "key"; + // If DataSpec.key is present, returns it + assertEquals(key, CacheUtil.getKey(new DataSpec(testUri, 0, C.LENGTH_UNSET, key))); + // If not generates a new one using DataSpec.uri + assertEquals(CacheUtil.generateKey(testUri), + CacheUtil.getKey(new DataSpec(testUri, 0, C.LENGTH_UNSET, null))); + } + + public void testGetCachedCachingCounters() throws Exception { + DataSpec dataSpec = new DataSpec(Uri.parse("test")); + CachingCounters counters = CacheUtil.getCached(dataSpec, mockCache, null); + // getCached should create a CachingCounters and return it + assertNotNull(counters); + + CachingCounters newCounters = CacheUtil.getCached(dataSpec, mockCache, counters); + // getCached should set and return given CachingCounters + assertEquals(counters, newCounters); + } + + public void testGetCachedNoData() throws Exception { + CachingCounters counters = + CacheUtil.getCached(new DataSpec(Uri.parse("test")), mockCache, null); + + assertCounters(counters, 0, 0, C.LENGTH_UNSET); + } + + public void testGetCachedDataUnknownLength() throws Exception { + // Mock there is 100 bytes cached at the beginning + mockCache.spansAndGaps = new int[]{100}; + CachingCounters counters = + CacheUtil.getCached(new DataSpec(Uri.parse("test")), mockCache, null); + + assertCounters(counters, 100, 0, C.LENGTH_UNSET); + } + + public void testGetCachedNoDataKnownLength() throws Exception { + mockCache.contentLength = 1000; + CachingCounters counters = + CacheUtil.getCached(new DataSpec(Uri.parse("test")), mockCache, null); + + assertCounters(counters, 0, 0, 1000); + } + + public void testGetCached() throws Exception { + mockCache.contentLength = 1000; + mockCache.spansAndGaps = new int[]{100, 100, 200}; + CachingCounters counters = + CacheUtil.getCached(new DataSpec(Uri.parse("test")), mockCache, null); + + assertCounters(counters, 300, 0, 1000); + } + + public void testCache() throws Exception { + FakeDataSet fakeDataSet = new FakeDataSet().setRandomData("test_data", 100); + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + + CachingCounters counters = + CacheUtil.cache(new DataSpec(Uri.parse("test_data")), cache, dataSource, null); + + assertCounters(counters, 0, 100, 100); + assertCachedData(cache, fakeDataSet); + } + + public void testCacheSetOffsetAndLength() throws Exception { + FakeDataSet fakeDataSet = new FakeDataSet().setRandomData("test_data", 100); + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + + Uri testUri = Uri.parse("test_data"); + DataSpec dataSpec = new DataSpec(testUri, 10, 20, null); + CachingCounters counters = CacheUtil.cache(dataSpec, cache, dataSource, null); + + assertCounters(counters, 0, 20, 20); + + CacheUtil.cache(new DataSpec(testUri), cache, dataSource, counters); + + assertCounters(counters, 20, 80, 100); + assertCachedData(cache, fakeDataSet); + } + + public void testCacheUnknownLength() throws Exception { + FakeDataSet fakeDataSet = new FakeDataSet().newData("test_data") + .setSimulateUnknownLength(true) + .appendReadData(TestUtil.buildTestData(100)).endData(); + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + + DataSpec dataSpec = new DataSpec(Uri.parse("test_data")); + CachingCounters counters = CacheUtil.cache(dataSpec, cache, dataSource, null); + + assertCounters(counters, 0, 100, 100); + assertCachedData(cache, fakeDataSet); + } + + public void testCacheUnknownLengthPartialCaching() throws Exception { + FakeDataSet fakeDataSet = new FakeDataSet().newData("test_data") + .setSimulateUnknownLength(true) + .appendReadData(TestUtil.buildTestData(100)).endData(); + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + + Uri testUri = Uri.parse("test_data"); + DataSpec dataSpec = new DataSpec(testUri, 10, 20, null); + CachingCounters counters = CacheUtil.cache(dataSpec, cache, dataSource, null); + + assertCounters(counters, 0, 20, 20); + + CacheUtil.cache(new DataSpec(testUri), cache, dataSource, counters); + + assertCounters(counters, 20, 80, 100); + assertCachedData(cache, fakeDataSet); + } + + public void testCacheLengthExceedsActualDataLength() throws Exception { + FakeDataSet fakeDataSet = new FakeDataSet().setRandomData("test_data", 100); + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + + Uri testUri = Uri.parse("test_data"); + DataSpec dataSpec = new DataSpec(testUri, 0, 1000, null); + CachingCounters counters = CacheUtil.cache(dataSpec, cache, dataSource, null); + + assertCounters(counters, 0, 100, 1000); + assertCachedData(cache, fakeDataSet); + } + + public void testCacheThrowEOFException() throws Exception { + FakeDataSet fakeDataSet = new FakeDataSet().setRandomData("test_data", 100); + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + + Uri testUri = Uri.parse("test_data"); + DataSpec dataSpec = new DataSpec(testUri, 0, 1000, null); + + try { + CacheUtil.cache(dataSpec, cache, new CacheDataSource(cache, dataSource), + new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES], null, 0, null, + /*enableEOFException*/ true); + fail(); + } catch (EOFException e) { + // Do nothing. + } + } + + public void testCachePolling() throws Exception { + final CachingCounters counters = new CachingCounters(); + FakeDataSet fakeDataSet = new FakeDataSet().newData("test_data") + .appendReadData(TestUtil.buildTestData(100)) + .appendReadAction(new Runnable() { + @Override + public void run() { + assertCounters(counters, 0, 100, 300); + } + }) + .appendReadData(TestUtil.buildTestData(100)) + .appendReadAction(new Runnable() { + @Override + public void run() { + assertCounters(counters, 0, 200, 300); + } + }) + .appendReadData(TestUtil.buildTestData(100)).endData(); + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + + CacheUtil.cache(new DataSpec(Uri.parse("test_data")), cache, dataSource, counters); + + assertCounters(counters, 0, 300, 300); + assertCachedData(cache, fakeDataSet); + } + + public void testRemove() throws Exception { + FakeDataSet fakeDataSet = new FakeDataSet().setRandomData("test_data", 100); + FakeDataSource dataSource = new FakeDataSource(fakeDataSet); + + Uri uri = Uri.parse("test_data"); + CacheUtil.cache(new DataSpec(uri), cache, + // set maxCacheFileSize to 10 to make sure there are multiple spans + new CacheDataSource(cache, dataSource, 0, 10), + new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES], null, 0, null, true); + CacheUtil.remove(cache, CacheUtil.generateKey(uri)); + + assertCacheEmpty(cache); + } + + private static void assertCounters(CachingCounters counters, int alreadyCachedBytes, + int downloadedBytes, int totalBytes) { + assertEquals(alreadyCachedBytes, counters.alreadyCachedBytes); + assertEquals(downloadedBytes, counters.downloadedBytes); + assertEquals(totalBytes, counters.totalBytes); + } + +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java index bb1f88e5ea..22a7635564 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java @@ -22,28 +22,32 @@ import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; import com.google.android.exoplayer2.util.Util; +import java.io.EOFException; import java.io.IOException; import java.util.NavigableSet; /** * Caching related utility methods. */ +@SuppressWarnings({"NonAtomicVolatileUpdate", "NonAtomicOperationOnVolatileField"}) public final class CacheUtil { /** Holds the counters used during caching. */ public static class CachingCounters { /** Total number of already cached bytes. */ - public long alreadyCachedBytes; + public volatile long alreadyCachedBytes; + /** Total number of downloaded bytes. */ + public volatile long downloadedBytes; /** - * Total number of downloaded bytes. - * - *

{@link #getCached(DataSpec, Cache, CachingCounters)} sets it to the count of the missing - * bytes or to {@link C#LENGTH_UNSET} if {@code dataSpec} is unbounded and content length isn't - * available in the {@code cache}. + * Total number of bytes. This is the sum of already cached, downloaded and missing bytes. If + * the length of the missing bytes is unknown this is set to {@link C#LENGTH_UNSET}. */ - public long downloadedBytes; + public volatile long totalBytes = C.LENGTH_UNSET; } + /** Default buffer size to be used while caching. */ + public static final int DEFAULT_BUFFER_SIZE_BYTES = 128 * 1024; + /** * Generates a cache key out of the given {@link Uri}. * @@ -76,14 +80,34 @@ public final class CacheUtil { public static CachingCounters getCached(DataSpec dataSpec, Cache cache, CachingCounters counters) { try { - return internalCache(dataSpec, cache, null, null, null, 0, counters); + return internalCache(dataSpec, cache, null, null, null, 0, counters, false); } catch (IOException | InterruptedException e) { throw new IllegalStateException(e); } } /** - * Caches the data defined by {@code dataSpec} while skipping already cached data. + * Caches the data defined by {@code dataSpec} while skipping already cached data. Caching stops + * early if end of input is reached. + * + * @param dataSpec Defines the data to be cached. + * @param cache A {@link Cache} to store the data. + * @param upstream A {@link DataSource} for reading data not in the cache. + * @param counters The counters to be set during caching. If not null its values reset to + * zero before using. If null a new {@link CachingCounters} is created and used. + * @return The used {@link CachingCounters} instance. + * @throws IOException If an error occurs reading from the source. + * @throws InterruptedException If the thread was interrupted. + */ + public static CachingCounters cache(DataSpec dataSpec, Cache cache, + DataSource upstream, CachingCounters counters) throws IOException, InterruptedException { + return cache(dataSpec, cache, new CacheDataSource(cache, upstream), + new byte[DEFAULT_BUFFER_SIZE_BYTES], null, 0, counters, false); + } + + /** + * Caches the data defined by {@code dataSpec} while skipping already cached data. Caching stops + * early if end of input is reached and {@code enableEOFException} is false. * * @param dataSpec Defines the data to be cached. * @param cache A {@link Cache} to store the data. @@ -94,17 +118,20 @@ public final class CacheUtil { * @param priority The priority of this task. Used with {@code priorityTaskManager}. * @param counters The counters to be set during caching. If not null its values reset to * zero before using. If null a new {@link CachingCounters} is created and used. + * @param enableEOFException Whether to throw an {@link EOFException} if end of input has been + * reached unexpectedly. * @return The used {@link CachingCounters} instance. * @throws IOException If an error occurs reading from the source. * @throws InterruptedException If the thread was interrupted. */ public static CachingCounters cache(DataSpec dataSpec, Cache cache, CacheDataSource dataSource, byte[] buffer, PriorityTaskManager priorityTaskManager, int priority, - CachingCounters counters) throws IOException, InterruptedException { + CachingCounters counters, boolean enableEOFException) + throws IOException, InterruptedException { Assertions.checkNotNull(dataSource); Assertions.checkNotNull(buffer); return internalCache(dataSpec, cache, dataSource, buffer, priorityTaskManager, priority, - counters); + counters, enableEOFException); } /** @@ -121,21 +148,21 @@ public final class CacheUtil { * @param priority The priority of this task. Used with {@code priorityTaskManager}. * @param counters The counters to be set during caching. If not null its values reset to * zero before using. If null a new {@link CachingCounters} is created and used. + * @param enableEOFException Whether to throw an {@link EOFException} if end of input has been + * reached unexpectedly. * @return The used {@link CachingCounters} instance. * @throws IOException If not dry run and an error occurs reading from the source. * @throws InterruptedException If not dry run and the thread was interrupted. */ private static CachingCounters internalCache(DataSpec dataSpec, Cache cache, CacheDataSource dataSource, byte[] buffer, PriorityTaskManager priorityTaskManager, - int priority, CachingCounters counters) throws IOException, InterruptedException { - long start = dataSpec.position; + int priority, CachingCounters counters, boolean enableEOFException) + throws IOException, InterruptedException { + long start = dataSpec.absoluteStreamPosition; long left = dataSpec.length; String key = getKey(dataSpec); if (left == C.LENGTH_UNSET) { left = cache.getContentLength(key); - if (left == C.LENGTH_UNSET) { - left = Long.MAX_VALUE; - } } if (counters == null) { counters = new CachingCounters(); @@ -143,8 +170,11 @@ public final class CacheUtil { counters.alreadyCachedBytes = 0; counters.downloadedBytes = 0; } - while (left > 0) { - long blockLength = cache.getCachedBytes(key, start, left); + counters.totalBytes = left; + + while (left != 0) { + long blockLength = cache.getCachedBytes(key, start, + left != C.LENGTH_UNSET ? left : Long.MAX_VALUE); // Skip already cached data if (blockLength > 0) { counters.alreadyCachedBytes += blockLength; @@ -152,24 +182,21 @@ public final class CacheUtil { // There is a hole in the cache which is at least "-blockLength" long. blockLength = -blockLength; if (dataSource != null && buffer != null) { - DataSpec subDataSpec = new DataSpec(dataSpec.uri, start, - blockLength == Long.MAX_VALUE ? C.LENGTH_UNSET : blockLength, key); - long read = readAndDiscard(subDataSpec, dataSource, buffer, priorityTaskManager, - priority); - counters.downloadedBytes += read; + long read = readAndDiscard(dataSpec, start, blockLength, dataSource, buffer, + priorityTaskManager, priority, counters); if (read < blockLength) { - // Reached end of data. + // Reached to the end of the data. + if (enableEOFException && left != C.LENGTH_UNSET) { + throw new EOFException(); + } break; } } else if (blockLength == Long.MAX_VALUE) { - counters.downloadedBytes = C.LENGTH_UNSET; break; - } else { - counters.downloadedBytes += blockLength; } } start += blockLength; - if (left != Long.MAX_VALUE) { + if (left != C.LENGTH_UNSET) { left -= blockLength; } } @@ -179,36 +206,56 @@ public final class CacheUtil { /** * Reads and discards all data specified by the {@code dataSpec}. * - * @param dataSpec Defines the data to be read. + * @param dataSpec Defines the data to be read. {@code absoluteStreamPosition} and {@code length} + * fields are overwritten by the following parameters. + * @param absoluteStreamPosition The absolute position of the data to be read. + * @param length Length of the data to be read, or {@link C#LENGTH_UNSET} if it is unknown. * @param dataSource The {@link DataSource} to read the data from. * @param buffer The buffer to be used while downloading. * @param priorityTaskManager If not null it's used to check whether it is allowed to proceed with * caching. * @param priority The priority of this task. + * @param counters The counters to be set during reading. * @return Number of read bytes, or 0 if no data is available because the end of the opened range - * has been reached. + * has been reached. */ - private static long readAndDiscard(DataSpec dataSpec, DataSource dataSource, byte[] buffer, - PriorityTaskManager priorityTaskManager, int priority) - throws IOException, InterruptedException { + private static long readAndDiscard(DataSpec dataSpec, long absoluteStreamPosition, long length, + DataSource dataSource, byte[] buffer, PriorityTaskManager priorityTaskManager, int priority, + CachingCounters counters) throws IOException, InterruptedException { while (true) { if (priorityTaskManager != null) { // Wait for any other thread with higher priority to finish its job. priorityTaskManager.proceed(priority); } try { - dataSource.open(dataSpec); + // Create a new dataSpec setting length to C.LENGTH_UNSET to prevent getting an error in + // case the given length exceeds the end of input. + dataSpec = new DataSpec(dataSpec.uri, dataSpec.postBody, absoluteStreamPosition, + dataSpec.position + absoluteStreamPosition - dataSpec.absoluteStreamPosition, + C.LENGTH_UNSET, dataSpec.key, + dataSpec.flags | DataSpec.FLAG_ALLOW_CACHING_UNKNOWN_LENGTH); + long resolvedLength = dataSource.open(dataSpec); + if (counters.totalBytes == C.LENGTH_UNSET && resolvedLength != C.LENGTH_UNSET) { + counters.totalBytes = dataSpec.absoluteStreamPosition + resolvedLength; + } long totalRead = 0; - while (true) { + while (totalRead != length) { if (Thread.interrupted()) { throw new InterruptedException(); } - int read = dataSource.read(buffer, 0, buffer.length); + int read = dataSource.read(buffer, 0, + length != C.LENGTH_UNSET ? (int) Math.min(buffer.length, length - totalRead) + : buffer.length); if (read == C.RESULT_END_OF_INPUT) { - return totalRead; + if (counters.totalBytes == C.LENGTH_UNSET) { + counters.totalBytes = dataSpec.absoluteStreamPosition + totalRead; + } + break; } totalRead += read; + counters.downloadedBytes += read; } + return totalRead; } catch (PriorityTaskManager.PriorityTooLowException exception) { // catch and try again } finally {