From 55ca323cee1906e499e65acfdbb32a027f1e1376 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Fri, 20 Jan 2017 20:50:02 +0000 Subject: [PATCH] Add upstream.crypto package (and friends). --- .../upstream/cache/CacheDataSourceTest.java | 4 +- .../upstream/cache/CacheDataSourceTest2.java | 181 ++++++++++++++++ .../cache/CachedRegionTrackerTest.java | 126 +++++++++++ .../crypto/AesFlushingCipherTest.java | 186 ++++++++++++++++ .../upstream/cache/CachedRegionTracker.java | 205 ++++++++++++++++++ .../upstream/crypto/AesCipherDataSink.java | 95 ++++++++ .../upstream/crypto/AesCipherDataSource.java | 73 +++++++ .../upstream/crypto/AesFlushingCipher.java | 120 ++++++++++ .../upstream/crypto/CryptoUtil.java | 44 ++++ 9 files changed, 1033 insertions(+), 1 deletion(-) create mode 100644 library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java create mode 100644 library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java create mode 100644 library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java create mode 100644 library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 18e39be93c..c9eaa33204 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -27,7 +27,9 @@ import java.io.File; import java.io.IOException; import java.util.Arrays; -/** Unit tests for {@link CacheDataSource}. */ +/** + * Unit tests for {@link CacheDataSource}. + */ public class CacheDataSourceTest extends InstrumentationTestCase { private static final byte[] TEST_DATA = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java new file mode 100644 index 0000000000..70a7d797c1 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java @@ -0,0 +1,181 @@ +/* + * 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 android.content.Context; +import android.net.Uri; +import android.test.AndroidTestCase; +import android.test.MoreAsserts; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DataSink; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; +import com.google.android.exoplayer2.upstream.crypto.AesCipherDataSink; +import com.google.android.exoplayer2.upstream.crypto.AesCipherDataSource; +import com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.Random; + +/** + * Additional tests for {@link CacheDataSource}. + */ +public class CacheDataSourceTest2 extends AndroidTestCase { + + private static final String EXO_CACHE_DIR = "exo"; + private static final int EXO_CACHE_MAX_FILESIZE = 128; + + private static final Uri URI = Uri.parse("http://test.com/content"); + private static final String KEY = "key"; + private static final byte[] DATA = TestUtil.buildTestData(8 * EXO_CACHE_MAX_FILESIZE + 1); + + // A DataSpec that covers the full file. + private static final DataSpec FULL = new DataSpec(URI, 0, DATA.length, KEY); + + private static final int OFFSET_ON_BOUNDARY = EXO_CACHE_MAX_FILESIZE; + // A DataSpec that starts at 0 and extends to a cache file boundary. + private static final DataSpec END_ON_BOUNDARY = new DataSpec(URI, 0, OFFSET_ON_BOUNDARY, KEY); + // A DataSpec that starts on the same boundary and extends to the end of the file. + private static final DataSpec START_ON_BOUNDARY = new DataSpec(URI, OFFSET_ON_BOUNDARY, + DATA.length - OFFSET_ON_BOUNDARY, KEY); + + private static final int OFFSET_OFF_BOUNDARY = EXO_CACHE_MAX_FILESIZE * 2 + 1; + // A DataSpec that starts at 0 and extends to just past a cache file boundary. + private static final DataSpec END_OFF_BOUNDARY = new DataSpec(URI, 0, OFFSET_OFF_BOUNDARY, KEY); + // A DataSpec that starts on the same boundary and extends to the end of the file. + private static final DataSpec START_OFF_BOUNDARY = new DataSpec(URI, OFFSET_OFF_BOUNDARY, + DATA.length - OFFSET_OFF_BOUNDARY, KEY); + + public void testWithoutEncryption() throws IOException { + testReads(false); + } + + public void testWithEncryption() throws IOException { + testReads(true); + } + + private void testReads(boolean useEncryption) throws IOException { + FakeDataSource upstreamSource = buildFakeUpstreamSource(); + CacheDataSource source = buildCacheDataSource(getContext(), upstreamSource, useEncryption); + // First read, should arrive from upstream. + testRead(END_ON_BOUNDARY, source); + assertSingleOpen(upstreamSource, 0, OFFSET_ON_BOUNDARY); + // Second read, should arrive from upstream. + testRead(START_OFF_BOUNDARY, source); + assertSingleOpen(upstreamSource, OFFSET_OFF_BOUNDARY, DATA.length); + // Second read, should arrive part from cache and part from upstream. + testRead(END_OFF_BOUNDARY, source); + assertSingleOpen(upstreamSource, OFFSET_ON_BOUNDARY, OFFSET_OFF_BOUNDARY); + // Third read, should arrive from cache. + testRead(FULL, source); + assertNoOpen(upstreamSource); + // Various reads, should all arrive from cache. + testRead(FULL, source); + assertNoOpen(upstreamSource); + testRead(START_ON_BOUNDARY, source); + assertNoOpen(upstreamSource); + testRead(END_ON_BOUNDARY, source); + assertNoOpen(upstreamSource); + testRead(START_OFF_BOUNDARY, source); + assertNoOpen(upstreamSource); + testRead(END_OFF_BOUNDARY, source); + assertNoOpen(upstreamSource); + } + + private void testRead(DataSpec dataSpec, CacheDataSource source) throws IOException { + byte[] scratch = new byte[4096]; + Random random = new Random(0); + source.open(dataSpec); + int position = (int) dataSpec.absoluteStreamPosition; + int bytesRead = 0; + while (bytesRead != C.RESULT_END_OF_INPUT) { + int maxBytesToRead = random.nextInt(scratch.length) + 1; + bytesRead = source.read(scratch, 0, maxBytesToRead); + if (bytesRead != C.RESULT_END_OF_INPUT) { + MoreAsserts.assertEquals(Arrays.copyOfRange(DATA, position, position + bytesRead), + Arrays.copyOf(scratch, bytesRead)); + position += bytesRead; + } + } + source.close(); + } + + /** + * Asserts that a single {@link DataSource#open(DataSpec)} call has been made to the upstream + * source, with the specified start (inclusive) and end (exclusive) positions. + */ + private void assertSingleOpen(FakeDataSource upstreamSource, int start, int end) { + DataSpec[] openedDataSpecs = upstreamSource.getAndClearOpenedDataSpecs(); + assertEquals(1, openedDataSpecs.length); + assertEquals(start, openedDataSpecs[0].position); + assertEquals(start, openedDataSpecs[0].absoluteStreamPosition); + assertEquals(end - start, openedDataSpecs[0].length); + } + + /** + * Asserts that the upstream source was not opened. + */ + private void assertNoOpen(FakeDataSource upstreamSource) { + DataSpec[] openedDataSpecs = upstreamSource.getAndClearOpenedDataSpecs(); + assertEquals(0, openedDataSpecs.length); + } + + private static FakeDataSource buildFakeUpstreamSource() { + return new FakeDataSource.Builder().appendReadData(DATA).build(); + } + + private static CacheDataSource buildCacheDataSource(Context context, DataSource upstreamSource, + boolean useAesEncryption) throws CacheException { + File cacheDir = context.getExternalCacheDir(); + Cache cache = new SimpleCache(new File(cacheDir, EXO_CACHE_DIR), new NoOpCacheEvictor()); + emptyCache(cache); + + // Source and cipher + final String secretKey = "testKey:12345678"; + DataSource file = new FileDataSource(); + DataSource cacheReadDataSource = useAesEncryption + ? new AesCipherDataSource(Util.getUtf8Bytes(secretKey), file) : file; + + // Sink and cipher + CacheDataSink cacheSink = new CacheDataSink(cache, EXO_CACHE_MAX_FILESIZE); + byte[] scratch = new byte[3897]; + DataSink cacheWriteDataSink = useAesEncryption + ? new AesCipherDataSink(Util.getUtf8Bytes(secretKey), cacheSink, scratch) : cacheSink; + + return new CacheDataSource(cache, + upstreamSource, + cacheReadDataSource, + cacheWriteDataSink, + CacheDataSource.FLAG_BLOCK_ON_CACHE, + null); // eventListener + } + + private static void emptyCache(Cache cache) throws CacheException { + for (String key : cache.getKeys()) { + for (CacheSpan span : cache.getCachedSpans(key)) { + cache.removeSpan(span); + } + } + // Sanity check that the cache really is empty now. + assertTrue(cache.getKeys().isEmpty()); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java new file mode 100644 index 0000000000..799027f4b5 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTrackerTest.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2016 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 android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.extractor.ChunkIndex; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.File; +import java.io.IOException; +import org.mockito.Mock; + +/** + * Tests for {@link CachedRegionTracker}. + */ +public final class CachedRegionTrackerTest extends InstrumentationTestCase { + + private static final String CACHE_KEY = "abc"; + private static final long MS_IN_US = 1000; + + // 5 chunks, each 20 bytes long and 100 ms long. + private static final ChunkIndex CHUNK_INDEX = new ChunkIndex( + new int[] {20, 20, 20, 20, 20}, + new long[] {100, 120, 140, 160, 180}, + new long[] {100 * MS_IN_US, 100 * MS_IN_US, 100 * MS_IN_US, 100 * MS_IN_US, 100 * MS_IN_US}, + new long[] {0, 100 * MS_IN_US, 200 * MS_IN_US, 300 * MS_IN_US, 400 * MS_IN_US}); + + @Mock private Cache cache; + private CachedRegionTracker tracker; + + private CachedContentIndex index; + private File cacheDir; + + @Override + protected void setUp() throws Exception { + TestUtil.setUpMockito(this); + + tracker = new CachedRegionTracker(cache, CACHE_KEY, CHUNK_INDEX); + + cacheDir = TestUtil.createTempFolder(getInstrumentation().getContext()); + index = new CachedContentIndex(cacheDir); + } + + @Override + protected void tearDown() throws Exception { + TestUtil.recursiveDelete(cacheDir); + } + + public void testGetRegion_noSpansInCache() { + assertEquals(CachedRegionTracker.NOT_CACHED, tracker.getRegionEndTimeMs(100)); + assertEquals(CachedRegionTracker.NOT_CACHED, tracker.getRegionEndTimeMs(150)); + } + + public void testGetRegion_fullyCached() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 100)); + + assertEquals(CachedRegionTracker.CACHED_TO_END, tracker.getRegionEndTimeMs(101)); + assertEquals(CachedRegionTracker.CACHED_TO_END, tracker.getRegionEndTimeMs(121)); + } + + public void testGetRegion_partiallyCached() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 40)); + + assertEquals(200, tracker.getRegionEndTimeMs(101)); + assertEquals(200, tracker.getRegionEndTimeMs(121)); + } + + public void testGetRegion_multipleSpanAddsJoinedCorrectly() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 20)); + tracker.onSpanAdded( + cache, + newCacheSpan(120, 20)); + + assertEquals(200, tracker.getRegionEndTimeMs(101)); + assertEquals(200, tracker.getRegionEndTimeMs(121)); + } + + public void testGetRegion_fullyCachedThenPartiallyRemoved() throws Exception { + // Start with the full stream in cache. + tracker.onSpanAdded( + cache, + newCacheSpan(100, 100)); + + // Remove the middle bit. + tracker.onSpanRemoved( + cache, + newCacheSpan(140, 40)); + + assertEquals(200, tracker.getRegionEndTimeMs(101)); + assertEquals(200, tracker.getRegionEndTimeMs(121)); + + assertEquals(CachedRegionTracker.CACHED_TO_END, tracker.getRegionEndTimeMs(181)); + } + + public void testGetRegion_subchunkEstimation() throws Exception { + tracker.onSpanAdded( + cache, + newCacheSpan(100, 10)); + + assertEquals(50, tracker.getRegionEndTimeMs(101)); + assertEquals(CachedRegionTracker.NOT_CACHED, tracker.getRegionEndTimeMs(111)); + } + + private CacheSpan newCacheSpan(int position, int length) throws IOException { + return SimpleCacheSpanTest.createCacheSpan(index, cacheDir, CACHE_KEY, position, length, 0); + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java new file mode 100644 index 0000000000..b4e7e6e7f6 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2016 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.crypto; + +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.Util; +import java.util.Random; +import javax.crypto.Cipher; +import junit.framework.TestCase; + +/** + * Unit tests for {@link AesFlushingCipher}. + */ +public class AesFlushingCipherTest extends TestCase { + + private static final int DATA_LENGTH = 65536; + private static final byte[] KEY = Util.getUtf8Bytes("testKey:12345678"); + private static final long NONCE = 0; + private static final long START_OFFSET = 11; + private static final long RANDOM_SEED = 0x12345678; + + private AesFlushingCipher encryptCipher; + private AesFlushingCipher decryptCipher; + + @Override + protected void setUp() { + encryptCipher = new AesFlushingCipher(Cipher.ENCRYPT_MODE, KEY, NONCE, START_OFFSET); + decryptCipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, KEY, NONCE, START_OFFSET); + } + + @Override + protected void tearDown() { + encryptCipher = null; + decryptCipher = null; + } + + private long getMaxUnchangedBytesAllowedPostEncryption(long length) { + // Assuming that not more than 10% of the resultant bytes should be identical. + // The value of 10% is arbitrary, ciphers standards do not name a value. + return length / 10; + } + + // Count the number of bytes that do not match. + private int getDifferingByteCount(byte[] data1, byte[] data2, int startOffset) { + int count = 0; + for (int i = startOffset; i < data1.length; i++) { + if (data1[i] != data2[i]) { + count++; + } + } + return count; + } + + // Count the number of bytes that do not match. + private int getDifferingByteCount(byte[] data1, byte[] data2) { + return getDifferingByteCount(data1, data2, 0); + } + + // Test a single encrypt and decrypt call + public void testSingle() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + + encryptCipher.updateInPlace(data, 0, data.length); + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + decryptCipher.updateInPlace(data, 0, data.length); + int differingByteCount = getDifferingByteCount(reference, data); + assertEquals(0, differingByteCount); + } + + // Test several encrypt and decrypt calls, each aligned on a 16 byte block size + public void testAligned() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + Random random = new Random(RANDOM_SEED); + + int offset = 0; + while (offset < data.length) { + int bytes = (1 + random.nextInt(50)) * 16; + bytes = Math.min(bytes, data.length - offset); + assertEquals(0, bytes % 16); + encryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + offset = 0; + while (offset < data.length) { + int bytes = (1 + random.nextInt(50)) * 16; + bytes = Math.min(bytes, data.length - offset); + assertEquals(0, bytes % 16); + decryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int differingByteCount = getDifferingByteCount(reference, data); + assertEquals(0, differingByteCount); + } + + // Test several encrypt and decrypt calls, not aligned on block boundary + public void testUnAligned() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + Random random = new Random(RANDOM_SEED); + + // Encrypt + int offset = 0; + while (offset < data.length) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, data.length - offset); + encryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + offset = 0; + while (offset < data.length) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, data.length - offset); + decryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + int differingByteCount = getDifferingByteCount(reference, data); + assertEquals(0, differingByteCount); + } + + // Test decryption starting from the middle of an encrypted block + public void testMidJoin() { + byte[] reference = TestUtil.buildTestData(DATA_LENGTH); + byte[] data = reference.clone(); + Random random = new Random(RANDOM_SEED); + + // Encrypt + int offset = 0; + while (offset < data.length) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, data.length - offset); + encryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + } + + // Verify + int unchangedByteCount = data.length - getDifferingByteCount(reference, data); + assertTrue(unchangedByteCount <= getMaxUnchangedBytesAllowedPostEncryption(data.length)); + + // Setup decryption from random location + offset = random.nextInt(4096); + decryptCipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, KEY, NONCE, offset + START_OFFSET); + int remainingLength = data.length - offset; + int originalOffset = offset; + + // Decrypt + while (remainingLength > 0) { + int bytes = 1 + random.nextInt(4095); + bytes = Math.min(bytes, remainingLength); + decryptCipher.updateInPlace(data, offset, bytes); + offset += bytes; + remainingLength -= bytes; + } + + // Verify + int differingByteCount = getDifferingByteCount(reference, data, originalOffset); + assertEquals(0, differingByteCount); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java new file mode 100644 index 0000000000..0f08ca40f2 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2016 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 android.util.Log; +import com.google.android.exoplayer2.extractor.ChunkIndex; +import java.util.Arrays; +import java.util.Iterator; +import java.util.NavigableSet; +import java.util.TreeSet; + +/** + * Utility class for efficiently tracking regions of data that are stored in a {@link Cache} + * for a given cache key. + */ +public final class CachedRegionTracker implements Cache.Listener { + + private static final String TAG = "CachedRegionTracker"; + + public static final int NOT_CACHED = -1; + public static final int CACHED_TO_END = -2; + + private final Cache cache; + private final String cacheKey; + private final ChunkIndex chunkIndex; + + private final TreeSet regions; + private final Region lookupRegion; + + public CachedRegionTracker(Cache cache, String cacheKey, ChunkIndex chunkIndex) { + this.cache = cache; + this.cacheKey = cacheKey; + this.chunkIndex = chunkIndex; + this.regions = new TreeSet<>(); + this.lookupRegion = new Region(0, 0); + + synchronized (this) { + NavigableSet cacheSpans = cache.addListener(cacheKey, this); + if (cacheSpans != null) { + // Merge the spans into regions. mergeSpan is more efficient when merging from high to low, + // which is why a descending iterator is used here. + Iterator spanIterator = cacheSpans.descendingIterator(); + while (spanIterator.hasNext()) { + CacheSpan span = spanIterator.next(); + mergeSpan(span); + } + } + } + } + + public void release() { + cache.removeListener(cacheKey, this); + } + + /** + * When provided with a byte offset, this method locates the cached region within which the + * offset falls, and returns the approximate end position in milliseconds of that region. If the + * byte offset does not fall within a cached region then {@link #NOT_CACHED} is returned. + * If the cached region extends to the end of the stream, {@link #CACHED_TO_END} is returned. + * + * @param byteOffset The byte offset in the underlying stream. + * @return The end position of the corresponding cache region, {@link #NOT_CACHED}, or + * {@link #CACHED_TO_END}. + */ + public synchronized int getRegionEndTimeMs(long byteOffset) { + lookupRegion.startOffset = byteOffset; + Region floorRegion = regions.floor(lookupRegion); + if (floorRegion == null || byteOffset > floorRegion.endOffset + || floorRegion.endOffsetIndex == -1) { + return NOT_CACHED; + } + int index = floorRegion.endOffsetIndex; + if (index == chunkIndex.length - 1 + && floorRegion.endOffset == (chunkIndex.offsets[index] + chunkIndex.sizes[index])) { + return CACHED_TO_END; + } + long segmentFractionUs = (chunkIndex.durationsUs[index] + * (floorRegion.endOffset - chunkIndex.offsets[index])) / chunkIndex.sizes[index]; + return (int) ((chunkIndex.timesUs[index] + segmentFractionUs) / 1000); + } + + @Override + public synchronized void onSpanAdded(Cache cache, CacheSpan span) { + mergeSpan(span); + } + + @Override + public synchronized void onSpanRemoved(Cache cache, CacheSpan span) { + Region removedRegion = new Region(span.position, span.position + span.length); + + // Look up a region this span falls into. + Region floorRegion = regions.floor(removedRegion); + if (floorRegion == null) { + Log.e(TAG, "Removed a span we were not aware of"); + return; + } + + // Remove it. + regions.remove(floorRegion); + + // Add new floor and ceiling regions, if necessary. + if (floorRegion.startOffset < removedRegion.startOffset) { + Region newFloorRegion = new Region(floorRegion.startOffset, removedRegion.startOffset); + + int index = Arrays.binarySearch(chunkIndex.offsets, newFloorRegion.endOffset); + newFloorRegion.endOffsetIndex = index < 0 ? -index - 2 : index; + regions.add(newFloorRegion); + } + + if (floorRegion.endOffset > removedRegion.endOffset) { + Region newCeilingRegion = new Region(removedRegion.endOffset + 1, floorRegion.endOffset); + newCeilingRegion.endOffsetIndex = floorRegion.endOffsetIndex; + regions.add(newCeilingRegion); + } + } + + @Override + public void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) { + // Do nothing. + } + + private void mergeSpan(CacheSpan span) { + Region newRegion = new Region(span.position, span.position + span.length); + Region floorRegion = regions.floor(newRegion); + Region ceilingRegion = regions.ceiling(newRegion); + boolean floorConnects = regionsConnect(floorRegion, newRegion); + boolean ceilingConnects = regionsConnect(newRegion, ceilingRegion); + + if (ceilingConnects) { + if (floorConnects) { + // Extend floorRegion to cover both newRegion and ceilingRegion. + floorRegion.endOffset = ceilingRegion.endOffset; + floorRegion.endOffsetIndex = ceilingRegion.endOffsetIndex; + } else { + // Extend newRegion to cover ceilingRegion. Add it. + newRegion.endOffset = ceilingRegion.endOffset; + newRegion.endOffsetIndex = ceilingRegion.endOffsetIndex; + regions.add(newRegion); + } + regions.remove(ceilingRegion); + } else if (floorConnects) { + // Extend floorRegion to the right to cover newRegion. + floorRegion.endOffset = newRegion.endOffset; + int index = floorRegion.endOffsetIndex; + while (index < chunkIndex.length - 1 + && (chunkIndex.offsets[index + 1] <= floorRegion.endOffset)) { + index++; + } + floorRegion.endOffsetIndex = index; + } else { + // This is a new region. + int index = Arrays.binarySearch(chunkIndex.offsets, newRegion.endOffset); + newRegion.endOffsetIndex = index < 0 ? -index - 2 : index; + regions.add(newRegion); + } + } + + private boolean regionsConnect(Region lower, Region upper) { + return lower != null && upper != null && lower.endOffset == upper.startOffset; + } + + private static class Region implements Comparable { + + /** + * The first byte of the region (inclusive). + */ + public long startOffset; + /** + * End offset of the region (exclusive). + */ + public long endOffset; + /** + * The index in chunkIndex that contains the end offset. May be -1 if the end offset comes + * before the start of the first media chunk (i.e. if the end offset is within the stream + * header). + */ + public int endOffsetIndex; + + public Region(long position, long endOffset) { + this.startOffset = position; + this.endOffset = endOffset; + } + + @Override + public int compareTo(Region another) { + return startOffset < another.startOffset ? -1 + : startOffset == another.startOffset ? 0 : 1; + } + + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java new file mode 100644 index 0000000000..ccf9a5b3f5 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2016 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.crypto; + +import com.google.android.exoplayer2.upstream.DataSink; +import com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import javax.crypto.Cipher; + +/** + * A wrapping {@link DataSink} that encrypts the data being consumed. + */ +public final class AesCipherDataSink implements DataSink { + + private final DataSink wrappedDataSink; + private final byte[] secretKey; + private final byte[] scratch; + + private AesFlushingCipher cipher; + + /** + * Create an instance whose {@code write} methods have the side effect of overwriting the input + * {@code data}. Use this constructor for maximum efficiency in the case that there is no + * requirement for the input data arrays to remain unchanged. + * + * @param secretKey The key data. + * @param wrappedDataSink The wrapped {@link DataSink}. + */ + public AesCipherDataSink(byte[] secretKey, DataSink wrappedDataSink) { + this(secretKey, wrappedDataSink, null); + } + + /** + * Create an instance whose {@code write} methods are free of side effects. Use this constructor + * when the input data arrays are required to remain unchanged. + * + * @param secretKey The key data. + * @param wrappedDataSink The wrapped {@link DataSink}. + * @param scratch Scratch space. Data is decrypted into this array before being written to the + * wrapped {@link DataSink}. It should be of appropriate size for the expected writes. If a + * write is larger than the size of this array the write will still succeed, but multiple + * cipher calls will be required to complete the operation. + */ + public AesCipherDataSink(byte[] secretKey, DataSink wrappedDataSink, byte[] scratch) { + this.wrappedDataSink = wrappedDataSink; + this.secretKey = secretKey; + this.scratch = scratch; + } + + @Override + public void open(DataSpec dataSpec) throws IOException { + wrappedDataSink.open(dataSpec); + long nonce = CryptoUtil.getFNV64Hash(dataSpec.key); + cipher = new AesFlushingCipher(Cipher.ENCRYPT_MODE, secretKey, nonce, + dataSpec.absoluteStreamPosition); + } + + @Override + public void write(byte[] data, int offset, int length) throws IOException { + if (scratch == null) { + // In-place mode. Writes over the input data. + cipher.updateInPlace(data, offset, length); + wrappedDataSink.write(data, offset, length); + } else { + // Use scratch space. The original data remains intact. + int bytesProcessed = 0; + while (bytesProcessed < length) { + int bytesToProcess = Math.min(length - bytesProcessed, scratch.length); + cipher.update(data, offset + bytesProcessed, bytesToProcess, scratch, 0); + wrappedDataSink.write(scratch, 0, bytesToProcess); + bytesProcessed += bytesToProcess; + } + } + } + + @Override + public void close() throws IOException { + cipher = null; + wrappedDataSink.close(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java new file mode 100644 index 0000000000..26ac3b38fa --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2016 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.crypto; + +import android.net.Uri; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import javax.crypto.Cipher; + +/** + * A {@link DataSource} that decrypts the data read from an upstream source. + */ +public final class AesCipherDataSource implements DataSource { + + private final DataSource upstream; + private final byte[] secretKey; + + private AesFlushingCipher cipher; + + public AesCipherDataSource(byte[] secretKey, DataSource upstream) { + this.upstream = upstream; + this.secretKey = secretKey; + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + long dataLength = upstream.open(dataSpec); + long nonce = CryptoUtil.getFNV64Hash(dataSpec.key); + cipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, secretKey, nonce, + dataSpec.absoluteStreamPosition); + return dataLength; + } + + @Override + public int read(byte[] data, int offset, int readLength) throws IOException { + if (readLength == 0) { + return 0; + } + int read = upstream.read(data, offset, readLength); + if (read == C.RESULT_END_OF_INPUT) { + return C.RESULT_END_OF_INPUT; + } + cipher.updateInPlace(data, offset, read); + return read; + } + + @Override + public void close() throws IOException { + cipher = null; + upstream.close(); + } + + @Override + public Uri getUri() { + return upstream.getUri(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java new file mode 100644 index 0000000000..e093eb3064 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2016 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.crypto; + +import com.google.android.exoplayer2.util.Assertions; +import java.nio.ByteBuffer; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * A flushing variant of a AES/CTR/NoPadding {@link Cipher}. + * + * Unlike a regular {@link Cipher}, the update methods of this class are guaranteed to process all + * of the bytes input (and hence output the same number of bytes). + */ +public final class AesFlushingCipher { + + private final Cipher cipher; + private final int blockSize; + private final byte[] zerosBlock; + private final byte[] flushedBlock; + + private int pendingXorBytes; + + public AesFlushingCipher(int mode, byte[] secretKey, long nonce, long offset) { + try { + cipher = Cipher.getInstance("AES/CTR/NoPadding"); + blockSize = cipher.getBlockSize(); + zerosBlock = new byte[blockSize]; + flushedBlock = new byte[blockSize]; + long counter = offset / blockSize; + int startPadding = (int) (offset % blockSize); + cipher.init(mode, new SecretKeySpec(secretKey, cipher.getAlgorithm().split("/")[0]), + new IvParameterSpec(getInitializationVector(nonce, counter))); + if (startPadding != 0) { + updateInPlace(new byte[startPadding], 0, startPadding); + } + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | InvalidAlgorithmParameterException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + public void updateInPlace(byte[] data, int offset, int length) { + update(data, offset, length, data, offset); + } + + public void update(byte[] in, int inOffset, int length, byte[] out, int outOffset) { + // If we previously flushed the cipher by inputting zeros up to a block boundary, then we need + // to manually transform the data that actually ended the block. See the comment below for more + // details. + while (pendingXorBytes > 0) { + out[outOffset] = (byte) (in[inOffset] ^ flushedBlock[blockSize - pendingXorBytes]); + outOffset++; + inOffset++; + pendingXorBytes--; + length--; + if (length == 0) { + return; + } + } + + // Do the bulk of the update. + int written = nonFlushingUpdate(in, inOffset, length, out, outOffset); + if (length == written) { + return; + } + + // We need to finish the block to flush out the remaining bytes. We do so by inputting zeros, + // so that the corresponding bytes output by the cipher are those that would have been XORed + // against the real end-of-block data to transform it. We store these bytes so that we can + // perform the transformation manually in the case of a subsequent call to this method with + // the real data. + int bytesToFlush = length - written; + Assertions.checkState(bytesToFlush < blockSize); + outOffset += written; + pendingXorBytes = blockSize - bytesToFlush; + written = nonFlushingUpdate(zerosBlock, 0, pendingXorBytes, flushedBlock, 0); + Assertions.checkState(written == blockSize); + // The first part of xorBytes contains the flushed data, which we copy out. The remainder + // contains the bytes that will be needed for manual transformation in a subsequent call. + for (int i = 0; i < bytesToFlush; i++) { + out[outOffset++] = flushedBlock[i]; + } + } + + private int nonFlushingUpdate(byte[] in, int inOffset, int length, byte[] out, int outOffset) { + try { + return cipher.update(in, inOffset, length, out, outOffset); + } catch (ShortBufferException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + private byte[] getInitializationVector(long nonce, long counter) { + return ByteBuffer.allocate(16).putLong(nonce).putLong(counter).array(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java new file mode 100644 index 0000000000..ff8841fa9c --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2016 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.crypto; + +/** + * Utility functions for the crypto package. + */ +/* package */ final class CryptoUtil { + + private CryptoUtil() {} + + /** + * Returns the hash value of the input as a long using the 64 bit FNV-1a hash function. The hash + * values produced by this function are less likely to collide than those produced by + * {@link #hashCode()}. + */ + public static long getFNV64Hash(String input) { + if (input == null) { + return 0; + } + + long hash = 0; + for (int i = 0; i < input.length(); i++) { + hash ^= input.charAt(i); + // This is equivalent to hash *= 0x100000001b3 (the FNV magic prime number). + hash += (hash << 1) + (hash << 4) + (hash << 5) + (hash << 7) + (hash << 8) + (hash << 40); + } + return hash; + } + +}