Add upstream.crypto package (and friends).

This commit is contained in:
Oliver Woodman 2017-01-20 20:50:02 +00:00
parent 52d47aa244
commit 55ca323cee
9 changed files with 1033 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Region> 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<CacheSpan> 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<CacheSpan> 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<Region> {
/**
* 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;
}
}
}

View File

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

View File

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

View File

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

View File

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