Encrypt SimpleCache index file.

Clean up AtomicFile and make it return a custom FileOutputStream
for writing which handles IOException automatically during write
operations.

It also syncs the file descriptor and deletes the backup file on
close() call. This fixes the order of flush and close operations
when the fileoutputstream is wrapped by another OutputStream.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=138779187
This commit is contained in:
eguven 2016-11-10 10:54:13 -08:00 committed by Oliver Woodman
parent 16ddc84d93
commit 92a98d1ce2
10 changed files with 443 additions and 212 deletions

View file

@ -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<String> keys = index.getKeys();
Set<String> 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<String> keys = index.getKeys();
Set<String> 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());
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String, CachedContent> keyToContent;
private final SparseArray<String> 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);
}
}

View file

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

View file

@ -38,18 +38,33 @@ public final class SimpleCache implements Cache {
private final CachedContentIndex index;
private final HashMap<String, ArrayList<Listener>> 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<CacheSpan> 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();
}

View file

@ -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.
* <p>
* 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).
* </p><p>
* 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.
* </p>
* A helper class for performing atomic operations on a file by creating a backup file until a write
* has successfully completed.
*
* <p>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).
*
* <p>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 <em>must not</em> 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 <em>must</em> call
* {@link #endWrite(OutputStream)}. On failure you should call {@link OutputStream#close()}
* only to free up resources used by it.
*
* <p>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.
* <p>Example usage:
*
* <pre>
* 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();
* }
* }
* </pre>
*
* <p>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.
*
* <p>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.
*
* <p>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;
}
}