diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java index 4666c81dfb..ad64de50ca 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java @@ -8,9 +8,11 @@ import com.google.android.exoplayer2.testutil.TestUtil; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; +import java.io.IOException; import java.util.Arrays; import java.util.Collection; import java.util.Set; +import junit.framework.AssertionFailedError; /** * Tests {@link CachedContentIndex}. @@ -91,21 +93,7 @@ public class CachedContentIndexTest extends InstrumentationTestCase { } public void testStoreAndLoad() throws Exception { - index.addNew(new CachedContent(5, "key1", 10)); - index.add("key2"); - - index.store(); - - CachedContentIndex index2 = new CachedContentIndex(cacheDir); - index2.load(); - - Set keys = index.getKeys(); - Set keys2 = index2.getKeys(); - assertEquals(keys, keys2); - for (String key : keys) { - assertEquals(index.getContentLength(key), index2.getContentLength(key)); - assertEquals(index.get(key).getSpans(), index2.get(key).getSpans()); - } + assertStoredAndLoadedEqual(index, new CachedContentIndex(cacheDir)); } public void testLoadV1() throws Exception { @@ -168,4 +156,66 @@ public class CachedContentIndexTest extends InstrumentationTestCase { assertEquals(1, CachedContentIndex.getNewId(idToKey)); } + public void testEncryption() throws Exception { + byte[] key = "Bar12345Bar12345".getBytes(C.UTF8_NAME); // 128 bit key + byte[] key2 = "bar12345Bar12345".getBytes(C.UTF8_NAME); // 128 bit key + + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir, key), + new CachedContentIndex(cacheDir, key)); + + // Rename the index file from the test above + File file1 = new File(cacheDir, CachedContentIndex.FILE_NAME); + File file2 = new File(cacheDir, "file2compare"); + assertTrue(file1.renameTo(file2)); + + // Write a new index file + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir, key), + new CachedContentIndex(cacheDir, key)); + + assertEquals(file2.length(), file1.length()); + // Assert file content is different + FileInputStream fis1 = new FileInputStream(file1); + FileInputStream fis2 = new FileInputStream(file2); + for (int b; (b = fis1.read()) == fis2.read();) { + assertTrue(b != -1); + } + + boolean threw = false; + try { + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir, key), + new CachedContentIndex(cacheDir, key2)); + } catch (AssertionFailedError e) { + threw = true; + } + assertTrue("Encrypted index file can not be read with different encryption key", threw); + + try { + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir, key), + new CachedContentIndex(cacheDir)); + } catch (AssertionFailedError e) { + threw = true; + } + assertTrue("Encrypted index file can not be read without encryption key", threw); + + // Non encrypted index file can be read even when encryption key provided. + assertStoredAndLoadedEqual(new CachedContentIndex(cacheDir), + new CachedContentIndex(cacheDir, key)); + } + + private void assertStoredAndLoadedEqual(CachedContentIndex index, CachedContentIndex index2) + throws IOException { + index.addNew(new CachedContent(5, "key1", 10)); + index.add("key2"); + index.store(); + + index2.load(); + Set keys = index.getKeys(); + Set keys2 = index2.getKeys(); + assertEquals(keys, keys2); + for (String key : keys) { + assertEquals(index.getContentLength(key), index2.getContentLength(key)); + assertEquals(index.get(key).getSpans(), index2.get(key).getSpans()); + } + } + } diff --git a/library/src/androidTest/java/com/google/android/exoplayer2/util/AtomicFileTest.java b/library/src/androidTest/java/com/google/android/exoplayer2/util/AtomicFileTest.java new file mode 100644 index 0000000000..afe28a1e99 --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer2/util/AtomicFileTest.java @@ -0,0 +1,85 @@ +/* + * 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.util; + +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Tests {@link AtomicFile}. + */ +public class AtomicFileTest extends InstrumentationTestCase { + + private File tempFolder; + private File file; + private AtomicFile atomicFile; + + @Override + public void setUp() throws Exception { + tempFolder = TestUtil.createTempFolder(getInstrumentation().getContext()); + file = new File(tempFolder, "atomicFile"); + atomicFile = new AtomicFile(file); + } + + @Override + protected void tearDown() throws Exception { + TestUtil.recursiveDelete(tempFolder); + } + + public void testDelete() throws Exception { + assertTrue(file.createNewFile()); + atomicFile.delete(); + assertFalse(file.exists()); + } + + public void testWriteEndRead() throws Exception { + OutputStream output = atomicFile.startWrite(); + output.write(5); + atomicFile.endWrite(output); + output.close(); + + assertRead(); + + output = atomicFile.startWrite(); + output.write(5); + output.write(6); + output.close(); + + assertRead(); + + output = atomicFile.startWrite(); + output.write(6); + + assertRead(); + + output = atomicFile.startWrite(); + + assertRead(); + } + + private void assertRead() throws IOException { + InputStream input = atomicFile.openRead(); + assertEquals(5, input.read()); + assertEquals(-1, input.read()); + input.close(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index 27b989c36f..8dcfe75670 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream.cache; import java.io.File; +import java.io.IOException; import java.util.NavigableSet; import java.util.Set; @@ -60,6 +61,21 @@ public interface Cache { void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan); } + + /** + * Thrown when an error is encountered when writing data. + */ + class CacheException extends IOException { + + public CacheException(String message) { + super(message); + } + + public CacheException(IOException cause) { + super(cause); + } + + } /** * Registers a listener to listen for changes to a given key. @@ -125,7 +141,7 @@ public interface Cache { * @return The {@link CacheSpan}. * @throws InterruptedException */ - CacheSpan startReadWrite(String key, long position) throws InterruptedException; + CacheSpan startReadWrite(String key, long position) throws InterruptedException, CacheException; /** * Same as {@link #startReadWrite(String, long)}. However, if the cache entry is locked, then @@ -135,7 +151,7 @@ public interface Cache { * @param position The position of the data being requested. * @return The {@link CacheSpan}. Or null if the cache entry is locked. */ - CacheSpan startReadWriteNonBlocking(String key, long position); + CacheSpan startReadWriteNonBlocking(String key, long position) throws CacheException; /** * Obtains a cache file into which data can be written. Must only be called when holding a @@ -147,7 +163,7 @@ public interface Cache { * is enough space in the cache. * @return The file into which data should be written. */ - File startFile(String key, long position, long maxLength); + File startFile(String key, long position, long maxLength) throws CacheException; /** * Commits a file into the cache. Must only be called when holding a corresponding hole @@ -155,7 +171,7 @@ public interface Cache { * * @param file A newly written cache file. */ - void commitFile(File file); + void commitFile(File file) throws CacheException; /** * Releases a {@link CacheSpan} obtained from {@link #startReadWrite(String, long)} which @@ -170,7 +186,7 @@ public interface Cache { * * @param span The {@link CacheSpan} to remove. */ - void removeSpan(CacheSpan span); + void removeSpan(CacheSpan span) throws CacheException; /** * Queries if a range is entirely available in the cache. @@ -188,7 +204,7 @@ public interface Cache { * @param key The cache key for the data. * @param length The length of the data. */ - void setContentLength(String key, long length); + void setContentLength(String key, long length) throws CacheException; /** * Returns the content length for the given key if one set, or {@link diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java index 96c198b4c9..6a301f8a2e 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java @@ -18,10 +18,10 @@ package com.google.android.exoplayer2.upstream.cache; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.upstream.DataSink; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.File; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; @@ -42,7 +42,7 @@ public final class CacheDataSink implements DataSink { /** * Thrown when IOException is encountered when writing data into sink. */ - public static class CacheDataSinkException extends IOException { + public static class CacheDataSinkException extends CacheException { public CacheDataSinkException(IOException cause) { super(cause); @@ -50,7 +50,6 @@ public final class CacheDataSink implements DataSink { } - /** * @param cache The cache into which data should be written. * @param maxCacheFileSize The maximum size of a cache file, in bytes. If the sink is opened for @@ -71,7 +70,7 @@ public final class CacheDataSink implements DataSink { dataSpecBytesWritten = 0; try { openNextOutputStream(); - } catch (FileNotFoundException e) { + } catch (IOException e) { throw new CacheDataSinkException(e); } } @@ -112,7 +111,7 @@ public final class CacheDataSink implements DataSink { } } - private void openNextOutputStream() throws FileNotFoundException { + private void openNextOutputStream() throws IOException { file = cache.startFile(dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten, Math.min(dataSpec.length - dataSpecBytesWritten, maxCacheFileSize)); outputStream = new FileOutputStream(file); diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index d53a5d8fe8..b98eadc4cc 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -24,7 +24,7 @@ import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.TeeDataSource; -import com.google.android.exoplayer2.upstream.cache.CacheDataSink.CacheDataSinkException; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import java.io.IOException; import java.io.InterruptedIOException; import java.lang.annotation.Retention; @@ -328,7 +328,7 @@ public final class CacheDataSource implements DataSource { return successful; } - private void setContentLength(long length) { + private void setContentLength(long length) throws IOException { cache.setContentLength(key, length); } @@ -349,7 +349,7 @@ public final class CacheDataSource implements DataSource { } private void handleBeforeThrow(IOException exception) { - if (currentDataSource == cacheReadDataSource || exception instanceof CacheDataSinkException) { + if (currentDataSource == cacheReadDataSource || exception instanceof CacheException) { seenCacheError = true; } } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java index a25688f9db..c744a176ad 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream.cache; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Assertions; import java.io.DataInputStream; import java.io.DataOutputStream; @@ -150,14 +151,18 @@ import java.util.TreeSet; * * @param cacheSpan Span to be copied and updated. * @return a span with the updated last access time. + * @throws CacheException If renaming of the underlying span file failed. */ - public SimpleCacheSpan touch(SimpleCacheSpan cacheSpan) { + public SimpleCacheSpan touch(SimpleCacheSpan cacheSpan) throws CacheException { // Remove the old span from the in-memory representation. Assertions.checkState(cachedSpans.remove(cacheSpan)); // Obtain a new span with updated last access timestamp. SimpleCacheSpan newCacheSpan = cacheSpan.copyWithUpdatedLastAccessTime(id); // Rename the cache file - cacheSpan.file.renameTo(newCacheSpan.file); + if (!cacheSpan.file.renameTo(newCacheSpan.file)) { + throw new CacheException("Renaming of " + cacheSpan.file + " to " + newCacheSpan.file + + " failed."); + } // Add the updated span back into the in-memory representation. cachedSpans.add(newCacheSpan); return newCacheSpan; diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java index 4f884606ee..47019b98a3 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java @@ -17,18 +17,30 @@ package com.google.android.exoplayer2.upstream.cache; import android.util.SparseArray; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.AtomicFile; import com.google.android.exoplayer2.util.Util; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.util.Collection; import java.util.HashMap; import java.util.LinkedList; +import java.util.Random; import java.util.Set; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; /** * This class maintains the index of cached content. @@ -36,15 +48,36 @@ import java.util.Set; /*package*/ final class CachedContentIndex { public static final String FILE_NAME = "cached_content_index.exi"; + private static final int VERSION = 1; + private static final int FLAG_ENCRYPTED_INDEX = 1; + private final HashMap keyToContent; private final SparseArray idToKey; private final AtomicFile atomicFile; + private final Cipher cipher; + private final SecretKeySpec secretKeySpec; private boolean changed; /** Creates a CachedContentIndex which works on the index file in the given cacheDir. */ public CachedContentIndex(File cacheDir) { + this(cacheDir, null); + } + + /** Creates a CachedContentIndex which works on the index file in the given cacheDir. */ + public CachedContentIndex(File cacheDir, byte[] secretKey) { + if (secretKey != null) { + try { + cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING"); + secretKeySpec = new SecretKeySpec(secretKey, "AES"); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new IllegalStateException(e); // Should never happen. + } + } else { + cipher = null; + secretKeySpec = null; + } keyToContent = new HashMap<>(); idToKey = new SparseArray<>(); atomicFile = new AtomicFile(new File(cacheDir, FILE_NAME)); @@ -53,18 +86,15 @@ import java.util.Set; /** Loads the index file. */ public void load() { Assertions.checkState(!changed); - File cacheIndex = atomicFile.getBaseFile(); - if (cacheIndex.exists()) { - if (!readFile()) { - cacheIndex.delete(); - keyToContent.clear(); - idToKey.clear(); - } + if (!readFile()) { + atomicFile.delete(); + keyToContent.clear(); + idToKey.clear(); } } /** Stores the index data to index file if there is a change. */ - public void store() { + public void store() throws CacheException { if (!changed) { return; } @@ -177,13 +207,30 @@ import java.util.Set; private boolean readFile() { DataInputStream input = null; try { - input = new DataInputStream(atomicFile.openRead()); + InputStream inputStream = atomicFile.openRead(); + input = new DataInputStream(inputStream); int version = input.readInt(); if (version != VERSION) { // Currently there is no other version return false; } - input.readInt(); // ignore flags placeholder + + int flags = input.readInt(); + if ((flags & FLAG_ENCRYPTED_INDEX) != 0) { + if (cipher == null) { + return false; + } + byte[] initializationVector = new byte[16]; + input.read(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); + } + input = new DataInputStream(new CipherInputStream(inputStream, cipher)); + } + int count = input.readInt(); int hashCode = 0; for (int i = 0; i < count; i++) { @@ -204,14 +251,30 @@ import java.util.Set; return true; } - private void writeFile() { - FileOutputStream outputStream = null; + private void writeFile() throws CacheException { + DataOutputStream output = null; try { - outputStream = atomicFile.startWrite(); - DataOutputStream output = new DataOutputStream(outputStream); - + OutputStream outputStream = atomicFile.startWrite(); + output = new DataOutputStream(outputStream); output.writeInt(VERSION); - output.writeInt(0); // flags placeholder + + int flags = cipher != null ? FLAG_ENCRYPTED_INDEX : 0; + output.writeInt(flags); + + if (cipher != null) { + byte[] initializationVector = new byte[16]; + new Random().nextBytes(initializationVector); + output.write(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); // Should never happen. + } + output.flush(); + output = new DataOutputStream(new CipherOutputStream(outputStream, cipher)); + } + output.writeInt(keyToContent.size()); int hashCode = 0; for (CachedContent cachedContent : keyToContent.values()) { @@ -219,12 +282,11 @@ import java.util.Set; hashCode += cachedContent.headerHashCode(); } output.writeInt(hashCode); - - output.flush(); - atomicFile.finishWrite(outputStream); + atomicFile.endWrite(output); } catch (IOException e) { - atomicFile.failWrite(outputStream); - throw new RuntimeException("Writing the new cache index file failed.", e); + throw new CacheException(e); + } finally { + Util.closeQuietly(output); } } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java index 791fb677f1..d2a84f65f4 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream.cache; +import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import java.util.Comparator; import java.util.TreeSet; @@ -74,7 +75,11 @@ public final class LeastRecentlyUsedCacheEvictor implements CacheEvictor, Compar private void evictCache(Cache cache, long requiredSpace) { while (currentSize + requiredSpace > maxBytes) { - cache.removeSpan(leastRecentlyUsed.first()); + try { + cache.removeSpan(leastRecentlyUsed.first()); + } catch (CacheException e) { + // do nothing. + } } } diff --git a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 53a44a5797..ad3569aca0 100644 --- a/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -38,18 +38,33 @@ public final class SimpleCache implements Cache { private final CachedContentIndex index; private final HashMap> listeners; private long totalSpace = 0; + private CacheException initializationException; /** * Constructs the cache. The cache will delete any unrecognized files from the directory. Hence * the directory cannot be used to store other files. * * @param cacheDir A dedicated cache directory. + * @param evictor The evictor to be used. */ public SimpleCache(File cacheDir, CacheEvictor evictor) { + this(cacheDir, evictor, null); + } + + /** + * Constructs the cache. The cache will delete any unrecognized files from the directory. Hence + * the directory cannot be used to store other files. + * + * @param cacheDir A dedicated cache directory. + * @param evictor The evictor to be used. + * @param secretKey If not null, cache keys will be stored encrypted on filesystem using AES/CBC. + * The key must be 16 bytes long. + */ + public SimpleCache(File cacheDir, CacheEvictor evictor, byte[] secretKey) { this.cacheDir = cacheDir; this.evictor = evictor; this.lockedSpans = new HashMap<>(); - this.index = new CachedContentIndex(cacheDir); + this.index = new CachedContentIndex(cacheDir, secretKey); this.listeners = new HashMap<>(); // Start cache initialization. final ConditionVariable conditionVariable = new ConditionVariable(); @@ -58,7 +73,11 @@ public final class SimpleCache implements Cache { public void run() { synchronized (SimpleCache.this) { conditionVariable.open(); - initialize(); + try { + initialize(); + } catch (CacheException e) { + initializationException = e; + } SimpleCache.this.evictor.onCacheInitialized(); } } @@ -106,7 +125,7 @@ public final class SimpleCache implements Cache { @Override public synchronized SimpleCacheSpan startReadWrite(String key, long position) - throws InterruptedException { + throws InterruptedException, CacheException { while (true) { SimpleCacheSpan span = startReadWriteNonBlocking(key, position); if (span != null) { @@ -122,7 +141,12 @@ public final class SimpleCache implements Cache { } @Override - public synchronized SimpleCacheSpan startReadWriteNonBlocking(String key, long position) { + public synchronized SimpleCacheSpan startReadWriteNonBlocking(String key, long position) + throws CacheException { + if (initializationException != null) { + throw initializationException; + } + SimpleCacheSpan cacheSpan = getSpan(key, position); // Read case. @@ -144,7 +168,8 @@ public final class SimpleCache implements Cache { } @Override - public synchronized File startFile(String key, long position, long maxLength) { + public synchronized File startFile(String key, long position, long maxLength) + throws CacheException { Assertions.checkState(lockedSpans.containsKey(key)); if (!cacheDir.exists()) { // For some reason the cache directory doesn't exist. Make a best effort to create it. @@ -157,7 +182,7 @@ public final class SimpleCache implements Cache { } @Override - public synchronized void commitFile(File file) { + public synchronized void commitFile(File file) throws CacheException { SimpleCacheSpan span = SimpleCacheSpan.createCacheEntry(file, index); Assertions.checkState(span != null); Assertions.checkState(lockedSpans.containsKey(span.key)); @@ -199,7 +224,7 @@ public final class SimpleCache implements Cache { * @param position The position of the span being requested. * @return The corresponding cache {@link SimpleCacheSpan}. */ - private SimpleCacheSpan getSpan(String key, long position) { + private SimpleCacheSpan getSpan(String key, long position) throws CacheException { CachedContent cachedContent = index.get(key); if (cachedContent == null) { return SimpleCacheSpan.createOpenHole(key, position); @@ -219,7 +244,7 @@ public final class SimpleCache implements Cache { /** * Ensures that the cache's in-memory representation has been initialized. */ - private void initialize() { + private void initialize() throws CacheException { if (!cacheDir.exists()) { cacheDir.mkdirs(); return; @@ -259,7 +284,7 @@ public final class SimpleCache implements Cache { notifySpanAdded(span); } - private void removeSpan(CacheSpan span, boolean removeEmptyCachedContent) { + private void removeSpan(CacheSpan span, boolean removeEmptyCachedContent) throws CacheException { CachedContent cachedContent = index.get(span.key); Assertions.checkState(cachedContent.removeSpan(span)); totalSpace -= span.length; @@ -271,7 +296,7 @@ public final class SimpleCache implements Cache { } @Override - public synchronized void removeSpan(CacheSpan span) { + public synchronized void removeSpan(CacheSpan span) throws CacheException { removeSpan(span, true); } @@ -279,7 +304,7 @@ public final class SimpleCache implements Cache { * Scans all of the cached spans in the in-memory representation, removing any for which files * no longer exist. */ - private void removeStaleSpansAndCachedContents() { + private void removeStaleSpansAndCachedContents() throws CacheException { LinkedList spansToBeRemoved = new LinkedList<>(); for (CachedContent cachedContent : index.getAll()) { for (CacheSpan span : cachedContent.getSpans()) { @@ -336,7 +361,7 @@ public final class SimpleCache implements Cache { } @Override - public synchronized void setContentLength(String key, long length) { + public synchronized void setContentLength(String key, long length) throws CacheException { index.setContentLength(key, length); index.store(); } diff --git a/library/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java b/library/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java index 3746a741e0..10a473f177 100644 --- a/library/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java +++ b/library/src/main/java/com/google/android/exoplayer2/util/AtomicFile.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2009 The Android Open Source Project + * 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. @@ -22,192 +22,176 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; /** - * Exoplayer internal version of the framework's {@link android.util.AtomicFile}, - * a helper class for performing atomic operations on a file by creating a - * backup file until a write has successfully completed. - *

- * Atomic file guarantees file integrity by ensuring that a file has - * been completely written and sync'd to disk before removing its backup. - * As long as the backup file exists, the original file is considered - * to be invalid (left over from a previous attempt to write the file). - *

- * Atomic file does not confer any file locking semantics. - * Do not use this class when the file may be accessed or modified concurrently - * by multiple threads or processes. The caller is responsible for ensuring - * appropriate mutual exclusion invariants whenever it accesses the file. - *

+ * A helper class for performing atomic operations on a file by creating a backup file until a write + * has successfully completed. + * + *

Atomic file guarantees file integrity by ensuring that a file has been completely written and + * sync'd to disk before removing its backup. As long as the backup file exists, the original file + * is considered to be invalid (left over from a previous attempt to write the file). + * + *

Atomic file does not confer any file locking semantics. Do not use this class when the file + * may be accessed or modified concurrently by multiple threads or processes. The caller is + * responsible for ensuring appropriate mutual exclusion invariants whenever it accesses the file. */ -public class AtomicFile { - private final File mBaseName; - private final File mBackupName; +public final class AtomicFile { + + private static final String TAG = "AtomicFile"; + + private final File baseName; + private final File backupName; /** - * Create a new AtomicFile for a file located at the given File path. - * The secondary backup file will be the same file path with ".bak" appended. + * Create a new AtomicFile for a file located at the given File path. The secondary backup file + * will be the same file path with ".bak" appended. */ public AtomicFile(File baseName) { - mBaseName = baseName; - mBackupName = new File(baseName.getPath() + ".bak"); + this.baseName = baseName; + backupName = new File(baseName.getPath() + ".bak"); } - /** - * Return the path to the base file. You should not generally use this, - * as the data at that path may not be valid. - */ - public File getBaseFile() { - return mBaseName; - } - - /** - * Delete the atomic file. This deletes both the base and backup files. - */ + /** Delete the atomic file. This deletes both the base and backup files. */ public void delete() { - mBaseName.delete(); - mBackupName.delete(); + baseName.delete(); + backupName.delete(); } /** - * Start a new write operation on the file. This returns a FileOutputStream - * to which you can write the new file data. The existing file is replaced - * with the new data. You must not directly close the given - * FileOutputStream; instead call either {@link #finishWrite(FileOutputStream)} - * or {@link #failWrite(FileOutputStream)}. + * Start a new write operation on the file. This returns an {@link OutputStream} to which you can + * write the new file data. If the whole data is written successfully you must call + * {@link #endWrite(OutputStream)}. On failure you should call {@link OutputStream#close()} + * only to free up resources used by it. * - *

Note that if another thread is currently performing - * a write, this will simply replace whatever that thread is writing - * with the new file being written by this thread, and when the other - * thread finishes the write the new write operation will no longer be - * safe (or will be lost). You must do your own threading protection for - * access to AtomicFile. + *

Example usage: + * + *

+   *   DataOutputStream dataOutput = null;
+   *   try {
+   *     OutputStream outputStream = atomicFile.startWrite();
+   *     dataOutput = new DataOutputStream(outputStream); // Wrapper stream
+   *     dataOutput.write(data1);
+   *     dataOutput.write(data2);
+   *     atomicFile.endWrite(dataOutput); // Pass wrapper stream
+   *   } finally{
+   *     if (dataOutput != null) {
+   *       dataOutput.close();
+   *     }
+   *   }
+   * 
+ * + *

Note that if another thread is currently performing a write, this will simply replace + * whatever that thread is writing with the new file being written by this thread, and when the + * other thread finishes the write the new write operation will no longer be safe (or will be + * lost). You must do your own threading protection for access to AtomicFile. */ - public FileOutputStream startWrite() throws IOException { + public OutputStream startWrite() throws IOException { // Rename the current file so it may be used as a backup during the next read - if (mBaseName.exists()) { - if (!mBackupName.exists()) { - if (!mBaseName.renameTo(mBackupName)) { - Log.w("AtomicFile", "Couldn't rename file " + mBaseName - + " to backup file " + mBackupName); + if (baseName.exists()) { + if (!backupName.exists()) { + if (!baseName.renameTo(backupName)) { + Log.w(TAG, "Couldn't rename file " + baseName + " to backup file " + backupName); } } else { - mBaseName.delete(); + baseName.delete(); } } - FileOutputStream str = null; + OutputStream str = null; try { - str = new FileOutputStream(mBaseName); + str = new AtomicFileOutputStream(baseName); } catch (FileNotFoundException e) { - File parent = mBaseName.getParentFile(); + File parent = baseName.getParentFile(); if (!parent.mkdirs()) { - throw new IOException("Couldn't create directory " + mBaseName); + throw new IOException("Couldn't create directory " + baseName); } try { - str = new FileOutputStream(mBaseName); + str = new AtomicFileOutputStream(baseName); } catch (FileNotFoundException e2) { - throw new IOException("Couldn't create " + mBaseName); + throw new IOException("Couldn't create " + baseName); } } return str; } /** - * Call when you have successfully finished writing to the stream - * returned by {@link #startWrite()}. This will close, sync, and - * commit the new data. The next attempt to read the atomic file - * will return the new file stream. - */ - public void finishWrite(FileOutputStream str) { - if (str != null) { - sync(str); - try { - str.close(); - mBackupName.delete(); - } catch (IOException e) { - Log.w("AtomicFile", "finishWrite: Got exception:", e); - } - } - } - - /** - * Call when you have failed for some reason at writing to the stream - * returned by {@link #startWrite()}. This will close the current - * write stream, and roll back to the previous state of the file. - */ - public void failWrite(FileOutputStream str) { - if (str != null) { - sync(str); - try { - str.close(); - mBaseName.delete(); - mBackupName.renameTo(mBaseName); - } catch (IOException e) { - Log.w("AtomicFile", "failWrite: Got exception:", e); - } - } - } - - /** - * Open the atomic file for reading. If there previously was an - * incomplete write, this will roll back to the last good data before - * opening for read. You should call close() on the FileInputStream when - * you are done reading from it. + * Call when you have successfully finished writing to the stream returned by {@link + * #startWrite()}. This will close, sync, and commit the new data. The next attempt to read the + * atomic file will return the new file stream. * - *

Note that if another thread is currently performing - * a write, this will incorrectly consider it to be in the state of a bad - * write and roll back, causing the new data currently being written to - * be dropped. You must do your own threading protection for access to - * AtomicFile. + * @param str Outer-most wrapper OutputStream used to write to the stream returned by {@link + * #startWrite()}. + * @see #startWrite() */ - public FileInputStream openRead() throws FileNotFoundException { - if (mBackupName.exists()) { - mBaseName.delete(); - mBackupName.renameTo(mBaseName); - } - return new FileInputStream(mBaseName); + public void endWrite(OutputStream str) throws IOException { + str.close(); + // If close() throws exception, the next line is skipped. + backupName.delete(); } /** - * A convenience for {@link #openRead()} that also reads all of the - * file contents into a byte array which is returned. + * Open the atomic file for reading. If there previously was an incomplete write, this will roll + * back to the last good data before opening for read. + * + *

Note that if another thread is currently performing a write, this will incorrectly consider + * it to be in the state of a bad write and roll back, causing the new data currently being + * written to be dropped. You must do your own threading protection for access to AtomicFile. */ - public byte[] readFully() throws IOException { - FileInputStream stream = openRead(); - try { - int pos = 0; - int avail = stream.available(); - byte[] data = new byte[avail]; - while (true) { - int amt = stream.read(data, pos, data.length - pos); - //Log.i("foo", "Read " + amt + " bytes at " + pos - // + " of avail " + data.length); - if (amt <= 0) { - //Log.i("foo", "**** FINISHED READING: pos=" + pos - // + " len=" + data.length); - return data; - } - pos += amt; - avail = stream.available(); - if (avail > data.length - pos) { - byte[] newData = new byte[pos + avail]; - System.arraycopy(data, 0, newData, 0, pos); - data = newData; - } - } - } finally { - stream.close(); + public InputStream openRead() throws FileNotFoundException { + restoreBackup(); + return new FileInputStream(baseName); + } + + private void restoreBackup() { + if (backupName.exists()) { + baseName.delete(); + backupName.renameTo(baseName); } } - private static boolean sync(FileOutputStream stream) { - try { - if (stream != null) { - stream.getFD().sync(); - } - return true; - } catch (IOException e) { - // do nothing + private static final class AtomicFileOutputStream extends OutputStream { + + private final FileOutputStream fileOutputStream; + private boolean closed = false; + + public AtomicFileOutputStream(File file) throws FileNotFoundException { + fileOutputStream = new FileOutputStream(file); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + flush(); + try { + fileOutputStream.getFD().sync(); + } catch (IOException e) { + Log.w(TAG, "Failed to sync file descriptor:", e); + } + fileOutputStream.close(); + } + + @Override + public void flush() throws IOException { + fileOutputStream.flush(); + } + + @Override + public void write(int b) throws IOException { + fileOutputStream.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + fileOutputStream.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + fileOutputStream.write(b, off, len); } - return false; } }