summaryrefslogtreecommitdiffstats
path: root/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache')
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/Cache.java286
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java210
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java45
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java580
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java112
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java47
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java28
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java252
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java29
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheSpan.java106
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheUtil.java434
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContent.java208
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java956
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java204
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java87
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java145
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java173
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java89
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/NoOpCacheEvictor.java57
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java812
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java217
21 files changed, 5077 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/Cache.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/Cache.java
new file mode 100644
index 0000000000..cb90d95bb4
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/Cache.java
@@ -0,0 +1,286 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import java.io.File;
+import java.io.IOException;
+import java.util.NavigableSet;
+import java.util.Set;
+
+/**
+ * An interface for cache.
+ */
+public interface Cache {
+
+ /**
+ * Listener of {@link Cache} events.
+ */
+ interface Listener {
+
+ /**
+ * Called when a {@link CacheSpan} is added to the cache.
+ *
+ * @param cache The source of the event.
+ * @param span The added {@link CacheSpan}.
+ */
+ void onSpanAdded(Cache cache, CacheSpan span);
+
+ /**
+ * Called when a {@link CacheSpan} is removed from the cache.
+ *
+ * @param cache The source of the event.
+ * @param span The removed {@link CacheSpan}.
+ */
+ void onSpanRemoved(Cache cache, CacheSpan span);
+
+ /**
+ * Called when an existing {@link CacheSpan} is touched, causing it to be replaced. The new
+ * {@link CacheSpan} is guaranteed to represent the same data as the one it replaces, however
+ * {@link CacheSpan#file} and {@link CacheSpan#lastTouchTimestamp} may have changed.
+ *
+ * <p>Note that for span replacement, {@link #onSpanAdded(Cache, CacheSpan)} and {@link
+ * #onSpanRemoved(Cache, CacheSpan)} are not called in addition to this method.
+ *
+ * @param cache The source of the event.
+ * @param oldSpan The old {@link CacheSpan}, which has been removed from the cache.
+ * @param newSpan The new {@link CacheSpan}, which has been added to the 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(Throwable cause) {
+ super(cause);
+ }
+
+ public CacheException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+
+ /**
+ * Returned by {@link #getUid()} if initialization failed before the unique identifier was read or
+ * generated.
+ */
+ long UID_UNSET = -1;
+
+ /**
+ * Returns a non-negative unique identifier for the cache, or {@link #UID_UNSET} if initialization
+ * failed before the unique identifier was determined.
+ *
+ * <p>Implementations are expected to generate and store the unique identifier alongside the
+ * cached content. If the location of the cache is deleted or swapped, it is expected that a new
+ * unique identifier will be generated when the cache is recreated.
+ */
+ long getUid();
+
+ /**
+ * Releases the cache. This method must be called when the cache is no longer required. The cache
+ * must not be used after calling this method.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ */
+ @WorkerThread
+ void release();
+
+ /**
+ * Registers a listener to listen for changes to a given key.
+ *
+ * <p>No guarantees are made about the thread or threads on which the listener is called, but it
+ * is guaranteed that listener methods will be called in a serial fashion (i.e. one at a time) and
+ * in the same order as events occurred.
+ *
+ * @param key The key to listen to.
+ * @param listener The listener to add.
+ * @return The current spans for the key.
+ */
+ NavigableSet<CacheSpan> addListener(String key, Listener listener);
+
+ /**
+ * Unregisters a listener.
+ *
+ * @param key The key to stop listening to.
+ * @param listener The listener to remove.
+ */
+ void removeListener(String key, Listener listener);
+
+ /**
+ * Returns the cached spans for a given cache key.
+ *
+ * @param key The key for which spans should be returned.
+ * @return The spans for the key.
+ */
+ NavigableSet<CacheSpan> getCachedSpans(String key);
+
+ /**
+ * Returns all keys in the cache.
+ *
+ * @return All the keys in the cache.
+ */
+ Set<String> getKeys();
+
+ /**
+ * Returns the total disk space in bytes used by the cache.
+ *
+ * @return The total disk space in bytes.
+ */
+ long getCacheSpace();
+
+ /**
+ * A caller should invoke this method when they require data from a given position for a given
+ * key.
+ *
+ * <p>If there is a cache entry that overlaps the position, then the returned {@link CacheSpan}
+ * defines the file in which the data is stored. {@link CacheSpan#isCached} is true. The caller
+ * may read from the cache file, but does not acquire any locks.
+ *
+ * <p>If there is no cache entry overlapping {@code offset}, then the returned {@link CacheSpan}
+ * defines a hole in the cache starting at {@code position} into which the caller may write as it
+ * obtains the data from some other source. The returned {@link CacheSpan} serves as a lock.
+ * Whilst the caller holds the lock it may write data into the hole. It may split data into
+ * multiple files. When the caller has finished writing a file it should commit it to the cache by
+ * calling {@link #commitFile(File, long)}. When the caller has finished writing, it must release
+ * the lock by calling {@link #releaseHoleSpan}.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param key The key of the data being requested.
+ * @param position The position of the data being requested.
+ * @return The {@link CacheSpan}.
+ * @throws InterruptedException If the thread was interrupted.
+ * @throws CacheException If an error is encountered.
+ */
+ @WorkerThread
+ CacheSpan startReadWrite(String key, long position) throws InterruptedException, CacheException;
+
+ /**
+ * Same as {@link #startReadWrite(String, long)}. However, if the cache entry is locked, then
+ * instead of blocking, this method will return null as the {@link CacheSpan}.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param key The key of the data being requested.
+ * @param position The position of the data being requested.
+ * @return The {@link CacheSpan}. Or null if the cache entry is locked.
+ * @throws CacheException If an error is encountered.
+ */
+ @WorkerThread
+ @Nullable
+ 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
+ * corresponding hole {@link CacheSpan} obtained from {@link #startReadWrite(String, long)}.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param key The cache key for the data.
+ * @param position The starting position of the data.
+ * @param length The length of the data being written, or {@link C#LENGTH_UNSET} if unknown. Used
+ * only to ensure that there is enough space in the cache.
+ * @return The file into which data should be written.
+ * @throws CacheException If an error is encountered.
+ */
+ @WorkerThread
+ File startFile(String key, long position, long length) throws CacheException;
+
+ /**
+ * Commits a file into the cache. Must only be called when holding a corresponding hole {@link
+ * CacheSpan} obtained from {@link #startReadWrite(String, long)}.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param file A newly written cache file.
+ * @param length The length of the newly written cache file in bytes.
+ * @throws CacheException If an error is encountered.
+ */
+ @WorkerThread
+ void commitFile(File file, long length) throws CacheException;
+
+ /**
+ * Releases a {@link CacheSpan} obtained from {@link #startReadWrite(String, long)} which
+ * corresponded to a hole in the cache.
+ *
+ * @param holeSpan The {@link CacheSpan} being released.
+ */
+ void releaseHoleSpan(CacheSpan holeSpan);
+
+ /**
+ * Removes a cached {@link CacheSpan} from the cache, deleting the underlying file.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param span The {@link CacheSpan} to remove.
+ * @throws CacheException If an error is encountered.
+ */
+ @WorkerThread
+ void removeSpan(CacheSpan span) throws CacheException;
+
+ /**
+ * Queries if a range is entirely available in the cache.
+ *
+ * @param key The cache key for the data.
+ * @param position The starting position of the data.
+ * @param length The length of the data.
+ * @return true if the data is available in the Cache otherwise false;
+ */
+ boolean isCached(String key, long position, long length);
+
+ /**
+ * Returns the length of the cached data block starting from the {@code position} to the block end
+ * up to {@code length} bytes. If the {@code position} isn't cached then -(the length of the gap
+ * to the next cached data up to {@code length} bytes) is returned.
+ *
+ * @param key The cache key for the data.
+ * @param position The starting position of the data.
+ * @param length The maximum length of the data to be returned.
+ * @return The length of the cached or not cached data block length.
+ */
+ long getCachedLength(String key, long position, long length);
+
+ /**
+ * Applies {@code mutations} to the {@link ContentMetadata} for the given key. A new {@link
+ * CachedContent} is added if there isn't one already with the given key.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param key The cache key for the data.
+ * @param mutations Contains mutations to be applied to the metadata.
+ * @throws CacheException If an error is encountered.
+ */
+ @WorkerThread
+ void applyContentMetadataMutations(String key, ContentMetadataMutations mutations)
+ throws CacheException;
+
+ /**
+ * Returns a {@link ContentMetadata} for the given key.
+ *
+ * @param key The cache key for the data.
+ * @return A {@link ContentMetadata} for the given key.
+ */
+ ContentMetadata getContentMetadata(String key);
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java
new file mode 100644
index 0000000000..e372a02851
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java
@@ -0,0 +1,210 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache.CacheException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ReusableBufferedOutputStream;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Writes data into a cache.
+ *
+ * <p>If the {@link DataSpec} passed to {@link #open(DataSpec)} has the {@code length} field set to
+ * {@link C#LENGTH_UNSET} and {@link DataSpec#FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN} set, then {@link
+ * #write(byte[], int, int)} calls are ignored.
+ */
+public final class CacheDataSink implements DataSink {
+
+ /** Default {@code fragmentSize} recommended for caching use cases. */
+ public static final long DEFAULT_FRAGMENT_SIZE = 5 * 1024 * 1024;
+ /** Default buffer size in bytes. */
+ public static final int DEFAULT_BUFFER_SIZE = 20 * 1024;
+
+ private static final long MIN_RECOMMENDED_FRAGMENT_SIZE = 2 * 1024 * 1024;
+ private static final String TAG = "CacheDataSink";
+
+ private final Cache cache;
+ private final long fragmentSize;
+ private final int bufferSize;
+
+ private DataSpec dataSpec;
+ private long dataSpecFragmentSize;
+ private File file;
+ private OutputStream outputStream;
+ private long outputStreamBytesWritten;
+ private long dataSpecBytesWritten;
+ private ReusableBufferedOutputStream bufferedOutputStream;
+
+ /**
+ * Thrown when IOException is encountered when writing data into sink.
+ */
+ public static class CacheDataSinkException extends CacheException {
+
+ public CacheDataSinkException(IOException cause) {
+ super(cause);
+ }
+
+ }
+
+ /**
+ * Constructs an instance using {@link #DEFAULT_BUFFER_SIZE}.
+ *
+ * @param cache The cache into which data should be written.
+ * @param fragmentSize For requests that should be fragmented into multiple cache files, this is
+ * the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET} then no
+ * fragmentation will occur. Using a small value allows for finer-grained cache eviction
+ * policies, at the cost of increased overhead both on the cache implementation and the file
+ * system. Values under {@code (2 * 1024 * 1024)} are not recommended.
+ */
+ public CacheDataSink(Cache cache, long fragmentSize) {
+ this(cache, fragmentSize, DEFAULT_BUFFER_SIZE);
+ }
+
+ /**
+ * @param cache The cache into which data should be written.
+ * @param fragmentSize For requests that should be fragmented into multiple cache files, this is
+ * the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET} then no
+ * fragmentation will occur. Using a small value allows for finer-grained cache eviction
+ * policies, at the cost of increased overhead both on the cache implementation and the file
+ * system. Values under {@code (2 * 1024 * 1024)} are not recommended.
+ * @param bufferSize The buffer size in bytes for writing to a cache file. A zero or negative
+ * value disables buffering.
+ */
+ public CacheDataSink(Cache cache, long fragmentSize, int bufferSize) {
+ Assertions.checkState(
+ fragmentSize > 0 || fragmentSize == C.LENGTH_UNSET,
+ "fragmentSize must be positive or C.LENGTH_UNSET.");
+ if (fragmentSize != C.LENGTH_UNSET && fragmentSize < MIN_RECOMMENDED_FRAGMENT_SIZE) {
+ Log.w(
+ TAG,
+ "fragmentSize is below the minimum recommended value of "
+ + MIN_RECOMMENDED_FRAGMENT_SIZE
+ + ". This may cause poor cache performance.");
+ }
+ this.cache = Assertions.checkNotNull(cache);
+ this.fragmentSize = fragmentSize == C.LENGTH_UNSET ? Long.MAX_VALUE : fragmentSize;
+ this.bufferSize = bufferSize;
+ }
+
+ @Override
+ public void open(DataSpec dataSpec) throws CacheDataSinkException {
+ if (dataSpec.length == C.LENGTH_UNSET
+ && dataSpec.isFlagSet(DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN)) {
+ this.dataSpec = null;
+ return;
+ }
+ this.dataSpec = dataSpec;
+ this.dataSpecFragmentSize =
+ dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) ? fragmentSize : Long.MAX_VALUE;
+ dataSpecBytesWritten = 0;
+ try {
+ openNextOutputStream();
+ } catch (IOException e) {
+ throw new CacheDataSinkException(e);
+ }
+ }
+
+ @Override
+ public void write(byte[] buffer, int offset, int length) throws CacheDataSinkException {
+ if (dataSpec == null) {
+ return;
+ }
+ try {
+ int bytesWritten = 0;
+ while (bytesWritten < length) {
+ if (outputStreamBytesWritten == dataSpecFragmentSize) {
+ closeCurrentOutputStream();
+ openNextOutputStream();
+ }
+ int bytesToWrite =
+ (int) Math.min(length - bytesWritten, dataSpecFragmentSize - outputStreamBytesWritten);
+ outputStream.write(buffer, offset + bytesWritten, bytesToWrite);
+ bytesWritten += bytesToWrite;
+ outputStreamBytesWritten += bytesToWrite;
+ dataSpecBytesWritten += bytesToWrite;
+ }
+ } catch (IOException e) {
+ throw new CacheDataSinkException(e);
+ }
+ }
+
+ @Override
+ public void close() throws CacheDataSinkException {
+ if (dataSpec == null) {
+ return;
+ }
+ try {
+ closeCurrentOutputStream();
+ } catch (IOException e) {
+ throw new CacheDataSinkException(e);
+ }
+ }
+
+ private void openNextOutputStream() throws IOException {
+ long length =
+ dataSpec.length == C.LENGTH_UNSET
+ ? C.LENGTH_UNSET
+ : Math.min(dataSpec.length - dataSpecBytesWritten, dataSpecFragmentSize);
+ file =
+ cache.startFile(
+ dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten, length);
+ FileOutputStream underlyingFileOutputStream = new FileOutputStream(file);
+ if (bufferSize > 0) {
+ if (bufferedOutputStream == null) {
+ bufferedOutputStream = new ReusableBufferedOutputStream(underlyingFileOutputStream,
+ bufferSize);
+ } else {
+ bufferedOutputStream.reset(underlyingFileOutputStream);
+ }
+ outputStream = bufferedOutputStream;
+ } else {
+ outputStream = underlyingFileOutputStream;
+ }
+ outputStreamBytesWritten = 0;
+ }
+
+ private void closeCurrentOutputStream() throws IOException {
+ if (outputStream == null) {
+ return;
+ }
+
+ boolean success = false;
+ try {
+ outputStream.flush();
+ success = true;
+ } finally {
+ Util.closeQuietly(outputStream);
+ outputStream = null;
+ File fileToCommit = file;
+ file = null;
+ if (success) {
+ cache.commitFile(fileToCommit, outputStreamBytesWritten);
+ } else {
+ fileToCommit.delete();
+ }
+ }
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java
new file mode 100644
index 0000000000..51ba6f4294
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java
@@ -0,0 +1,45 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink;
+
+/**
+ * A {@link DataSink.Factory} that produces {@link CacheDataSink}.
+ */
+public final class CacheDataSinkFactory implements DataSink.Factory {
+
+ private final Cache cache;
+ private final long fragmentSize;
+ private final int bufferSize;
+
+ /** @see CacheDataSink#CacheDataSink(Cache, long) */
+ public CacheDataSinkFactory(Cache cache, long fragmentSize) {
+ this(cache, fragmentSize, CacheDataSink.DEFAULT_BUFFER_SIZE);
+ }
+
+ /** @see CacheDataSink#CacheDataSink(Cache, long, int) */
+ public CacheDataSinkFactory(Cache cache, long fragmentSize, int bufferSize) {
+ this.cache = cache;
+ this.fragmentSize = fragmentSize;
+ this.bufferSize = bufferSize;
+ }
+
+ @Override
+ public DataSink createDataSink() {
+ return new CacheDataSink(cache, fragmentSize, bufferSize);
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java
new file mode 100644
index 0000000000..19fb8191e4
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java
@@ -0,0 +1,580 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import android.net.Uri;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSourceException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec.HttpMethod;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.FileDataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TeeDataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache.CacheException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A {@link DataSource} that reads and writes a {@link Cache}. Requests are fulfilled from the cache
+ * when possible. When data is not cached it is requested from an upstream {@link DataSource} and
+ * written into the cache.
+ */
+public final class CacheDataSource implements DataSource {
+
+ /**
+ * Flags controlling the CacheDataSource's behavior. Possible flag values are {@link
+ * #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} and {@link
+ * #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ FLAG_BLOCK_ON_CACHE,
+ FLAG_IGNORE_CACHE_ON_ERROR,
+ FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS
+ })
+ public @interface Flags {}
+ /**
+ * A flag indicating whether we will block reads if the cache key is locked. If unset then data is
+ * read from upstream if the cache key is locked, regardless of whether the data is cached.
+ */
+ public static final int FLAG_BLOCK_ON_CACHE = 1;
+
+ /**
+ * A flag indicating whether the cache is bypassed following any cache related error. If set
+ * then cache related exceptions may be thrown for one cycle of open, read and close calls.
+ * Subsequent cycles of these calls will then bypass the cache.
+ */
+ public static final int FLAG_IGNORE_CACHE_ON_ERROR = 1 << 1; // 2
+
+ /**
+ * A flag indicating that the cache should be bypassed for requests whose lengths are unset. This
+ * flag is provided for legacy reasons only.
+ */
+ public static final int FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS = 1 << 2; // 4
+
+ /**
+ * Reasons the cache may be ignored. One of {@link #CACHE_IGNORED_REASON_ERROR} or {@link
+ * #CACHE_IGNORED_REASON_UNSET_LENGTH}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({CACHE_IGNORED_REASON_ERROR, CACHE_IGNORED_REASON_UNSET_LENGTH})
+ public @interface CacheIgnoredReason {}
+
+ /** Cache not ignored. */
+ private static final int CACHE_NOT_IGNORED = -1;
+
+ /** Cache ignored due to a cache related error. */
+ public static final int CACHE_IGNORED_REASON_ERROR = 0;
+
+ /** Cache ignored due to a request with an unset length. */
+ public static final int CACHE_IGNORED_REASON_UNSET_LENGTH = 1;
+
+ /**
+ * Listener of {@link CacheDataSource} events.
+ */
+ public interface EventListener {
+
+ /**
+ * Called when bytes have been read from the cache.
+ *
+ * @param cacheSizeBytes Current cache size in bytes.
+ * @param cachedBytesRead Total bytes read from the cache since this method was last called.
+ */
+ void onCachedBytesRead(long cacheSizeBytes, long cachedBytesRead);
+
+ /**
+ * Called when the current request ignores cache.
+ *
+ * @param reason Reason cache is bypassed.
+ */
+ void onCacheIgnored(@CacheIgnoredReason int reason);
+ }
+
+ /** Minimum number of bytes to read before checking cache for availability. */
+ private static final long MIN_READ_BEFORE_CHECKING_CACHE = 100 * 1024;
+
+ private final Cache cache;
+ private final DataSource cacheReadDataSource;
+ @Nullable private final DataSource cacheWriteDataSource;
+ private final DataSource upstreamDataSource;
+ private final CacheKeyFactory cacheKeyFactory;
+ @Nullable private final EventListener eventListener;
+
+ private final boolean blockOnCache;
+ private final boolean ignoreCacheOnError;
+ private final boolean ignoreCacheForUnsetLengthRequests;
+
+ @Nullable private DataSource currentDataSource;
+ private boolean currentDataSpecLengthUnset;
+ @Nullable private Uri uri;
+ @Nullable private Uri actualUri;
+ @HttpMethod private int httpMethod;
+ @Nullable private byte[] httpBody;
+ private Map<String, String> httpRequestHeaders = Collections.emptyMap();
+ @DataSpec.Flags private int flags;
+ @Nullable private String key;
+ private long readPosition;
+ private long bytesRemaining;
+ @Nullable private CacheSpan currentHoleSpan;
+ private boolean seenCacheError;
+ private boolean currentRequestIgnoresCache;
+ private long totalCachedBytesRead;
+ private long checkCachePosition;
+
+ /**
+ * Constructs an instance with default {@link DataSource} and {@link DataSink} instances for
+ * reading and writing the cache.
+ *
+ * @param cache The cache.
+ * @param upstream A {@link DataSource} for reading data not in the cache.
+ */
+ public CacheDataSource(Cache cache, DataSource upstream) {
+ this(cache, upstream, /* flags= */ 0);
+ }
+
+ /**
+ * Constructs an instance with default {@link DataSource} and {@link DataSink} instances for
+ * reading and writing the cache.
+ *
+ * @param cache The cache.
+ * @param upstream A {@link DataSource} for reading data not in the cache.
+ * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR}
+ * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0.
+ */
+ public CacheDataSource(Cache cache, DataSource upstream, @Flags int flags) {
+ this(
+ cache,
+ upstream,
+ new FileDataSource(),
+ new CacheDataSink(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE),
+ flags,
+ /* eventListener= */ null);
+ }
+
+ /**
+ * Constructs an instance with arbitrary {@link DataSource} and {@link DataSink} instances for
+ * reading and writing the cache. One use of this constructor is to allow data to be transformed
+ * before it is written to disk.
+ *
+ * @param cache The cache.
+ * @param upstream A {@link DataSource} for reading data not in the cache.
+ * @param cacheReadDataSource A {@link DataSource} for reading data from the cache.
+ * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is
+ * accessed read-only.
+ * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR}
+ * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0.
+ * @param eventListener An optional {@link EventListener} to receive events.
+ */
+ public CacheDataSource(
+ Cache cache,
+ DataSource upstream,
+ DataSource cacheReadDataSource,
+ @Nullable DataSink cacheWriteDataSink,
+ @Flags int flags,
+ @Nullable EventListener eventListener) {
+ this(
+ cache,
+ upstream,
+ cacheReadDataSource,
+ cacheWriteDataSink,
+ flags,
+ eventListener,
+ /* cacheKeyFactory= */ null);
+ }
+
+ /**
+ * Constructs an instance with arbitrary {@link DataSource} and {@link DataSink} instances for
+ * reading and writing the cache. One use of this constructor is to allow data to be transformed
+ * before it is written to disk.
+ *
+ * @param cache The cache.
+ * @param upstream A {@link DataSource} for reading data not in the cache.
+ * @param cacheReadDataSource A {@link DataSource} for reading data from the cache.
+ * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is
+ * accessed read-only.
+ * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR}
+ * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0.
+ * @param eventListener An optional {@link EventListener} to receive events.
+ * @param cacheKeyFactory An optional factory for cache keys.
+ */
+ public CacheDataSource(
+ Cache cache,
+ DataSource upstream,
+ DataSource cacheReadDataSource,
+ @Nullable DataSink cacheWriteDataSink,
+ @Flags int flags,
+ @Nullable EventListener eventListener,
+ @Nullable CacheKeyFactory cacheKeyFactory) {
+ this.cache = cache;
+ this.cacheReadDataSource = cacheReadDataSource;
+ this.cacheKeyFactory =
+ cacheKeyFactory != null ? cacheKeyFactory : CacheUtil.DEFAULT_CACHE_KEY_FACTORY;
+ this.blockOnCache = (flags & FLAG_BLOCK_ON_CACHE) != 0;
+ this.ignoreCacheOnError = (flags & FLAG_IGNORE_CACHE_ON_ERROR) != 0;
+ this.ignoreCacheForUnsetLengthRequests =
+ (flags & FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS) != 0;
+ this.upstreamDataSource = upstream;
+ if (cacheWriteDataSink != null) {
+ this.cacheWriteDataSource = new TeeDataSource(upstream, cacheWriteDataSink);
+ } else {
+ this.cacheWriteDataSource = null;
+ }
+ this.eventListener = eventListener;
+ }
+
+ @Override
+ public void addTransferListener(TransferListener transferListener) {
+ cacheReadDataSource.addTransferListener(transferListener);
+ upstreamDataSource.addTransferListener(transferListener);
+ }
+
+ @Override
+ public long open(DataSpec dataSpec) throws IOException {
+ try {
+ key = cacheKeyFactory.buildCacheKey(dataSpec);
+ uri = dataSpec.uri;
+ actualUri = getRedirectedUriOrDefault(cache, key, /* defaultUri= */ uri);
+ httpMethod = dataSpec.httpMethod;
+ httpBody = dataSpec.httpBody;
+ httpRequestHeaders = dataSpec.httpRequestHeaders;
+ flags = dataSpec.flags;
+ readPosition = dataSpec.position;
+
+ int reason = shouldIgnoreCacheForRequest(dataSpec);
+ currentRequestIgnoresCache = reason != CACHE_NOT_IGNORED;
+ if (currentRequestIgnoresCache) {
+ notifyCacheIgnored(reason);
+ }
+
+ if (dataSpec.length != C.LENGTH_UNSET || currentRequestIgnoresCache) {
+ bytesRemaining = dataSpec.length;
+ } else {
+ bytesRemaining = ContentMetadata.getContentLength(cache.getContentMetadata(key));
+ if (bytesRemaining != C.LENGTH_UNSET) {
+ bytesRemaining -= dataSpec.position;
+ if (bytesRemaining <= 0) {
+ throw new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE);
+ }
+ }
+ }
+ openNextSource(false);
+ return bytesRemaining;
+ } catch (Throwable e) {
+ handleBeforeThrow(e);
+ throw e;
+ }
+ }
+
+ @Override
+ public int read(byte[] buffer, int offset, int readLength) throws IOException {
+ if (readLength == 0) {
+ return 0;
+ }
+ if (bytesRemaining == 0) {
+ return C.RESULT_END_OF_INPUT;
+ }
+ try {
+ if (readPosition >= checkCachePosition) {
+ openNextSource(true);
+ }
+ int bytesRead = currentDataSource.read(buffer, offset, readLength);
+ if (bytesRead != C.RESULT_END_OF_INPUT) {
+ if (isReadingFromCache()) {
+ totalCachedBytesRead += bytesRead;
+ }
+ readPosition += bytesRead;
+ if (bytesRemaining != C.LENGTH_UNSET) {
+ bytesRemaining -= bytesRead;
+ }
+ } else if (currentDataSpecLengthUnset) {
+ setNoBytesRemainingAndMaybeStoreLength();
+ } else if (bytesRemaining > 0 || bytesRemaining == C.LENGTH_UNSET) {
+ closeCurrentSource();
+ openNextSource(false);
+ return read(buffer, offset, readLength);
+ }
+ return bytesRead;
+ } catch (IOException e) {
+ if (currentDataSpecLengthUnset && CacheUtil.isCausedByPositionOutOfRange(e)) {
+ setNoBytesRemainingAndMaybeStoreLength();
+ return C.RESULT_END_OF_INPUT;
+ }
+ handleBeforeThrow(e);
+ throw e;
+ } catch (Throwable e) {
+ handleBeforeThrow(e);
+ throw e;
+ }
+ }
+
+ @Override
+ @Nullable
+ public Uri getUri() {
+ return actualUri;
+ }
+
+ @Override
+ public Map<String, List<String>> getResponseHeaders() {
+ // TODO: Implement.
+ return isReadingFromUpstream()
+ ? upstreamDataSource.getResponseHeaders()
+ : Collections.emptyMap();
+ }
+
+ @Override
+ public void close() throws IOException {
+ uri = null;
+ actualUri = null;
+ httpMethod = DataSpec.HTTP_METHOD_GET;
+ httpBody = null;
+ httpRequestHeaders = Collections.emptyMap();
+ flags = 0;
+ readPosition = 0;
+ key = null;
+ notifyBytesRead();
+ try {
+ closeCurrentSource();
+ } catch (Throwable e) {
+ handleBeforeThrow(e);
+ throw e;
+ }
+ }
+
+ /**
+ * Opens the next source. If the cache contains data spanning the current read position then
+ * {@link #cacheReadDataSource} is opened to read from it. Else {@link #upstreamDataSource} is
+ * opened to read from the upstream source and write into the cache.
+ *
+ * <p>There must not be a currently open source when this method is called, except in the case
+ * that {@code checkCache} is true. If {@code checkCache} is true then there must be a currently
+ * open source, and it must be {@link #upstreamDataSource}. It will be closed and a new source
+ * opened if it's possible to switch to reading from or writing to the cache. If a switch isn't
+ * possible then the current source is left unchanged.
+ *
+ * @param checkCache If true tries to switch to reading from or writing to cache instead of
+ * reading from {@link #upstreamDataSource}, which is the currently open source.
+ */
+ private void openNextSource(boolean checkCache) throws IOException {
+ CacheSpan nextSpan;
+ if (currentRequestIgnoresCache) {
+ nextSpan = null;
+ } else if (blockOnCache) {
+ try {
+ nextSpan = cache.startReadWrite(key, readPosition);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new InterruptedIOException();
+ }
+ } else {
+ nextSpan = cache.startReadWriteNonBlocking(key, readPosition);
+ }
+
+ DataSpec nextDataSpec;
+ DataSource nextDataSource;
+ if (nextSpan == null) {
+ // The data is locked in the cache, or we're ignoring the cache. Bypass the cache and read
+ // from upstream.
+ nextDataSource = upstreamDataSource;
+ nextDataSpec =
+ new DataSpec(
+ uri,
+ httpMethod,
+ httpBody,
+ readPosition,
+ readPosition,
+ bytesRemaining,
+ key,
+ flags,
+ httpRequestHeaders);
+ } else if (nextSpan.isCached) {
+ // Data is cached, read from cache.
+ Uri fileUri = Uri.fromFile(nextSpan.file);
+ long filePosition = readPosition - nextSpan.position;
+ long length = nextSpan.length - filePosition;
+ if (bytesRemaining != C.LENGTH_UNSET) {
+ length = Math.min(length, bytesRemaining);
+ }
+ // Deliberately skip the HTTP-related parameters since we're reading from the cache, not
+ // making an HTTP request.
+ nextDataSpec = new DataSpec(fileUri, readPosition, filePosition, length, key, flags);
+ nextDataSource = cacheReadDataSource;
+ } else {
+ // Data is not cached, and data is not locked, read from upstream with cache backing.
+ long length;
+ if (nextSpan.isOpenEnded()) {
+ length = bytesRemaining;
+ } else {
+ length = nextSpan.length;
+ if (bytesRemaining != C.LENGTH_UNSET) {
+ length = Math.min(length, bytesRemaining);
+ }
+ }
+ nextDataSpec =
+ new DataSpec(
+ uri,
+ httpMethod,
+ httpBody,
+ readPosition,
+ readPosition,
+ length,
+ key,
+ flags,
+ httpRequestHeaders);
+ if (cacheWriteDataSource != null) {
+ nextDataSource = cacheWriteDataSource;
+ } else {
+ nextDataSource = upstreamDataSource;
+ cache.releaseHoleSpan(nextSpan);
+ nextSpan = null;
+ }
+ }
+
+ checkCachePosition =
+ !currentRequestIgnoresCache && nextDataSource == upstreamDataSource
+ ? readPosition + MIN_READ_BEFORE_CHECKING_CACHE
+ : Long.MAX_VALUE;
+ if (checkCache) {
+ Assertions.checkState(isBypassingCache());
+ if (nextDataSource == upstreamDataSource) {
+ // Continue reading from upstream.
+ return;
+ }
+ // We're switching to reading from or writing to the cache.
+ try {
+ closeCurrentSource();
+ } catch (Throwable e) {
+ if (nextSpan.isHoleSpan()) {
+ // Release the hole span before throwing, else we'll hold it forever.
+ cache.releaseHoleSpan(nextSpan);
+ }
+ throw e;
+ }
+ }
+
+ if (nextSpan != null && nextSpan.isHoleSpan()) {
+ currentHoleSpan = nextSpan;
+ }
+ currentDataSource = nextDataSource;
+ currentDataSpecLengthUnset = nextDataSpec.length == C.LENGTH_UNSET;
+ long resolvedLength = nextDataSource.open(nextDataSpec);
+
+ // Update bytesRemaining, actualUri and (if writing to cache) the cache metadata.
+ ContentMetadataMutations mutations = new ContentMetadataMutations();
+ if (currentDataSpecLengthUnset && resolvedLength != C.LENGTH_UNSET) {
+ bytesRemaining = resolvedLength;
+ ContentMetadataMutations.setContentLength(mutations, readPosition + bytesRemaining);
+ }
+ if (isReadingFromUpstream()) {
+ actualUri = currentDataSource.getUri();
+ boolean isRedirected = !uri.equals(actualUri);
+ ContentMetadataMutations.setRedirectedUri(mutations, isRedirected ? actualUri : null);
+ }
+ if (isWritingToCache()) {
+ cache.applyContentMetadataMutations(key, mutations);
+ }
+ }
+
+ private void setNoBytesRemainingAndMaybeStoreLength() throws IOException {
+ bytesRemaining = 0;
+ if (isWritingToCache()) {
+ ContentMetadataMutations mutations = new ContentMetadataMutations();
+ ContentMetadataMutations.setContentLength(mutations, readPosition);
+ cache.applyContentMetadataMutations(key, mutations);
+ }
+ }
+
+ private static Uri getRedirectedUriOrDefault(Cache cache, String key, Uri defaultUri) {
+ Uri redirectedUri = ContentMetadata.getRedirectedUri(cache.getContentMetadata(key));
+ return redirectedUri != null ? redirectedUri : defaultUri;
+ }
+
+ private boolean isReadingFromUpstream() {
+ return !isReadingFromCache();
+ }
+
+ private boolean isBypassingCache() {
+ return currentDataSource == upstreamDataSource;
+ }
+
+ private boolean isReadingFromCache() {
+ return currentDataSource == cacheReadDataSource;
+ }
+
+ private boolean isWritingToCache() {
+ return currentDataSource == cacheWriteDataSource;
+ }
+
+ private void closeCurrentSource() throws IOException {
+ if (currentDataSource == null) {
+ return;
+ }
+ try {
+ currentDataSource.close();
+ } finally {
+ currentDataSource = null;
+ currentDataSpecLengthUnset = false;
+ if (currentHoleSpan != null) {
+ cache.releaseHoleSpan(currentHoleSpan);
+ currentHoleSpan = null;
+ }
+ }
+ }
+
+ private void handleBeforeThrow(Throwable exception) {
+ if (isReadingFromCache() || exception instanceof CacheException) {
+ seenCacheError = true;
+ }
+ }
+
+ private int shouldIgnoreCacheForRequest(DataSpec dataSpec) {
+ if (ignoreCacheOnError && seenCacheError) {
+ return CACHE_IGNORED_REASON_ERROR;
+ } else if (ignoreCacheForUnsetLengthRequests && dataSpec.length == C.LENGTH_UNSET) {
+ return CACHE_IGNORED_REASON_UNSET_LENGTH;
+ } else {
+ return CACHE_NOT_IGNORED;
+ }
+ }
+
+ private void notifyCacheIgnored(@CacheIgnoredReason int reason) {
+ if (eventListener != null) {
+ eventListener.onCacheIgnored(reason);
+ }
+ }
+
+ private void notifyBytesRead() {
+ if (eventListener != null && totalCachedBytesRead > 0) {
+ eventListener.onCachedBytesRead(cache.getCacheSpace(), totalCachedBytesRead);
+ totalCachedBytesRead = 0;
+ }
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java
new file mode 100644
index 0000000000..21aef3f93a
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java
@@ -0,0 +1,112 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.FileDataSource;
+
+/** A {@link DataSource.Factory} that produces {@link CacheDataSource}. */
+public final class CacheDataSourceFactory implements DataSource.Factory {
+
+ private final Cache cache;
+ private final DataSource.Factory upstreamFactory;
+ private final DataSource.Factory cacheReadDataSourceFactory;
+ @CacheDataSource.Flags private final int flags;
+ @Nullable private final DataSink.Factory cacheWriteDataSinkFactory;
+ @Nullable private final CacheDataSource.EventListener eventListener;
+ @Nullable private final CacheKeyFactory cacheKeyFactory;
+
+ /**
+ * Constructs a factory which creates {@link CacheDataSource} instances with default {@link
+ * DataSource} and {@link DataSink} instances for reading and writing the cache.
+ *
+ * @param cache The cache.
+ * @param upstreamFactory A {@link DataSource.Factory} for creating upstream {@link DataSource}s
+ * for reading data not in the cache.
+ */
+ public CacheDataSourceFactory(Cache cache, DataSource.Factory upstreamFactory) {
+ this(cache, upstreamFactory, /* flags= */ 0);
+ }
+
+ /** @see CacheDataSource#CacheDataSource(Cache, DataSource, int) */
+ public CacheDataSourceFactory(
+ Cache cache, DataSource.Factory upstreamFactory, @CacheDataSource.Flags int flags) {
+ this(
+ cache,
+ upstreamFactory,
+ new FileDataSource.Factory(),
+ new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE),
+ flags,
+ /* eventListener= */ null);
+ }
+
+ /**
+ * @see CacheDataSource#CacheDataSource(Cache, DataSource, DataSource, DataSink, int,
+ * CacheDataSource.EventListener)
+ */
+ public CacheDataSourceFactory(
+ Cache cache,
+ DataSource.Factory upstreamFactory,
+ DataSource.Factory cacheReadDataSourceFactory,
+ @Nullable DataSink.Factory cacheWriteDataSinkFactory,
+ @CacheDataSource.Flags int flags,
+ @Nullable CacheDataSource.EventListener eventListener) {
+ this(
+ cache,
+ upstreamFactory,
+ cacheReadDataSourceFactory,
+ cacheWriteDataSinkFactory,
+ flags,
+ eventListener,
+ /* cacheKeyFactory= */ null);
+ }
+
+ /**
+ * @see CacheDataSource#CacheDataSource(Cache, DataSource, DataSource, DataSink, int,
+ * CacheDataSource.EventListener, CacheKeyFactory)
+ */
+ public CacheDataSourceFactory(
+ Cache cache,
+ DataSource.Factory upstreamFactory,
+ DataSource.Factory cacheReadDataSourceFactory,
+ @Nullable DataSink.Factory cacheWriteDataSinkFactory,
+ @CacheDataSource.Flags int flags,
+ @Nullable CacheDataSource.EventListener eventListener,
+ @Nullable CacheKeyFactory cacheKeyFactory) {
+ this.cache = cache;
+ this.upstreamFactory = upstreamFactory;
+ this.cacheReadDataSourceFactory = cacheReadDataSourceFactory;
+ this.cacheWriteDataSinkFactory = cacheWriteDataSinkFactory;
+ this.flags = flags;
+ this.eventListener = eventListener;
+ this.cacheKeyFactory = cacheKeyFactory;
+ }
+
+ @Override
+ public CacheDataSource createDataSource() {
+ return new CacheDataSource(
+ cache,
+ upstreamFactory.createDataSource(),
+ cacheReadDataSourceFactory.createDataSource(),
+ cacheWriteDataSinkFactory == null ? null : cacheWriteDataSinkFactory.createDataSink(),
+ flags,
+ eventListener,
+ cacheKeyFactory);
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java
new file mode 100644
index 0000000000..017e84c8c8
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java
@@ -0,0 +1,47 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+
+/**
+ * Evicts data from a {@link Cache}. Implementations should call {@link Cache#removeSpan(CacheSpan)}
+ * to evict cache entries based on their eviction policies.
+ */
+public interface CacheEvictor extends Cache.Listener {
+
+ /**
+ * Returns whether the evictor requires the {@link Cache} to touch {@link CacheSpan CacheSpans}
+ * when it accesses them. Implementations that do not use {@link CacheSpan#lastTouchTimestamp}
+ * should return {@code false}.
+ */
+ boolean requiresCacheSpanTouches();
+
+ /**
+ * Called when cache has been initialized.
+ */
+ void onCacheInitialized();
+
+ /**
+ * Called when a writer starts writing to the cache.
+ *
+ * @param cache The source of the event.
+ * @param key The key being written.
+ * @param position The starting position of the data being written.
+ * @param length The length of the data being written, or {@link C#LENGTH_UNSET} if unknown.
+ */
+ void onStartFile(Cache cache, String key, long position, long length);
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java
new file mode 100644
index 0000000000..2618a3ef6a
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+/** Metadata associated with a cache file. */
+/* package */ final class CacheFileMetadata {
+
+ public final long length;
+ public final long lastTouchTimestamp;
+
+ public CacheFileMetadata(long length, long lastTouchTimestamp) {
+ this.length = length;
+ this.lastTouchTimestamp = lastTouchTimestamp;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java
new file mode 100644
index 0000000000..cd69336ff4
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import androidx.annotation.WorkerThread;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseIOException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.VersionTable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/** Maintains an index of cache file metadata. */
+/* package */ final class CacheFileMetadataIndex {
+
+ private static final String TABLE_PREFIX = DatabaseProvider.TABLE_PREFIX + "CacheFileMetadata";
+ private static final int TABLE_VERSION = 1;
+
+ private static final String COLUMN_NAME = "name";
+ private static final String COLUMN_LENGTH = "length";
+ private static final String COLUMN_LAST_TOUCH_TIMESTAMP = "last_touch_timestamp";
+
+ private static final int COLUMN_INDEX_NAME = 0;
+ private static final int COLUMN_INDEX_LENGTH = 1;
+ private static final int COLUMN_INDEX_LAST_TOUCH_TIMESTAMP = 2;
+
+ private static final String WHERE_NAME_EQUALS = COLUMN_NAME + " = ?";
+
+ private static final String[] COLUMNS =
+ new String[] {
+ COLUMN_NAME, COLUMN_LENGTH, COLUMN_LAST_TOUCH_TIMESTAMP,
+ };
+ private static final String TABLE_SCHEMA =
+ "("
+ + COLUMN_NAME
+ + " TEXT PRIMARY KEY NOT NULL,"
+ + COLUMN_LENGTH
+ + " INTEGER NOT NULL,"
+ + COLUMN_LAST_TOUCH_TIMESTAMP
+ + " INTEGER NOT NULL)";
+
+ private final DatabaseProvider databaseProvider;
+
+ private @MonotonicNonNull String tableName;
+
+ /**
+ * Deletes index data for the specified cache.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param databaseProvider Provides the database in which the index is stored.
+ * @param uid The cache UID.
+ * @throws DatabaseIOException If an error occurs deleting the index data.
+ */
+ @WorkerThread
+ public static void delete(DatabaseProvider databaseProvider, long uid)
+ throws DatabaseIOException {
+ String hexUid = Long.toHexString(uid);
+ try {
+ String tableName = getTableName(hexUid);
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.beginTransactionNonExclusive();
+ try {
+ VersionTable.removeVersion(
+ writableDatabase, VersionTable.FEATURE_CACHE_FILE_METADATA, hexUid);
+ dropTable(writableDatabase, tableName);
+ writableDatabase.setTransactionSuccessful();
+ } finally {
+ writableDatabase.endTransaction();
+ }
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ /** @param databaseProvider Provides the database in which the index is stored. */
+ public CacheFileMetadataIndex(DatabaseProvider databaseProvider) {
+ this.databaseProvider = databaseProvider;
+ }
+
+ /**
+ * Initializes the index for the given cache UID.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param uid The cache UID.
+ * @throws DatabaseIOException If an error occurs initializing the index.
+ */
+ @WorkerThread
+ public void initialize(long uid) throws DatabaseIOException {
+ try {
+ String hexUid = Long.toHexString(uid);
+ tableName = getTableName(hexUid);
+ SQLiteDatabase readableDatabase = databaseProvider.getReadableDatabase();
+ int version =
+ VersionTable.getVersion(
+ readableDatabase, VersionTable.FEATURE_CACHE_FILE_METADATA, hexUid);
+ if (version != TABLE_VERSION) {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.beginTransactionNonExclusive();
+ try {
+ VersionTable.setVersion(
+ writableDatabase, VersionTable.FEATURE_CACHE_FILE_METADATA, hexUid, TABLE_VERSION);
+ dropTable(writableDatabase, tableName);
+ writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA);
+ writableDatabase.setTransactionSuccessful();
+ } finally {
+ writableDatabase.endTransaction();
+ }
+ }
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ /**
+ * Returns all file metadata keyed by file name. The returned map is mutable and may be modified
+ * by the caller.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @return The file metadata keyed by file name.
+ * @throws DatabaseIOException If an error occurs loading the metadata.
+ */
+ @WorkerThread
+ public Map<String, CacheFileMetadata> getAll() throws DatabaseIOException {
+ try (Cursor cursor = getCursor()) {
+ Map<String, CacheFileMetadata> fileMetadata = new HashMap<>(cursor.getCount());
+ while (cursor.moveToNext()) {
+ String name = cursor.getString(COLUMN_INDEX_NAME);
+ long length = cursor.getLong(COLUMN_INDEX_LENGTH);
+ long lastTouchTimestamp = cursor.getLong(COLUMN_INDEX_LAST_TOUCH_TIMESTAMP);
+ fileMetadata.put(name, new CacheFileMetadata(length, lastTouchTimestamp));
+ }
+ return fileMetadata;
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ /**
+ * Sets metadata for a given file.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param name The name of the file.
+ * @param length The file length.
+ * @param lastTouchTimestamp The file last touch timestamp.
+ * @throws DatabaseIOException If an error occurs setting the metadata.
+ */
+ @WorkerThread
+ public void set(String name, long length, long lastTouchTimestamp) throws DatabaseIOException {
+ Assertions.checkNotNull(tableName);
+ try {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ ContentValues values = new ContentValues();
+ values.put(COLUMN_NAME, name);
+ values.put(COLUMN_LENGTH, length);
+ values.put(COLUMN_LAST_TOUCH_TIMESTAMP, lastTouchTimestamp);
+ writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values);
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ /**
+ * Removes metadata.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param name The name of the file whose metadata is to be removed.
+ * @throws DatabaseIOException If an error occurs removing the metadata.
+ */
+ @WorkerThread
+ public void remove(String name) throws DatabaseIOException {
+ Assertions.checkNotNull(tableName);
+ try {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.delete(tableName, WHERE_NAME_EQUALS, new String[] {name});
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ /**
+ * Removes metadata.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param names The names of the files whose metadata is to be removed.
+ * @throws DatabaseIOException If an error occurs removing the metadata.
+ */
+ @WorkerThread
+ public void removeAll(Set<String> names) throws DatabaseIOException {
+ Assertions.checkNotNull(tableName);
+ try {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.beginTransactionNonExclusive();
+ try {
+ for (String name : names) {
+ writableDatabase.delete(tableName, WHERE_NAME_EQUALS, new String[] {name});
+ }
+ writableDatabase.setTransactionSuccessful();
+ } finally {
+ writableDatabase.endTransaction();
+ }
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ private Cursor getCursor() {
+ Assertions.checkNotNull(tableName);
+ return databaseProvider
+ .getReadableDatabase()
+ .query(
+ tableName,
+ COLUMNS,
+ /* selection */ null,
+ /* selectionArgs= */ null,
+ /* groupBy= */ null,
+ /* having= */ null,
+ /* orderBy= */ null);
+ }
+
+ private static void dropTable(SQLiteDatabase writableDatabase, String tableName) {
+ writableDatabase.execSQL("DROP TABLE IF EXISTS " + tableName);
+ }
+
+ private static String getTableName(String hexUid) {
+ return TABLE_PREFIX + hexUid;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java
new file mode 100644
index 0000000000..1c30a4b03e
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+
+/** Factory for cache keys. */
+public interface CacheKeyFactory {
+
+ /**
+ * Returns a cache key for the given {@link DataSpec}.
+ *
+ * @param dataSpec The data being cached.
+ */
+ String buildCacheKey(DataSpec dataSpec);
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheSpan.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheSpan.java
new file mode 100644
index 0000000000..f57544f12b
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheSpan.java
@@ -0,0 +1,106 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import java.io.File;
+
+/**
+ * Defines a span of data that may or may not be cached (as indicated by {@link #isCached}).
+ */
+public class CacheSpan implements Comparable<CacheSpan> {
+
+ /**
+ * The cache key that uniquely identifies the original stream.
+ */
+ public final String key;
+ /**
+ * The position of the {@link CacheSpan} in the original stream.
+ */
+ public final long position;
+ /**
+ * The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an open-ended hole.
+ */
+ public final long length;
+ /**
+ * Whether the {@link CacheSpan} is cached.
+ */
+ public final boolean isCached;
+ /** The file corresponding to this {@link CacheSpan}, or null if {@link #isCached} is false. */
+ @Nullable public final File file;
+ /** The last touch timestamp, or {@link C#TIME_UNSET} if {@link #isCached} is false. */
+ public final long lastTouchTimestamp;
+
+ /**
+ * Creates a hole CacheSpan which isn't cached, has no last touch timestamp and no file
+ * associated.
+ *
+ * @param key The cache key that uniquely identifies the original stream.
+ * @param position The position of the {@link CacheSpan} in the original stream.
+ * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an
+ * open-ended hole.
+ */
+ public CacheSpan(String key, long position, long length) {
+ this(key, position, length, C.TIME_UNSET, null);
+ }
+
+ /**
+ * Creates a CacheSpan.
+ *
+ * @param key The cache key that uniquely identifies the original stream.
+ * @param position The position of the {@link CacheSpan} in the original stream.
+ * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an
+ * open-ended hole.
+ * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} if {@link
+ * #isCached} is false.
+ * @param file The file corresponding to this {@link CacheSpan}, or null if it's a hole.
+ */
+ public CacheSpan(
+ String key, long position, long length, long lastTouchTimestamp, @Nullable File file) {
+ this.key = key;
+ this.position = position;
+ this.length = length;
+ this.isCached = file != null;
+ this.file = file;
+ this.lastTouchTimestamp = lastTouchTimestamp;
+ }
+
+ /**
+ * Returns whether this is an open-ended {@link CacheSpan}.
+ */
+ public boolean isOpenEnded() {
+ return length == C.LENGTH_UNSET;
+ }
+
+ /**
+ * Returns whether this is a hole {@link CacheSpan}.
+ */
+ public boolean isHoleSpan() {
+ return !isCached;
+ }
+
+ @Override
+ public int compareTo(@NonNull CacheSpan another) {
+ if (!key.equals(another.key)) {
+ return key.compareTo(another.key);
+ }
+ long startOffsetDiff = position - another.position;
+ return startOffsetDiff == 0 ? 0 : ((startOffsetDiff < 0) ? -1 : 1);
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheUtil.java
new file mode 100644
index 0000000000..01fef2b605
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheUtil.java
@@ -0,0 +1,434 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import android.net.Uri;
+import android.util.Pair;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSourceException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.EOFException;
+import java.io.IOException;
+import java.util.NavigableSet;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Caching related utility methods.
+ */
+public final class CacheUtil {
+
+ /** Receives progress updates during cache operations. */
+ public interface ProgressListener {
+
+ /**
+ * Called when progress is made during a cache operation.
+ *
+ * @param requestLength The length of the content being cached in bytes, or {@link
+ * C#LENGTH_UNSET} if unknown.
+ * @param bytesCached The number of bytes that are cached.
+ * @param newBytesCached The number of bytes that have been newly cached since the last progress
+ * update.
+ */
+ void onProgress(long requestLength, long bytesCached, long newBytesCached);
+ }
+
+ /** Default buffer size to be used while caching. */
+ public static final int DEFAULT_BUFFER_SIZE_BYTES = 128 * 1024;
+
+ /** Default {@link CacheKeyFactory}. */
+ public static final CacheKeyFactory DEFAULT_CACHE_KEY_FACTORY =
+ (dataSpec) -> dataSpec.key != null ? dataSpec.key : generateKey(dataSpec.uri);
+
+ /**
+ * Generates a cache key out of the given {@link Uri}.
+ *
+ * @param uri Uri of a content which the requested key is for.
+ */
+ public static String generateKey(Uri uri) {
+ return uri.toString();
+ }
+
+ /**
+ * Queries the cache to obtain the request length and the number of bytes already cached for a
+ * given {@link DataSpec}.
+ *
+ * @param dataSpec Defines the data to be checked.
+ * @param cache A {@link Cache} which has the data.
+ * @param cacheKeyFactory An optional factory for cache keys.
+ * @return A pair containing the request length and the number of bytes that are already cached.
+ */
+ public static Pair<Long, Long> getCached(
+ DataSpec dataSpec, Cache cache, @Nullable CacheKeyFactory cacheKeyFactory) {
+ String key = buildCacheKey(dataSpec, cacheKeyFactory);
+ long position = dataSpec.absoluteStreamPosition;
+ long requestLength = getRequestLength(dataSpec, cache, key);
+ long bytesAlreadyCached = 0;
+ long bytesLeft = requestLength;
+ while (bytesLeft != 0) {
+ long blockLength =
+ cache.getCachedLength(
+ key, position, bytesLeft != C.LENGTH_UNSET ? bytesLeft : Long.MAX_VALUE);
+ if (blockLength > 0) {
+ bytesAlreadyCached += blockLength;
+ } else {
+ blockLength = -blockLength;
+ if (blockLength == Long.MAX_VALUE) {
+ break;
+ }
+ }
+ position += blockLength;
+ bytesLeft -= bytesLeft == C.LENGTH_UNSET ? 0 : blockLength;
+ }
+ return Pair.create(requestLength, bytesAlreadyCached);
+ }
+
+ /**
+ * Caches the data defined by {@code dataSpec}, skipping already cached data. Caching stops early
+ * if the end of the input is reached.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param dataSpec Defines the data to be cached.
+ * @param cache A {@link Cache} to store the data.
+ * @param cacheKeyFactory An optional factory for cache keys.
+ * @param upstream A {@link DataSource} for reading data not in the cache.
+ * @param progressListener A listener to receive progress updates, or {@code null}.
+ * @param isCanceled An optional flag that will interrupt caching if set to true.
+ * @throws IOException If an error occurs reading from the source.
+ * @throws InterruptedException If the thread was interrupted directly or via {@code isCanceled}.
+ */
+ @WorkerThread
+ public static void cache(
+ DataSpec dataSpec,
+ Cache cache,
+ @Nullable CacheKeyFactory cacheKeyFactory,
+ DataSource upstream,
+ @Nullable ProgressListener progressListener,
+ @Nullable AtomicBoolean isCanceled)
+ throws IOException, InterruptedException {
+ cache(
+ dataSpec,
+ cache,
+ cacheKeyFactory,
+ new CacheDataSource(cache, upstream),
+ new byte[DEFAULT_BUFFER_SIZE_BYTES],
+ /* priorityTaskManager= */ null,
+ /* priority= */ 0,
+ progressListener,
+ isCanceled,
+ /* enableEOFException= */ false);
+ }
+
+ /**
+ * Caches the data defined by {@code dataSpec} while skipping already cached data. Caching stops
+ * early if end of input is reached and {@code enableEOFException} is false.
+ *
+ * <p>If a {@link PriorityTaskManager} is given, it's used to pause and resume caching depending
+ * on {@code priority} and the priority of other tasks registered to the PriorityTaskManager.
+ * Please note that it's the responsibility of the calling code to call {@link
+ * PriorityTaskManager#add} to register with the manager before calling this method, and to call
+ * {@link PriorityTaskManager#remove} afterwards to unregister.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param dataSpec Defines the data to be cached.
+ * @param cache A {@link Cache} to store the data.
+ * @param cacheKeyFactory An optional factory for cache keys.
+ * @param dataSource A {@link CacheDataSource} that works on the {@code cache}.
+ * @param buffer The buffer to be used while caching.
+ * @param priorityTaskManager If not null it's used to check whether it is allowed to proceed with
+ * caching.
+ * @param priority The priority of this task. Used with {@code priorityTaskManager}.
+ * @param progressListener A listener to receive progress updates, or {@code null}.
+ * @param isCanceled An optional flag that will interrupt caching if set to true.
+ * @param enableEOFException Whether to throw an {@link EOFException} if end of input has been
+ * reached unexpectedly.
+ * @throws IOException If an error occurs reading from the source.
+ * @throws InterruptedException If the thread was interrupted directly or via {@code isCanceled}.
+ */
+ @WorkerThread
+ public static void cache(
+ DataSpec dataSpec,
+ Cache cache,
+ @Nullable CacheKeyFactory cacheKeyFactory,
+ CacheDataSource dataSource,
+ byte[] buffer,
+ @Nullable PriorityTaskManager priorityTaskManager,
+ int priority,
+ @Nullable ProgressListener progressListener,
+ @Nullable AtomicBoolean isCanceled,
+ boolean enableEOFException)
+ throws IOException, InterruptedException {
+ Assertions.checkNotNull(dataSource);
+ Assertions.checkNotNull(buffer);
+
+ String key = buildCacheKey(dataSpec, cacheKeyFactory);
+ long bytesLeft;
+ ProgressNotifier progressNotifier = null;
+ if (progressListener != null) {
+ progressNotifier = new ProgressNotifier(progressListener);
+ Pair<Long, Long> lengthAndBytesAlreadyCached = getCached(dataSpec, cache, cacheKeyFactory);
+ progressNotifier.init(lengthAndBytesAlreadyCached.first, lengthAndBytesAlreadyCached.second);
+ bytesLeft = lengthAndBytesAlreadyCached.first;
+ } else {
+ bytesLeft = getRequestLength(dataSpec, cache, key);
+ }
+
+ long position = dataSpec.absoluteStreamPosition;
+ boolean lengthUnset = bytesLeft == C.LENGTH_UNSET;
+ while (bytesLeft != 0) {
+ throwExceptionIfInterruptedOrCancelled(isCanceled);
+ long blockLength =
+ cache.getCachedLength(key, position, lengthUnset ? Long.MAX_VALUE : bytesLeft);
+ if (blockLength > 0) {
+ // Skip already cached data.
+ } else {
+ // There is a hole in the cache which is at least "-blockLength" long.
+ blockLength = -blockLength;
+ long length = blockLength == Long.MAX_VALUE ? C.LENGTH_UNSET : blockLength;
+ boolean isLastBlock = length == bytesLeft;
+ long read =
+ readAndDiscard(
+ dataSpec,
+ position,
+ length,
+ dataSource,
+ buffer,
+ priorityTaskManager,
+ priority,
+ progressNotifier,
+ isLastBlock,
+ isCanceled);
+ if (read < blockLength) {
+ // Reached to the end of the data.
+ if (enableEOFException && !lengthUnset) {
+ throw new EOFException();
+ }
+ break;
+ }
+ }
+ position += blockLength;
+ if (!lengthUnset) {
+ bytesLeft -= blockLength;
+ }
+ }
+ }
+
+ private static long getRequestLength(DataSpec dataSpec, Cache cache, String key) {
+ if (dataSpec.length != C.LENGTH_UNSET) {
+ return dataSpec.length;
+ } else {
+ long contentLength = ContentMetadata.getContentLength(cache.getContentMetadata(key));
+ return contentLength == C.LENGTH_UNSET
+ ? C.LENGTH_UNSET
+ : contentLength - dataSpec.absoluteStreamPosition;
+ }
+ }
+
+ /**
+ * Reads and discards all data specified by the {@code dataSpec}.
+ *
+ * @param dataSpec Defines the data to be read. {@code absoluteStreamPosition} and {@code length}
+ * fields are overwritten by the following parameters.
+ * @param absoluteStreamPosition The absolute position of the data to be read.
+ * @param length Length of the data to be read, or {@link C#LENGTH_UNSET} if it is unknown.
+ * @param dataSource The {@link DataSource} to read the data from.
+ * @param buffer The buffer to be used while downloading.
+ * @param priorityTaskManager If not null it's used to check whether it is allowed to proceed with
+ * caching.
+ * @param priority The priority of this task.
+ * @param progressNotifier A notifier through which to report progress updates, or {@code null}.
+ * @param isLastBlock Whether this read block is the last block of the content.
+ * @param isCanceled An optional flag that will interrupt caching if set to true.
+ * @return Number of read bytes, or 0 if no data is available because the end of the opened range
+ * has been reached.
+ */
+ private static long readAndDiscard(
+ DataSpec dataSpec,
+ long absoluteStreamPosition,
+ long length,
+ DataSource dataSource,
+ byte[] buffer,
+ @Nullable PriorityTaskManager priorityTaskManager,
+ int priority,
+ @Nullable ProgressNotifier progressNotifier,
+ boolean isLastBlock,
+ @Nullable AtomicBoolean isCanceled)
+ throws IOException, InterruptedException {
+ long positionOffset = absoluteStreamPosition - dataSpec.absoluteStreamPosition;
+ long initialPositionOffset = positionOffset;
+ long endOffset = length != C.LENGTH_UNSET ? positionOffset + length : C.POSITION_UNSET;
+ while (true) {
+ if (priorityTaskManager != null) {
+ // Wait for any other thread with higher priority to finish its job.
+ priorityTaskManager.proceed(priority);
+ }
+ throwExceptionIfInterruptedOrCancelled(isCanceled);
+ try {
+ long resolvedLength = C.LENGTH_UNSET;
+ boolean isDataSourceOpen = false;
+ if (endOffset != C.POSITION_UNSET) {
+ // If a specific length is given, first try to open the data source for that length to
+ // avoid more data then required to be requested. If the given length exceeds the end of
+ // input we will get a "position out of range" error. In that case try to open the source
+ // again with unset length.
+ try {
+ resolvedLength =
+ dataSource.open(dataSpec.subrange(positionOffset, endOffset - positionOffset));
+ isDataSourceOpen = true;
+ } catch (IOException exception) {
+ if (!isLastBlock || !isCausedByPositionOutOfRange(exception)) {
+ throw exception;
+ }
+ Util.closeQuietly(dataSource);
+ }
+ }
+ if (!isDataSourceOpen) {
+ resolvedLength = dataSource.open(dataSpec.subrange(positionOffset, C.LENGTH_UNSET));
+ }
+ if (isLastBlock && progressNotifier != null && resolvedLength != C.LENGTH_UNSET) {
+ progressNotifier.onRequestLengthResolved(positionOffset + resolvedLength);
+ }
+ while (positionOffset != endOffset) {
+ throwExceptionIfInterruptedOrCancelled(isCanceled);
+ int bytesRead =
+ dataSource.read(
+ buffer,
+ 0,
+ endOffset != C.POSITION_UNSET
+ ? (int) Math.min(buffer.length, endOffset - positionOffset)
+ : buffer.length);
+ if (bytesRead == C.RESULT_END_OF_INPUT) {
+ if (progressNotifier != null) {
+ progressNotifier.onRequestLengthResolved(positionOffset);
+ }
+ break;
+ }
+ positionOffset += bytesRead;
+ if (progressNotifier != null) {
+ progressNotifier.onBytesCached(bytesRead);
+ }
+ }
+ return positionOffset - initialPositionOffset;
+ } catch (PriorityTaskManager.PriorityTooLowException exception) {
+ // catch and try again
+ } finally {
+ Util.closeQuietly(dataSource);
+ }
+ }
+ }
+
+ /**
+ * Removes all of the data specified by the {@code dataSpec}.
+ *
+ * <p>This methods blocks until the operation is complete.
+ *
+ * @param dataSpec Defines the data to be removed.
+ * @param cache A {@link Cache} to store the data.
+ * @param cacheKeyFactory An optional factory for cache keys.
+ */
+ @WorkerThread
+ public static void remove(
+ DataSpec dataSpec, Cache cache, @Nullable CacheKeyFactory cacheKeyFactory) {
+ remove(cache, buildCacheKey(dataSpec, cacheKeyFactory));
+ }
+
+ /**
+ * Removes all of the data specified by the {@code key}.
+ *
+ * <p>This methods blocks until the operation is complete.
+ *
+ * @param cache A {@link Cache} to store the data.
+ * @param key The key whose data should be removed.
+ */
+ @WorkerThread
+ public static void remove(Cache cache, String key) {
+ NavigableSet<CacheSpan> cachedSpans = cache.getCachedSpans(key);
+ for (CacheSpan cachedSpan : cachedSpans) {
+ try {
+ cache.removeSpan(cachedSpan);
+ } catch (Cache.CacheException e) {
+ // Do nothing.
+ }
+ }
+ }
+
+ /* package */ static boolean isCausedByPositionOutOfRange(IOException e) {
+ Throwable cause = e;
+ while (cause != null) {
+ if (cause instanceof DataSourceException) {
+ int reason = ((DataSourceException) cause).reason;
+ if (reason == DataSourceException.POSITION_OUT_OF_RANGE) {
+ return true;
+ }
+ }
+ cause = cause.getCause();
+ }
+ return false;
+ }
+
+ private static String buildCacheKey(
+ DataSpec dataSpec, @Nullable CacheKeyFactory cacheKeyFactory) {
+ return (cacheKeyFactory != null ? cacheKeyFactory : DEFAULT_CACHE_KEY_FACTORY)
+ .buildCacheKey(dataSpec);
+ }
+
+ private static void throwExceptionIfInterruptedOrCancelled(@Nullable AtomicBoolean isCanceled)
+ throws InterruptedException {
+ if (Thread.interrupted() || (isCanceled != null && isCanceled.get())) {
+ throw new InterruptedException();
+ }
+ }
+
+ private CacheUtil() {}
+
+ private static final class ProgressNotifier {
+ /** The listener to notify when progress is made. */
+ private final ProgressListener listener;
+ /** The length of the content being cached in bytes, or {@link C#LENGTH_UNSET} if unknown. */
+ private long requestLength;
+ /** The number of bytes that are cached. */
+ private long bytesCached;
+
+ public ProgressNotifier(ProgressListener listener) {
+ this.listener = listener;
+ }
+
+ public void init(long requestLength, long bytesCached) {
+ this.requestLength = requestLength;
+ this.bytesCached = bytesCached;
+ listener.onProgress(requestLength, bytesCached, /* newBytesCached= */ 0);
+ }
+
+ public void onRequestLengthResolved(long requestLength) {
+ if (this.requestLength == C.LENGTH_UNSET && requestLength != C.LENGTH_UNSET) {
+ this.requestLength = requestLength;
+ listener.onProgress(requestLength, bytesCached, /* newBytesCached= */ 0);
+ }
+ }
+
+ public void onBytesCached(long newBytesCached) {
+ bytesCached += newBytesCached;
+ listener.onProgress(requestLength, bytesCached, newBytesCached);
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContent.java
new file mode 100644
index 0000000000..660a2a3cb3
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContent.java
@@ -0,0 +1,208 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import java.io.File;
+import java.util.TreeSet;
+
+/** Defines the cached content for a single stream. */
+/* package */ final class CachedContent {
+
+ private static final String TAG = "CachedContent";
+
+ /** The cache file id that uniquely identifies the original stream. */
+ public final int id;
+ /** The cache key that uniquely identifies the original stream. */
+ public final String key;
+ /** The cached spans of this content. */
+ private final TreeSet<SimpleCacheSpan> cachedSpans;
+ /** Metadata values. */
+ private DefaultContentMetadata metadata;
+ /** Whether the content is locked. */
+ private boolean locked;
+
+ /**
+ * Creates a CachedContent.
+ *
+ * @param id The cache file id.
+ * @param key The cache stream key.
+ */
+ public CachedContent(int id, String key) {
+ this(id, key, DefaultContentMetadata.EMPTY);
+ }
+
+ public CachedContent(int id, String key, DefaultContentMetadata metadata) {
+ this.id = id;
+ this.key = key;
+ this.metadata = metadata;
+ this.cachedSpans = new TreeSet<>();
+ }
+
+ /** Returns the metadata. */
+ public DefaultContentMetadata getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * Applies {@code mutations} to the metadata.
+ *
+ * @return Whether {@code mutations} changed any metadata.
+ */
+ public boolean applyMetadataMutations(ContentMetadataMutations mutations) {
+ DefaultContentMetadata oldMetadata = metadata;
+ metadata = metadata.copyWithMutationsApplied(mutations);
+ return !metadata.equals(oldMetadata);
+ }
+
+ /** Returns whether the content is locked. */
+ public boolean isLocked() {
+ return locked;
+ }
+
+ /** Sets the locked state of the content. */
+ public void setLocked(boolean locked) {
+ this.locked = locked;
+ }
+
+ /** Adds the given {@link SimpleCacheSpan} which contains a part of the content. */
+ public void addSpan(SimpleCacheSpan span) {
+ cachedSpans.add(span);
+ }
+
+ /** Returns a set of all {@link SimpleCacheSpan}s. */
+ public TreeSet<SimpleCacheSpan> getSpans() {
+ return cachedSpans;
+ }
+
+ /**
+ * Returns the span containing the position. If there isn't one, it returns a hole span
+ * which defines the maximum extents of the hole in the cache.
+ */
+ public SimpleCacheSpan getSpan(long position) {
+ SimpleCacheSpan lookupSpan = SimpleCacheSpan.createLookup(key, position);
+ SimpleCacheSpan floorSpan = cachedSpans.floor(lookupSpan);
+ if (floorSpan != null && floorSpan.position + floorSpan.length > position) {
+ return floorSpan;
+ }
+ SimpleCacheSpan ceilSpan = cachedSpans.ceiling(lookupSpan);
+ return ceilSpan == null ? SimpleCacheSpan.createOpenHole(key, position)
+ : SimpleCacheSpan.createClosedHole(key, position, ceilSpan.position - position);
+ }
+
+ /**
+ * Returns the length of the cached data block starting from the {@code position} to the block end
+ * up to {@code length} bytes. If the {@code position} isn't cached then -(the length of the gap
+ * to the next cached data up to {@code length} bytes) is returned.
+ *
+ * @param position The starting position of the data.
+ * @param length The maximum length of the data to be returned.
+ * @return the length of the cached or not cached data block length.
+ */
+ public long getCachedBytesLength(long position, long length) {
+ SimpleCacheSpan span = getSpan(position);
+ if (span.isHoleSpan()) {
+ // We don't have a span covering the start of the queried region.
+ return -Math.min(span.isOpenEnded() ? Long.MAX_VALUE : span.length, length);
+ }
+ long queryEndPosition = position + length;
+ long currentEndPosition = span.position + span.length;
+ if (currentEndPosition < queryEndPosition) {
+ for (SimpleCacheSpan next : cachedSpans.tailSet(span, false)) {
+ if (next.position > currentEndPosition) {
+ // There's a hole in the cache within the queried region.
+ break;
+ }
+ // We expect currentEndPosition to always equal (next.position + next.length), but
+ // perform a max check anyway to guard against the existence of overlapping spans.
+ currentEndPosition = Math.max(currentEndPosition, next.position + next.length);
+ if (currentEndPosition >= queryEndPosition) {
+ // We've found spans covering the queried region.
+ break;
+ }
+ }
+ }
+ return Math.min(currentEndPosition - position, length);
+ }
+
+ /**
+ * Sets the given span's last touch timestamp. The passed span becomes invalid after this call.
+ *
+ * @param cacheSpan Span to be copied and updated.
+ * @param lastTouchTimestamp The new last touch timestamp.
+ * @param updateFile Whether the span file should be renamed to have its timestamp match the new
+ * last touch time.
+ * @return A span with the updated last touch timestamp.
+ */
+ public SimpleCacheSpan setLastTouchTimestamp(
+ SimpleCacheSpan cacheSpan, long lastTouchTimestamp, boolean updateFile) {
+ Assertions.checkState(cachedSpans.remove(cacheSpan));
+ File file = cacheSpan.file;
+ if (updateFile) {
+ File directory = file.getParentFile();
+ long position = cacheSpan.position;
+ File newFile = SimpleCacheSpan.getCacheFile(directory, id, position, lastTouchTimestamp);
+ if (file.renameTo(newFile)) {
+ file = newFile;
+ } else {
+ Log.w(TAG, "Failed to rename " + file + " to " + newFile);
+ }
+ }
+ SimpleCacheSpan newCacheSpan =
+ cacheSpan.copyWithFileAndLastTouchTimestamp(file, lastTouchTimestamp);
+ cachedSpans.add(newCacheSpan);
+ return newCacheSpan;
+ }
+
+ /** Returns whether there are any spans cached. */
+ public boolean isEmpty() {
+ return cachedSpans.isEmpty();
+ }
+
+ /** Removes the given span from cache. */
+ public boolean removeSpan(CacheSpan span) {
+ if (cachedSpans.remove(span)) {
+ span.file.delete();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = id;
+ result = 31 * result + key.hashCode();
+ result = 31 * result + metadata.hashCode();
+ return result;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CachedContent that = (CachedContent) o;
+ return id == that.id
+ && key.equals(that.key)
+ && cachedSpans.equals(that.cachedSpans)
+ && metadata.equals(that.metadata);
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java
new file mode 100644
index 0000000000..ac31e492a2
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java
@@ -0,0 +1,956 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import android.annotation.SuppressLint;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.util.SparseArray;
+import android.util.SparseBooleanArray;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.annotation.WorkerThread;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseIOException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.VersionTable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.AtomicFile;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ReusableBufferedOutputStream;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+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.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+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;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+
+/** Maintains the index of cached content. */
+/* package */ class CachedContentIndex {
+
+ /* package */ static final String FILE_NAME_ATOMIC = "cached_content_index.exi";
+
+ private static final int INCREMENTAL_METADATA_READ_LENGTH = 10 * 1024 * 1024;
+
+ private final HashMap<String, CachedContent> keyToContent;
+ /**
+ * Maps assigned ids to their corresponding keys. Also contains (id -> null) entries for ids that
+ * have been removed from the index since it was last stored. This prevents reuse of these ids,
+ * which is necessary to avoid clashes that could otherwise occur as a result of the sequence:
+ *
+ * <p>[1] (key1, id1) is removed from the in-memory index ... the index is not stored to disk ...
+ * [2] id1 is reused for a different key2 ... the index is not stored to disk ... [3] A file for
+ * key2 is partially written using a path corresponding to id1 ... the process is killed before
+ * the index is stored to disk ... [4] The index is read from disk, causing the partially written
+ * file to be incorrectly associated to key1
+ *
+ * <p>By avoiding id reuse in step [2], a new id2 will be used instead. Step [4] will then delete
+ * the partially written file because the index does not contain an entry for id2.
+ *
+ * <p>When the index is next stored (id -> null) entries are removed, making the ids eligible for
+ * reuse.
+ */
+ private final SparseArray<@NullableType String> idToKey;
+ /**
+ * Tracks ids for which (id -> null) entries are present in idToKey, so that they can be removed
+ * efficiently when the index is next stored.
+ */
+ private final SparseBooleanArray removedIds;
+ /** Tracks ids that are new since the index was last stored. */
+ private final SparseBooleanArray newIds;
+
+ private Storage storage;
+ @Nullable private Storage previousStorage;
+
+ /** Returns whether the file is an index file. */
+ public static boolean isIndexFile(String fileName) {
+ // Atomic file backups add additional suffixes to the file name.
+ return fileName.startsWith(FILE_NAME_ATOMIC);
+ }
+
+ /**
+ * Deletes index data for the specified cache.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param databaseProvider Provides the database in which the index is stored.
+ * @param uid The cache UID.
+ * @throws DatabaseIOException If an error occurs deleting the index data.
+ */
+ @WorkerThread
+ public static void delete(DatabaseProvider databaseProvider, long uid)
+ throws DatabaseIOException {
+ DatabaseStorage.delete(databaseProvider, uid);
+ }
+
+ /**
+ * Creates an instance supporting database storage only.
+ *
+ * @param databaseProvider Provides the database in which the index is stored.
+ */
+ public CachedContentIndex(DatabaseProvider databaseProvider) {
+ this(
+ databaseProvider,
+ /* legacyStorageDir= */ null,
+ /* legacyStorageSecretKey= */ null,
+ /* legacyStorageEncrypt= */ false,
+ /* preferLegacyStorage= */ false);
+ }
+
+ /**
+ * Creates an instance supporting either or both of database and legacy storage.
+ *
+ * @param databaseProvider Provides the database in which the index is stored, or {@code null} to
+ * use only legacy storage.
+ * @param legacyStorageDir The directory in which any legacy storage is stored, or {@code null} to
+ * use only database storage.
+ * @param legacyStorageSecretKey A 16 byte AES key for reading, and optionally writing, legacy
+ * storage.
+ * @param legacyStorageEncrypt Whether to encrypt when writing to legacy storage. Must be false if
+ * {@code legacyStorageSecretKey} is null.
+ * @param preferLegacyStorage Whether to use prefer legacy storage if both storage types are
+ * enabled. This option is only useful for downgrading from database storage back to legacy
+ * storage.
+ */
+ public CachedContentIndex(
+ @Nullable DatabaseProvider databaseProvider,
+ @Nullable File legacyStorageDir,
+ @Nullable byte[] legacyStorageSecretKey,
+ boolean legacyStorageEncrypt,
+ boolean preferLegacyStorage) {
+ Assertions.checkState(databaseProvider != null || legacyStorageDir != null);
+ keyToContent = new HashMap<>();
+ idToKey = new SparseArray<>();
+ removedIds = new SparseBooleanArray();
+ newIds = new SparseBooleanArray();
+ Storage databaseStorage =
+ databaseProvider != null ? new DatabaseStorage(databaseProvider) : null;
+ Storage legacyStorage =
+ legacyStorageDir != null
+ ? new LegacyStorage(
+ new File(legacyStorageDir, FILE_NAME_ATOMIC),
+ legacyStorageSecretKey,
+ legacyStorageEncrypt)
+ : null;
+ if (databaseStorage == null || (legacyStorage != null && preferLegacyStorage)) {
+ storage = legacyStorage;
+ previousStorage = databaseStorage;
+ } else {
+ storage = databaseStorage;
+ previousStorage = legacyStorage;
+ }
+ }
+
+ /**
+ * Loads the index data for the given cache UID.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param uid The UID of the cache whose index is to be loaded.
+ * @throws IOException If an error occurs initializing the index data.
+ */
+ @WorkerThread
+ public void initialize(long uid) throws IOException {
+ storage.initialize(uid);
+ if (previousStorage != null) {
+ previousStorage.initialize(uid);
+ }
+ if (!storage.exists() && previousStorage != null && previousStorage.exists()) {
+ // Copy from previous storage into current storage.
+ previousStorage.load(keyToContent, idToKey);
+ storage.storeFully(keyToContent);
+ } else {
+ // Load from the current storage.
+ storage.load(keyToContent, idToKey);
+ }
+ if (previousStorage != null) {
+ previousStorage.delete();
+ previousStorage = null;
+ }
+ }
+
+ /**
+ * Stores the index data to index file if there is a change.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @throws IOException If an error occurs storing the index data.
+ */
+ @WorkerThread
+ public void store() throws IOException {
+ storage.storeIncremental(keyToContent);
+ // Make ids that were removed since the index was last stored eligible for re-use.
+ int removedIdCount = removedIds.size();
+ for (int i = 0; i < removedIdCount; i++) {
+ idToKey.remove(removedIds.keyAt(i));
+ }
+ removedIds.clear();
+ newIds.clear();
+ }
+
+ /**
+ * Adds the given key to the index if it isn't there already.
+ *
+ * @param key The cache key that uniquely identifies the original stream.
+ * @return A new or existing CachedContent instance with the given key.
+ */
+ public CachedContent getOrAdd(String key) {
+ CachedContent cachedContent = keyToContent.get(key);
+ return cachedContent == null ? addNew(key) : cachedContent;
+ }
+
+ /** Returns a CachedContent instance with the given key or null if there isn't one. */
+ public CachedContent get(String key) {
+ return keyToContent.get(key);
+ }
+
+ /**
+ * Returns a Collection of all CachedContent instances in the index. The collection is backed by
+ * the {@code keyToContent} map, so changes to the map are reflected in the collection, and
+ * vice-versa. If the map is modified while an iteration over the collection is in progress
+ * (except through the iterator's own remove operation), the results of the iteration are
+ * undefined.
+ */
+ public Collection<CachedContent> getAll() {
+ return keyToContent.values();
+ }
+
+ /** Returns an existing or new id assigned to the given key. */
+ public int assignIdForKey(String key) {
+ return getOrAdd(key).id;
+ }
+
+ /** Returns the key which has the given id assigned. */
+ public String getKeyForId(int id) {
+ return idToKey.get(id);
+ }
+
+ /** Removes {@link CachedContent} with the given key from index if it's empty and not locked. */
+ public void maybeRemove(String key) {
+ CachedContent cachedContent = keyToContent.get(key);
+ if (cachedContent != null && cachedContent.isEmpty() && !cachedContent.isLocked()) {
+ keyToContent.remove(key);
+ int id = cachedContent.id;
+ boolean neverStored = newIds.get(id);
+ storage.onRemove(cachedContent, neverStored);
+ if (neverStored) {
+ // The id can be reused immediately.
+ idToKey.remove(id);
+ newIds.delete(id);
+ } else {
+ // Keep an entry in idToKey to stop the id from being reused until the index is next stored,
+ // and add an entry to removedIds to track that it should be removed when this does happen.
+ idToKey.put(id, /* value= */ null);
+ removedIds.put(id, /* value= */ true);
+ }
+ }
+ }
+
+ /** Removes empty and not locked {@link CachedContent} instances from index. */
+ public void removeEmpty() {
+ String[] keys = new String[keyToContent.size()];
+ keyToContent.keySet().toArray(keys);
+ for (String key : keys) {
+ maybeRemove(key);
+ }
+ }
+
+ /**
+ * Returns a set of all content keys. The set is backed by the {@code keyToContent} map, so
+ * changes to the map are reflected in the set, and vice-versa. If the map is modified while an
+ * iteration over the set is in progress (except through the iterator's own remove operation), the
+ * results of the iteration are undefined.
+ */
+ public Set<String> getKeys() {
+ return keyToContent.keySet();
+ }
+
+ /**
+ * Applies {@code mutations} to the {@link ContentMetadata} for the given key. A new {@link
+ * CachedContent} is added if there isn't one already with the given key.
+ */
+ public void applyContentMetadataMutations(String key, ContentMetadataMutations mutations) {
+ CachedContent cachedContent = getOrAdd(key);
+ if (cachedContent.applyMetadataMutations(mutations)) {
+ storage.onUpdate(cachedContent);
+ }
+ }
+
+ /** Returns a {@link ContentMetadata} for the given key. */
+ public ContentMetadata getContentMetadata(String key) {
+ CachedContent cachedContent = get(key);
+ return cachedContent != null ? cachedContent.getMetadata() : DefaultContentMetadata.EMPTY;
+ }
+
+ private CachedContent addNew(String key) {
+ int id = getNewId(idToKey);
+ CachedContent cachedContent = new CachedContent(id, key);
+ keyToContent.put(key, cachedContent);
+ idToKey.put(id, key);
+ newIds.put(id, true);
+ storage.onUpdate(cachedContent);
+ return cachedContent;
+ }
+
+ @SuppressLint("GetInstance") // Suppress warning about specifying "BC" as an explicit provider.
+ private static Cipher getCipher() throws NoSuchPaddingException, NoSuchAlgorithmException {
+ // Workaround for https://issuetracker.google.com/issues/36976726
+ if (Util.SDK_INT == 18) {
+ try {
+ return Cipher.getInstance("AES/CBC/PKCS5PADDING", "BC");
+ } catch (Throwable ignored) {
+ // ignored
+ }
+ }
+ return Cipher.getInstance("AES/CBC/PKCS5PADDING");
+ }
+
+ /**
+ * Returns an id which isn't used in the given array. If the maximum id in the array is smaller
+ * than {@link java.lang.Integer#MAX_VALUE} it just returns the next bigger integer. Otherwise it
+ * returns the smallest unused non-negative integer.
+ */
+ @VisibleForTesting
+ /* package */ static int getNewId(SparseArray<String> idToKey) {
+ int size = idToKey.size();
+ int id = size == 0 ? 0 : (idToKey.keyAt(size - 1) + 1);
+ if (id < 0) { // In case if we pass max int value.
+ // TODO optimization: defragmentation or binary search?
+ for (id = 0; id < size; id++) {
+ if (id != idToKey.keyAt(id)) {
+ break;
+ }
+ }
+ }
+ return id;
+ }
+
+ /**
+ * Deserializes a {@link DefaultContentMetadata} from the given input stream.
+ *
+ * @param input Input stream to read from.
+ * @return a {@link DefaultContentMetadata} instance.
+ * @throws IOException If an error occurs during reading from the input.
+ */
+ private static DefaultContentMetadata readContentMetadata(DataInputStream input)
+ throws IOException {
+ int size = input.readInt();
+ HashMap<String, byte[]> metadata = new HashMap<>();
+ for (int i = 0; i < size; i++) {
+ String name = input.readUTF();
+ int valueSize = input.readInt();
+ if (valueSize < 0) {
+ throw new IOException("Invalid value size: " + valueSize);
+ }
+ // Grow the array incrementally to avoid OutOfMemoryError in the case that a corrupt (and very
+ // large) valueSize was read. In such cases the implementation below is expected to throw
+ // IOException from one of the readFully calls, due to the end of the input being reached.
+ int bytesRead = 0;
+ int nextBytesToRead = Math.min(valueSize, INCREMENTAL_METADATA_READ_LENGTH);
+ byte[] value = Util.EMPTY_BYTE_ARRAY;
+ while (bytesRead != valueSize) {
+ value = Arrays.copyOf(value, bytesRead + nextBytesToRead);
+ input.readFully(value, bytesRead, nextBytesToRead);
+ bytesRead += nextBytesToRead;
+ nextBytesToRead = Math.min(valueSize - bytesRead, INCREMENTAL_METADATA_READ_LENGTH);
+ }
+ metadata.put(name, value);
+ }
+ return new DefaultContentMetadata(metadata);
+ }
+
+ /**
+ * Serializes itself to a {@link DataOutputStream}.
+ *
+ * @param output Output stream to store the values.
+ * @throws IOException If an error occurs writing to the output.
+ */
+ private static void writeContentMetadata(DefaultContentMetadata metadata, DataOutputStream output)
+ throws IOException {
+ Set<Map.Entry<String, byte[]>> entrySet = metadata.entrySet();
+ output.writeInt(entrySet.size());
+ for (Map.Entry<String, byte[]> entry : entrySet) {
+ output.writeUTF(entry.getKey());
+ byte[] value = entry.getValue();
+ output.writeInt(value.length);
+ output.write(value);
+ }
+ }
+
+ /** Interface for the persistent index. */
+ private interface Storage {
+
+ /** Initializes the storage for the given cache UID. */
+ void initialize(long uid);
+
+ /**
+ * Returns whether the persisted index exists.
+ *
+ * @throws IOException If an error occurs determining whether the persisted index exists.
+ */
+ boolean exists() throws IOException;
+
+ /**
+ * Deletes the persisted index.
+ *
+ * @throws IOException If an error occurs deleting the index.
+ */
+ void delete() throws IOException;
+
+ /**
+ * Loads the persisted index into {@code content} and {@code idToKey}, creating it if it doesn't
+ * already exist.
+ *
+ * <p>If the persisted index is in a permanently bad state (i.e. all further attempts to load it
+ * are also expected to fail) then it will be deleted and the call will return successfully. For
+ * transient failures, {@link IOException} will be thrown.
+ *
+ * @param content The key to content map to populate with persisted data.
+ * @param idToKey The id to key map to populate with persisted data.
+ * @throws IOException If an error occurs loading the index.
+ */
+ void load(HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey)
+ throws IOException;
+
+ /**
+ * Writes the persisted index, creating it if it doesn't already exist and replacing any
+ * existing content if it does.
+ *
+ * @param content The key to content map to persist.
+ * @throws IOException If an error occurs persisting the index.
+ */
+ void storeFully(HashMap<String, CachedContent> content) throws IOException;
+
+ /**
+ * Ensures incremental changes to the index since the initial {@link #initialize(long)} or last
+ * {@link #storeFully(HashMap)} are persisted. The storage will have been notified of all such
+ * changes via {@link #onUpdate(CachedContent)} and {@link #onRemove(CachedContent, boolean)}.
+ *
+ * @param content The key to content map to persist.
+ * @throws IOException If an error occurs persisting the index.
+ */
+ void storeIncremental(HashMap<String, CachedContent> content) throws IOException;
+
+ /**
+ * Called when a {@link CachedContent} is added or updated.
+ *
+ * @param cachedContent The updated {@link CachedContent}.
+ */
+ void onUpdate(CachedContent cachedContent);
+
+ /**
+ * Called when a {@link CachedContent} is removed.
+ *
+ * @param cachedContent The removed {@link CachedContent}.
+ * @param neverStored True if the {@link CachedContent} was added more recently than when the
+ * index was last stored.
+ */
+ void onRemove(CachedContent cachedContent, boolean neverStored);
+ }
+
+ /** {@link Storage} implementation that uses an {@link AtomicFile}. */
+ private static class LegacyStorage implements Storage {
+
+ private static final int VERSION = 2;
+ private static final int VERSION_METADATA_INTRODUCED = 2;
+ private static final int FLAG_ENCRYPTED_INDEX = 1;
+
+ private final boolean encrypt;
+ @Nullable private final Cipher cipher;
+ @Nullable private final SecretKeySpec secretKeySpec;
+ @Nullable private final Random random;
+ private final AtomicFile atomicFile;
+
+ private boolean changed;
+ @Nullable private ReusableBufferedOutputStream bufferedOutputStream;
+
+ public LegacyStorage(File file, @Nullable byte[] secretKey, boolean encrypt) {
+ Cipher cipher = null;
+ SecretKeySpec secretKeySpec = null;
+ if (secretKey != null) {
+ Assertions.checkArgument(secretKey.length == 16);
+ try {
+ cipher = getCipher();
+ secretKeySpec = new SecretKeySpec(secretKey, "AES");
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+ throw new IllegalStateException(e); // Should never happen.
+ }
+ } else {
+ Assertions.checkArgument(!encrypt);
+ }
+ this.encrypt = encrypt;
+ this.cipher = cipher;
+ this.secretKeySpec = secretKeySpec;
+ random = encrypt ? new Random() : null;
+ atomicFile = new AtomicFile(file);
+ }
+
+ @Override
+ public void initialize(long uid) {
+ // Do nothing. Legacy storage uses a separate file for each cache.
+ }
+
+ @Override
+ public boolean exists() {
+ return atomicFile.exists();
+ }
+
+ @Override
+ public void delete() {
+ atomicFile.delete();
+ }
+
+ @Override
+ public void load(
+ HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey) {
+ Assertions.checkState(!changed);
+ if (!readFile(content, idToKey)) {
+ content.clear();
+ idToKey.clear();
+ atomicFile.delete();
+ }
+ }
+
+ @Override
+ public void storeFully(HashMap<String, CachedContent> content) throws IOException {
+ writeFile(content);
+ changed = false;
+ }
+
+ @Override
+ public void storeIncremental(HashMap<String, CachedContent> content) throws IOException {
+ if (!changed) {
+ return;
+ }
+ storeFully(content);
+ }
+
+ @Override
+ public void onUpdate(CachedContent cachedContent) {
+ changed = true;
+ }
+
+ @Override
+ public void onRemove(CachedContent cachedContent, boolean neverStored) {
+ changed = true;
+ }
+
+ private boolean readFile(
+ HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey) {
+ if (!atomicFile.exists()) {
+ return true;
+ }
+
+ DataInputStream input = null;
+ try {
+ InputStream inputStream = new BufferedInputStream(atomicFile.openRead());
+ input = new DataInputStream(inputStream);
+ int version = input.readInt();
+ if (version < 0 || version > VERSION) {
+ return false;
+ }
+
+ int flags = input.readInt();
+ if ((flags & FLAG_ENCRYPTED_INDEX) != 0) {
+ if (cipher == null) {
+ return false;
+ }
+ byte[] initializationVector = new byte[16];
+ input.readFully(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));
+ } else if (encrypt) {
+ changed = true; // Force index to be rewritten encrypted after read.
+ }
+
+ int count = input.readInt();
+ int hashCode = 0;
+ for (int i = 0; i < count; i++) {
+ CachedContent cachedContent = readCachedContent(version, input);
+ content.put(cachedContent.key, cachedContent);
+ idToKey.put(cachedContent.id, cachedContent.key);
+ hashCode += hashCachedContent(cachedContent, version);
+ }
+ int fileHashCode = input.readInt();
+ boolean isEOF = input.read() == -1;
+ if (fileHashCode != hashCode || !isEOF) {
+ return false;
+ }
+ } catch (IOException e) {
+ return false;
+ } finally {
+ if (input != null) {
+ Util.closeQuietly(input);
+ }
+ }
+ return true;
+ }
+
+ private void writeFile(HashMap<String, CachedContent> content) throws IOException {
+ DataOutputStream output = null;
+ try {
+ OutputStream outputStream = atomicFile.startWrite();
+ if (bufferedOutputStream == null) {
+ bufferedOutputStream = new ReusableBufferedOutputStream(outputStream);
+ } else {
+ bufferedOutputStream.reset(outputStream);
+ }
+ output = new DataOutputStream(bufferedOutputStream);
+ output.writeInt(VERSION);
+
+ int flags = encrypt ? FLAG_ENCRYPTED_INDEX : 0;
+ output.writeInt(flags);
+
+ if (encrypt) {
+ byte[] initializationVector = new byte[16];
+ 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(bufferedOutputStream, cipher));
+ }
+
+ output.writeInt(content.size());
+ int hashCode = 0;
+ for (CachedContent cachedContent : content.values()) {
+ writeCachedContent(cachedContent, output);
+ hashCode += hashCachedContent(cachedContent, VERSION);
+ }
+ output.writeInt(hashCode);
+ atomicFile.endWrite(output);
+ // Avoid calling close twice. Duplicate CipherOutputStream.close calls did
+ // not used to be no-ops: https://android-review.googlesource.com/#/c/272799/
+ output = null;
+ } finally {
+ Util.closeQuietly(output);
+ }
+ }
+
+ /**
+ * Calculates a hash code for a {@link CachedContent} which is compatible with a particular
+ * index version.
+ */
+ private int hashCachedContent(CachedContent cachedContent, int version) {
+ int result = cachedContent.id;
+ result = 31 * result + cachedContent.key.hashCode();
+ if (version < VERSION_METADATA_INTRODUCED) {
+ long length = ContentMetadata.getContentLength(cachedContent.getMetadata());
+ result = 31 * result + (int) (length ^ (length >>> 32));
+ } else {
+ result = 31 * result + cachedContent.getMetadata().hashCode();
+ }
+ return result;
+ }
+
+ /**
+ * Reads a {@link CachedContent} from a {@link DataInputStream}.
+ *
+ * @param version Version of the encoded data.
+ * @param input Input stream containing values needed to initialize CachedContent instance.
+ * @throws IOException If an error occurs during reading values.
+ */
+ private CachedContent readCachedContent(int version, DataInputStream input) throws IOException {
+ int id = input.readInt();
+ String key = input.readUTF();
+ DefaultContentMetadata metadata;
+ if (version < VERSION_METADATA_INTRODUCED) {
+ long length = input.readLong();
+ ContentMetadataMutations mutations = new ContentMetadataMutations();
+ ContentMetadataMutations.setContentLength(mutations, length);
+ metadata = DefaultContentMetadata.EMPTY.copyWithMutationsApplied(mutations);
+ } else {
+ metadata = readContentMetadata(input);
+ }
+ return new CachedContent(id, key, metadata);
+ }
+
+ /**
+ * Writes a {@link CachedContent} to a {@link DataOutputStream}.
+ *
+ * @param output Output stream to store the values.
+ * @throws IOException If an error occurs during writing values to output.
+ */
+ private void writeCachedContent(CachedContent cachedContent, DataOutputStream output)
+ throws IOException {
+ output.writeInt(cachedContent.id);
+ output.writeUTF(cachedContent.key);
+ writeContentMetadata(cachedContent.getMetadata(), output);
+ }
+ }
+
+ /** {@link Storage} implementation that uses an SQL database. */
+ private static final class DatabaseStorage implements Storage {
+
+ private static final String TABLE_PREFIX = DatabaseProvider.TABLE_PREFIX + "CacheIndex";
+ private static final int TABLE_VERSION = 1;
+
+ private static final String COLUMN_ID = "id";
+ private static final String COLUMN_KEY = "key";
+ private static final String COLUMN_METADATA = "metadata";
+
+ private static final int COLUMN_INDEX_ID = 0;
+ private static final int COLUMN_INDEX_KEY = 1;
+ private static final int COLUMN_INDEX_METADATA = 2;
+
+ private static final String WHERE_ID_EQUALS = COLUMN_ID + " = ?";
+
+ private static final String[] COLUMNS = new String[] {COLUMN_ID, COLUMN_KEY, COLUMN_METADATA};
+ private static final String TABLE_SCHEMA =
+ "("
+ + COLUMN_ID
+ + " INTEGER PRIMARY KEY NOT NULL,"
+ + COLUMN_KEY
+ + " TEXT NOT NULL,"
+ + COLUMN_METADATA
+ + " BLOB NOT NULL)";
+
+ private final DatabaseProvider databaseProvider;
+ private final SparseArray<CachedContent> pendingUpdates;
+
+ private String hexUid;
+ private String tableName;
+
+ public static void delete(DatabaseProvider databaseProvider, long uid)
+ throws DatabaseIOException {
+ delete(databaseProvider, Long.toHexString(uid));
+ }
+
+ public DatabaseStorage(DatabaseProvider databaseProvider) {
+ this.databaseProvider = databaseProvider;
+ pendingUpdates = new SparseArray<>();
+ }
+
+ @Override
+ public void initialize(long uid) {
+ hexUid = Long.toHexString(uid);
+ tableName = getTableName(hexUid);
+ }
+
+ @Override
+ public boolean exists() throws DatabaseIOException {
+ return VersionTable.getVersion(
+ databaseProvider.getReadableDatabase(),
+ VersionTable.FEATURE_CACHE_CONTENT_METADATA,
+ hexUid)
+ != VersionTable.VERSION_UNSET;
+ }
+
+ @Override
+ public void delete() throws DatabaseIOException {
+ delete(databaseProvider, hexUid);
+ }
+
+ @Override
+ public void load(
+ HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey)
+ throws IOException {
+ Assertions.checkState(pendingUpdates.size() == 0);
+ try {
+ int version =
+ VersionTable.getVersion(
+ databaseProvider.getReadableDatabase(),
+ VersionTable.FEATURE_CACHE_CONTENT_METADATA,
+ hexUid);
+ if (version != TABLE_VERSION) {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.beginTransactionNonExclusive();
+ try {
+ initializeTable(writableDatabase);
+ writableDatabase.setTransactionSuccessful();
+ } finally {
+ writableDatabase.endTransaction();
+ }
+ }
+
+ try (Cursor cursor = getCursor()) {
+ while (cursor.moveToNext()) {
+ int id = cursor.getInt(COLUMN_INDEX_ID);
+ String key = cursor.getString(COLUMN_INDEX_KEY);
+ byte[] metadataBytes = cursor.getBlob(COLUMN_INDEX_METADATA);
+
+ ByteArrayInputStream inputStream = new ByteArrayInputStream(metadataBytes);
+ DataInputStream input = new DataInputStream(inputStream);
+ DefaultContentMetadata metadata = readContentMetadata(input);
+
+ CachedContent cachedContent = new CachedContent(id, key, metadata);
+ content.put(cachedContent.key, cachedContent);
+ idToKey.put(cachedContent.id, cachedContent.key);
+ }
+ }
+ } catch (SQLiteException e) {
+ content.clear();
+ idToKey.clear();
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ @Override
+ public void storeFully(HashMap<String, CachedContent> content) throws IOException {
+ try {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.beginTransactionNonExclusive();
+ try {
+ initializeTable(writableDatabase);
+ for (CachedContent cachedContent : content.values()) {
+ addOrUpdateRow(writableDatabase, cachedContent);
+ }
+ writableDatabase.setTransactionSuccessful();
+ pendingUpdates.clear();
+ } finally {
+ writableDatabase.endTransaction();
+ }
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ @Override
+ public void storeIncremental(HashMap<String, CachedContent> content) throws IOException {
+ if (pendingUpdates.size() == 0) {
+ return;
+ }
+ try {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.beginTransactionNonExclusive();
+ try {
+ for (int i = 0; i < pendingUpdates.size(); i++) {
+ CachedContent cachedContent = pendingUpdates.valueAt(i);
+ if (cachedContent == null) {
+ deleteRow(writableDatabase, pendingUpdates.keyAt(i));
+ } else {
+ addOrUpdateRow(writableDatabase, cachedContent);
+ }
+ }
+ writableDatabase.setTransactionSuccessful();
+ pendingUpdates.clear();
+ } finally {
+ writableDatabase.endTransaction();
+ }
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ @Override
+ public void onUpdate(CachedContent cachedContent) {
+ pendingUpdates.put(cachedContent.id, cachedContent);
+ }
+
+ @Override
+ public void onRemove(CachedContent cachedContent, boolean neverStored) {
+ if (neverStored) {
+ pendingUpdates.delete(cachedContent.id);
+ } else {
+ pendingUpdates.put(cachedContent.id, null);
+ }
+ }
+
+ private Cursor getCursor() {
+ return databaseProvider
+ .getReadableDatabase()
+ .query(
+ tableName,
+ COLUMNS,
+ /* selection= */ null,
+ /* selectionArgs= */ null,
+ /* groupBy= */ null,
+ /* having= */ null,
+ /* orderBy= */ null);
+ }
+
+ private void initializeTable(SQLiteDatabase writableDatabase) throws DatabaseIOException {
+ VersionTable.setVersion(
+ writableDatabase, VersionTable.FEATURE_CACHE_CONTENT_METADATA, hexUid, TABLE_VERSION);
+ dropTable(writableDatabase, tableName);
+ writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA);
+ }
+
+ private void deleteRow(SQLiteDatabase writableDatabase, int key) {
+ writableDatabase.delete(tableName, WHERE_ID_EQUALS, new String[] {Integer.toString(key)});
+ }
+
+ private void addOrUpdateRow(SQLiteDatabase writableDatabase, CachedContent cachedContent)
+ throws IOException {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ writeContentMetadata(cachedContent.getMetadata(), new DataOutputStream(outputStream));
+ byte[] data = outputStream.toByteArray();
+
+ ContentValues values = new ContentValues();
+ values.put(COLUMN_ID, cachedContent.id);
+ values.put(COLUMN_KEY, cachedContent.key);
+ values.put(COLUMN_METADATA, data);
+ writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values);
+ }
+
+ private static void delete(DatabaseProvider databaseProvider, String hexUid)
+ throws DatabaseIOException {
+ try {
+ String tableName = getTableName(hexUid);
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.beginTransactionNonExclusive();
+ try {
+ VersionTable.removeVersion(
+ writableDatabase, VersionTable.FEATURE_CACHE_CONTENT_METADATA, hexUid);
+ dropTable(writableDatabase, tableName);
+ writableDatabase.setTransactionSuccessful();
+ } finally {
+ writableDatabase.endTransaction();
+ }
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ private static void dropTable(SQLiteDatabase writableDatabase, String tableName) {
+ writableDatabase.execSQL("DROP TABLE IF EXISTS " + tableName);
+ }
+
+ private static String getTableName(String hexUid) {
+ return TABLE_PREFIX + hexUid;
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java
new file mode 100644
index 0000000000..9b08301ab8
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java
@@ -0,0 +1,204 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import androidx.annotation.NonNull;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ChunkIndex;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+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);
+ // 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(@NonNull Region another) {
+ return Util.compareLong(startOffset, another.startOffset);
+ }
+
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java
new file mode 100644
index 0000000000..aa34823043
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+
+/**
+ * Interface for an immutable snapshot of keyed metadata.
+ */
+public interface ContentMetadata {
+
+ /**
+ * Prefix for custom metadata keys. Applications can use keys starting with this prefix without
+ * any risk of their keys colliding with ones defined by the ExoPlayer library.
+ */
+ @SuppressWarnings("unused")
+ String KEY_CUSTOM_PREFIX = "custom_";
+ /** Key for redirected uri (type: String). */
+ String KEY_REDIRECTED_URI = "exo_redir";
+ /** Key for content length in bytes (type: long). */
+ String KEY_CONTENT_LENGTH = "exo_len";
+
+ /**
+ * Returns a metadata value.
+ *
+ * @param key Key of the metadata to be returned.
+ * @param defaultValue Value to return if the metadata doesn't exist.
+ * @return The metadata value.
+ */
+ @Nullable
+ byte[] get(String key, @Nullable byte[] defaultValue);
+
+ /**
+ * Returns a metadata value.
+ *
+ * @param key Key of the metadata to be returned.
+ * @param defaultValue Value to return if the metadata doesn't exist.
+ * @return The metadata value.
+ */
+ @Nullable
+ String get(String key, @Nullable String defaultValue);
+
+ /**
+ * Returns a metadata value.
+ *
+ * @param key Key of the metadata to be returned.
+ * @param defaultValue Value to return if the metadata doesn't exist.
+ * @return The metadata value.
+ */
+ long get(String key, long defaultValue);
+
+ /** Returns whether the metadata is available. */
+ boolean contains(String key);
+
+ /**
+ * Returns the value stored under {@link #KEY_CONTENT_LENGTH}, or {@link C#LENGTH_UNSET} if not
+ * set.
+ */
+ static long getContentLength(ContentMetadata contentMetadata) {
+ return contentMetadata.get(KEY_CONTENT_LENGTH, C.LENGTH_UNSET);
+ }
+
+ /**
+ * Returns the value stored under {@link #KEY_REDIRECTED_URI} as a {@link Uri}, or {code null} if
+ * not set.
+ */
+ @Nullable
+ static Uri getRedirectedUri(ContentMetadata contentMetadata) {
+ String redirectedUri = contentMetadata.get(KEY_REDIRECTED_URI, (String) null);
+ return redirectedUri == null ? null : Uri.parse(redirectedUri);
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java
new file mode 100644
index 0000000000..c7a8d9f711
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+/**
+ * Defines multiple mutations on metadata value which are applied atomically. This class isn't
+ * thread safe.
+ */
+public class ContentMetadataMutations {
+
+ /**
+ * Adds a mutation to set the {@link ContentMetadata#KEY_CONTENT_LENGTH} value, or to remove any
+ * existing value if {@link C#LENGTH_UNSET} is passed.
+ *
+ * @param mutations The mutations to modify.
+ * @param length The length value, or {@link C#LENGTH_UNSET} to remove any existing entry.
+ * @return The mutations instance, for convenience.
+ */
+ public static ContentMetadataMutations setContentLength(
+ ContentMetadataMutations mutations, long length) {
+ return mutations.set(ContentMetadata.KEY_CONTENT_LENGTH, length);
+ }
+
+ /**
+ * Adds a mutation to set the {@link ContentMetadata#KEY_REDIRECTED_URI} value, or to remove any
+ * existing entry if {@code null} is passed.
+ *
+ * @param mutations The mutations to modify.
+ * @param uri The {@link Uri} value, or {@code null} to remove any existing entry.
+ * @return The mutations instance, for convenience.
+ */
+ public static ContentMetadataMutations setRedirectedUri(
+ ContentMetadataMutations mutations, @Nullable Uri uri) {
+ if (uri == null) {
+ return mutations.remove(ContentMetadata.KEY_REDIRECTED_URI);
+ } else {
+ return mutations.set(ContentMetadata.KEY_REDIRECTED_URI, uri.toString());
+ }
+ }
+
+ private final Map<String, Object> editedValues;
+ private final List<String> removedValues;
+
+ /** Constructs a DefaultMetadataMutations. */
+ public ContentMetadataMutations() {
+ editedValues = new HashMap<>();
+ removedValues = new ArrayList<>();
+ }
+
+ /**
+ * Adds a mutation to set a metadata value. Passing {@code null} as {@code name} or {@code value}
+ * isn't allowed.
+ *
+ * @param name The name of the metadata value.
+ * @param value The value to be set.
+ * @return This instance, for convenience.
+ */
+ public ContentMetadataMutations set(String name, String value) {
+ return checkAndSet(name, value);
+ }
+
+ /**
+ * Adds a mutation to set a metadata value. Passing {@code null} as {@code name} isn't allowed.
+ *
+ * @param name The name of the metadata value.
+ * @param value The value to be set.
+ * @return This instance, for convenience.
+ */
+ public ContentMetadataMutations set(String name, long value) {
+ return checkAndSet(name, value);
+ }
+
+ /**
+ * Adds a mutation to set a metadata value. Passing {@code null} as {@code name} or {@code value}
+ * isn't allowed.
+ *
+ * @param name The name of the metadata value.
+ * @param value The value to be set.
+ * @return This instance, for convenience.
+ */
+ public ContentMetadataMutations set(String name, byte[] value) {
+ return checkAndSet(name, Arrays.copyOf(value, value.length));
+ }
+
+ /**
+ * Adds a mutation to remove a metadata value.
+ *
+ * @param name The name of the metadata value.
+ * @return This instance, for convenience.
+ */
+ public ContentMetadataMutations remove(String name) {
+ removedValues.add(name);
+ editedValues.remove(name);
+ return this;
+ }
+
+ /** Returns a list of names of metadata values to be removed. */
+ public List<String> getRemovedValues() {
+ return Collections.unmodifiableList(new ArrayList<>(removedValues));
+ }
+
+ /** Returns a map of metadata name, value pairs to be set. Values are copied. */
+ public Map<String, Object> getEditedValues() {
+ HashMap<String, Object> hashMap = new HashMap<>(editedValues);
+ for (Entry<String, Object> entry : hashMap.entrySet()) {
+ Object value = entry.getValue();
+ if (value instanceof byte[]) {
+ byte[] bytes = (byte[]) value;
+ entry.setValue(Arrays.copyOf(bytes, bytes.length));
+ }
+ }
+ return Collections.unmodifiableMap(hashMap);
+ }
+
+ private ContentMetadataMutations checkAndSet(String name, Object value) {
+ editedValues.put(Assertions.checkNotNull(name), Assertions.checkNotNull(value));
+ removedValues.remove(name);
+ return this;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java
new file mode 100644
index 0000000000..2602f834e7
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+/** Default implementation of {@link ContentMetadata}. Values are stored as byte arrays. */
+public final class DefaultContentMetadata implements ContentMetadata {
+
+ /** An empty DefaultContentMetadata. */
+ public static final DefaultContentMetadata EMPTY =
+ new DefaultContentMetadata(Collections.emptyMap());
+
+ private int hashCode;
+
+ private final Map<String, byte[]> metadata;
+
+ public DefaultContentMetadata() {
+ this(Collections.emptyMap());
+ }
+
+ /** @param metadata The metadata entries in their raw byte array form. */
+ public DefaultContentMetadata(Map<String, byte[]> metadata) {
+ this.metadata = Collections.unmodifiableMap(metadata);
+ }
+
+ /**
+ * Returns a copy {@link DefaultContentMetadata} with {@code mutations} applied. If {@code
+ * mutations} don't change anything, returns this instance.
+ */
+ public DefaultContentMetadata copyWithMutationsApplied(ContentMetadataMutations mutations) {
+ Map<String, byte[]> mutatedMetadata = applyMutations(metadata, mutations);
+ if (isMetadataEqual(metadata, mutatedMetadata)) {
+ return this;
+ }
+ return new DefaultContentMetadata(mutatedMetadata);
+ }
+
+ /** Returns the set of metadata entries in their raw byte array form. */
+ public Set<Entry<String, byte[]>> entrySet() {
+ return metadata.entrySet();
+ }
+
+ @Override
+ @Nullable
+ public final byte[] get(String name, @Nullable byte[] defaultValue) {
+ if (metadata.containsKey(name)) {
+ byte[] bytes = metadata.get(name);
+ return Arrays.copyOf(bytes, bytes.length);
+ } else {
+ return defaultValue;
+ }
+ }
+
+ @Override
+ @Nullable
+ public final String get(String name, @Nullable String defaultValue) {
+ if (metadata.containsKey(name)) {
+ byte[] bytes = metadata.get(name);
+ return new String(bytes, Charset.forName(C.UTF8_NAME));
+ } else {
+ return defaultValue;
+ }
+ }
+
+ @Override
+ public final long get(String name, long defaultValue) {
+ if (metadata.containsKey(name)) {
+ byte[] bytes = metadata.get(name);
+ return ByteBuffer.wrap(bytes).getLong();
+ } else {
+ return defaultValue;
+ }
+ }
+
+ @Override
+ public final boolean contains(String name) {
+ return metadata.containsKey(name);
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ return isMetadataEqual(metadata, ((DefaultContentMetadata) o).metadata);
+ }
+
+ @Override
+ public int hashCode() {
+ if (hashCode == 0) {
+ int result = 0;
+ for (Entry<String, byte[]> entry : metadata.entrySet()) {
+ result += entry.getKey().hashCode() ^ Arrays.hashCode(entry.getValue());
+ }
+ hashCode = result;
+ }
+ return hashCode;
+ }
+
+ private static boolean isMetadataEqual(Map<String, byte[]> first, Map<String, byte[]> second) {
+ if (first.size() != second.size()) {
+ return false;
+ }
+ for (Entry<String, byte[]> entry : first.entrySet()) {
+ byte[] value = entry.getValue();
+ byte[] otherValue = second.get(entry.getKey());
+ if (!Arrays.equals(value, otherValue)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static Map<String, byte[]> applyMutations(
+ Map<String, byte[]> otherMetadata, ContentMetadataMutations mutations) {
+ HashMap<String, byte[]> metadata = new HashMap<>(otherMetadata);
+ removeValues(metadata, mutations.getRemovedValues());
+ addValues(metadata, mutations.getEditedValues());
+ return metadata;
+ }
+
+ private static void removeValues(HashMap<String, byte[]> metadata, List<String> names) {
+ for (int i = 0; i < names.size(); i++) {
+ metadata.remove(names.get(i));
+ }
+ }
+
+ private static void addValues(HashMap<String, byte[]> metadata, Map<String, Object> values) {
+ for (String name : values.keySet()) {
+ metadata.put(name, getBytes(values.get(name)));
+ }
+ }
+
+ private static byte[] getBytes(Object value) {
+ if (value instanceof Long) {
+ return ByteBuffer.allocate(8).putLong((Long) value).array();
+ } else if (value instanceof String) {
+ return ((String) value).getBytes(Charset.forName(C.UTF8_NAME));
+ } else if (value instanceof byte[]) {
+ return (byte[]) value;
+ } else {
+ throw new IllegalArgumentException();
+ }
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java
new file mode 100644
index 0000000000..56eff06b25
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java
@@ -0,0 +1,89 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache.CacheException;
+import java.util.TreeSet;
+
+/** Evicts least recently used cache files first. */
+public final class LeastRecentlyUsedCacheEvictor implements CacheEvictor {
+
+ private final long maxBytes;
+ private final TreeSet<CacheSpan> leastRecentlyUsed;
+
+ private long currentSize;
+
+ public LeastRecentlyUsedCacheEvictor(long maxBytes) {
+ this.maxBytes = maxBytes;
+ this.leastRecentlyUsed = new TreeSet<>(LeastRecentlyUsedCacheEvictor::compare);
+ }
+
+ @Override
+ public boolean requiresCacheSpanTouches() {
+ return true;
+ }
+
+ @Override
+ public void onCacheInitialized() {
+ // Do nothing.
+ }
+
+ @Override
+ public void onStartFile(Cache cache, String key, long position, long length) {
+ if (length != C.LENGTH_UNSET) {
+ evictCache(cache, length);
+ }
+ }
+
+ @Override
+ public void onSpanAdded(Cache cache, CacheSpan span) {
+ leastRecentlyUsed.add(span);
+ currentSize += span.length;
+ evictCache(cache, 0);
+ }
+
+ @Override
+ public void onSpanRemoved(Cache cache, CacheSpan span) {
+ leastRecentlyUsed.remove(span);
+ currentSize -= span.length;
+ }
+
+ @Override
+ public void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) {
+ onSpanRemoved(cache, oldSpan);
+ onSpanAdded(cache, newSpan);
+ }
+
+ private void evictCache(Cache cache, long requiredSpace) {
+ while (currentSize + requiredSpace > maxBytes && !leastRecentlyUsed.isEmpty()) {
+ try {
+ cache.removeSpan(leastRecentlyUsed.first());
+ } catch (CacheException e) {
+ // do nothing.
+ }
+ }
+ }
+
+ private static int compare(CacheSpan lhs, CacheSpan rhs) {
+ long lastTouchTimestampDelta = lhs.lastTouchTimestamp - rhs.lastTouchTimestamp;
+ if (lastTouchTimestampDelta == 0) {
+ // Use the standard compareTo method as a tie-break.
+ return lhs.compareTo(rhs);
+ }
+ return lhs.lastTouchTimestamp < rhs.lastTouchTimestamp ? -1 : 1;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/NoOpCacheEvictor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/NoOpCacheEvictor.java
new file mode 100644
index 0000000000..75c1ad0a09
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/NoOpCacheEvictor.java
@@ -0,0 +1,57 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+
+/**
+ * Evictor that doesn't ever evict cache files.
+ *
+ * Warning: Using this evictor might have unforeseeable consequences if cache
+ * size is not managed elsewhere.
+ */
+public final class NoOpCacheEvictor implements CacheEvictor {
+
+ @Override
+ public boolean requiresCacheSpanTouches() {
+ return false;
+ }
+
+ @Override
+ public void onCacheInitialized() {
+ // Do nothing.
+ }
+
+ @Override
+ public void onStartFile(Cache cache, String key, long position, long maxLength) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onSpanAdded(Cache cache, CacheSpan span) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onSpanRemoved(Cache cache, CacheSpan span) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) {
+ // Do nothing.
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java
new file mode 100644
index 0000000000..9e36c48d88
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java
@@ -0,0 +1,812 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import android.os.ConditionVariable;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseIOException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.File;
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.NavigableSet;
+import java.util.Random;
+import java.util.Set;
+import java.util.TreeSet;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/**
+ * A {@link Cache} implementation that maintains an in-memory representation.
+ *
+ * <p>Only one instance of SimpleCache is allowed for a given directory at a given time.
+ *
+ * <p>To delete a SimpleCache, use {@link #delete(File, DatabaseProvider)} rather than deleting the
+ * directory and its contents directly. This is necessary to ensure that associated index data is
+ * also removed.
+ */
+public final class SimpleCache implements Cache {
+
+ private static final String TAG = "SimpleCache";
+ /**
+ * Cache files are distributed between a number of subdirectories. This helps to avoid poor
+ * performance in cases where the performance of the underlying file system (e.g. FAT32) scales
+ * badly with the number of files per directory. See
+ * https://github.com/google/ExoPlayer/issues/4253.
+ */
+ private static final int SUBDIRECTORY_COUNT = 10;
+
+ private static final String UID_FILE_SUFFIX = ".uid";
+
+ private static final HashSet<File> lockedCacheDirs = new HashSet<>();
+
+ private final File cacheDir;
+ private final CacheEvictor evictor;
+ private final CachedContentIndex contentIndex;
+ @Nullable private final CacheFileMetadataIndex fileIndex;
+ private final HashMap<String, ArrayList<Listener>> listeners;
+ private final Random random;
+ private final boolean touchCacheSpans;
+
+ private long uid;
+ private long totalSpace;
+ private boolean released;
+ private @MonotonicNonNull CacheException initializationException;
+
+ /**
+ * Returns whether {@code cacheFolder} is locked by a {@link SimpleCache} instance. To unlock the
+ * folder the {@link SimpleCache} instance should be released.
+ */
+ public static synchronized boolean isCacheFolderLocked(File cacheFolder) {
+ return lockedCacheDirs.contains(cacheFolder.getAbsoluteFile());
+ }
+
+ /**
+ * Deletes all content belonging to a cache instance.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param cacheDir The cache directory.
+ * @param databaseProvider The database in which index data is stored, or {@code null} if the
+ * cache used a legacy index.
+ */
+ @WorkerThread
+ public static void delete(File cacheDir, @Nullable DatabaseProvider databaseProvider) {
+ if (!cacheDir.exists()) {
+ return;
+ }
+
+ File[] files = cacheDir.listFiles();
+ if (files == null) {
+ cacheDir.delete();
+ return;
+ }
+
+ if (databaseProvider != null) {
+ // Make a best effort to read the cache UID and delete associated index data before deleting
+ // cache directory itself.
+ long uid = loadUid(files);
+ if (uid != UID_UNSET) {
+ try {
+ CacheFileMetadataIndex.delete(databaseProvider, uid);
+ } catch (DatabaseIOException e) {
+ Log.w(TAG, "Failed to delete file metadata: " + uid);
+ }
+ try {
+ CachedContentIndex.delete(databaseProvider, uid);
+ } catch (DatabaseIOException e) {
+ Log.w(TAG, "Failed to delete file metadata: " + uid);
+ }
+ }
+ }
+
+ Util.recursiveDelete(cacheDir);
+ }
+
+ /**
+ * 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. For download use cases where cache eviction should not
+ * occur, use {@link NoOpCacheEvictor}.
+ * @deprecated Use a constructor that takes a {@link DatabaseProvider} for improved performance.
+ */
+ @Deprecated
+ public SimpleCache(File cacheDir, CacheEvictor evictor) {
+ this(cacheDir, evictor, null, false);
+ }
+
+ /**
+ * 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. For download use cases where cache eviction should not
+ * occur, use {@link NoOpCacheEvictor}.
+ * @param secretKey If not null, cache keys will be stored encrypted on filesystem using AES/CBC.
+ * The key must be 16 bytes long.
+ * @deprecated Use a constructor that takes a {@link DatabaseProvider} for improved performance.
+ */
+ @Deprecated
+ @SuppressWarnings("deprecation")
+ public SimpleCache(File cacheDir, CacheEvictor evictor, @Nullable byte[] secretKey) {
+ this(cacheDir, evictor, secretKey, secretKey != 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. For download use cases where cache eviction should not
+ * occur, use {@link NoOpCacheEvictor}.
+ * @param secretKey If not null, cache keys will be stored encrypted on filesystem using AES/CBC.
+ * The key must be 16 bytes long.
+ * @param encrypt Whether the index will be encrypted when written. Must be false if {@code
+ * secretKey} is null.
+ * @deprecated Use a constructor that takes a {@link DatabaseProvider} for improved performance.
+ */
+ @Deprecated
+ public SimpleCache(
+ File cacheDir, CacheEvictor evictor, @Nullable byte[] secretKey, boolean encrypt) {
+ this(
+ cacheDir,
+ evictor,
+ /* databaseProvider= */ null,
+ secretKey,
+ encrypt,
+ /* preferLegacyIndex= */ true);
+ }
+
+ /**
+ * 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. For download use cases where cache eviction should not
+ * occur, use {@link NoOpCacheEvictor}.
+ * @param databaseProvider Provides the database in which the cache index is stored.
+ */
+ public SimpleCache(File cacheDir, CacheEvictor evictor, DatabaseProvider databaseProvider) {
+ this(
+ cacheDir,
+ evictor,
+ databaseProvider,
+ /* legacyIndexSecretKey= */ null,
+ /* legacyIndexEncrypt= */ false,
+ /* preferLegacyIndex= */ false);
+ }
+
+ /**
+ * Constructs the cache. The cache will delete any unrecognized files from the cache directory.
+ * Hence the directory cannot be used to store other files.
+ *
+ * @param cacheDir A dedicated cache directory.
+ * @param evictor The evictor to be used. For download use cases where cache eviction should not
+ * occur, use {@link NoOpCacheEvictor}.
+ * @param databaseProvider Provides the database in which the cache index is stored, or {@code
+ * null} to use a legacy index. Using a database index is highly recommended for performance
+ * reasons.
+ * @param legacyIndexSecretKey A 16 byte AES key for reading, and optionally writing, the legacy
+ * index. Not used by the database index, however should still be provided when using the
+ * database index in cases where upgrading from the legacy index may be necessary.
+ * @param legacyIndexEncrypt Whether to encrypt when writing to the legacy index. Must be {@code
+ * false} if {@code legacyIndexSecretKey} is {@code null}. Not used by the database index.
+ * @param preferLegacyIndex Whether to use the legacy index even if a {@code databaseProvider} is
+ * provided. Should be {@code false} in nearly all cases. Setting this to {@code true} is only
+ * useful for downgrading from the database index back to the legacy index.
+ */
+ public SimpleCache(
+ File cacheDir,
+ CacheEvictor evictor,
+ @Nullable DatabaseProvider databaseProvider,
+ @Nullable byte[] legacyIndexSecretKey,
+ boolean legacyIndexEncrypt,
+ boolean preferLegacyIndex) {
+ this(
+ cacheDir,
+ evictor,
+ new CachedContentIndex(
+ databaseProvider,
+ cacheDir,
+ legacyIndexSecretKey,
+ legacyIndexEncrypt,
+ preferLegacyIndex),
+ databaseProvider != null && !preferLegacyIndex
+ ? new CacheFileMetadataIndex(databaseProvider)
+ : null);
+ }
+
+ /* package */ SimpleCache(
+ File cacheDir,
+ CacheEvictor evictor,
+ CachedContentIndex contentIndex,
+ @Nullable CacheFileMetadataIndex fileIndex) {
+ if (!lockFolder(cacheDir)) {
+ throw new IllegalStateException("Another SimpleCache instance uses the folder: " + cacheDir);
+ }
+
+ this.cacheDir = cacheDir;
+ this.evictor = evictor;
+ this.contentIndex = contentIndex;
+ this.fileIndex = fileIndex;
+ listeners = new HashMap<>();
+ random = new Random();
+ touchCacheSpans = evictor.requiresCacheSpanTouches();
+ uid = UID_UNSET;
+
+ // Start cache initialization.
+ final ConditionVariable conditionVariable = new ConditionVariable();
+ new Thread("SimpleCache.initialize()") {
+ @Override
+ public void run() {
+ synchronized (SimpleCache.this) {
+ conditionVariable.open();
+ initialize();
+ SimpleCache.this.evictor.onCacheInitialized();
+ }
+ }
+ }.start();
+ conditionVariable.block();
+ }
+
+ /**
+ * Checks whether the cache was initialized successfully.
+ *
+ * @throws CacheException If an error occurred during initialization.
+ */
+ public synchronized void checkInitialization() throws CacheException {
+ if (initializationException != null) {
+ throw initializationException;
+ }
+ }
+
+ @Override
+ public synchronized long getUid() {
+ return uid;
+ }
+
+ @Override
+ public synchronized void release() {
+ if (released) {
+ return;
+ }
+ listeners.clear();
+ removeStaleSpans();
+ try {
+ contentIndex.store();
+ } catch (IOException e) {
+ Log.e(TAG, "Storing index file failed", e);
+ } finally {
+ unlockFolder(cacheDir);
+ released = true;
+ }
+ }
+
+ @Override
+ public synchronized NavigableSet<CacheSpan> addListener(String key, Listener listener) {
+ Assertions.checkState(!released);
+ ArrayList<Listener> listenersForKey = listeners.get(key);
+ if (listenersForKey == null) {
+ listenersForKey = new ArrayList<>();
+ listeners.put(key, listenersForKey);
+ }
+ listenersForKey.add(listener);
+ return getCachedSpans(key);
+ }
+
+ @Override
+ public synchronized void removeListener(String key, Listener listener) {
+ if (released) {
+ return;
+ }
+ ArrayList<Listener> listenersForKey = listeners.get(key);
+ if (listenersForKey != null) {
+ listenersForKey.remove(listener);
+ if (listenersForKey.isEmpty()) {
+ listeners.remove(key);
+ }
+ }
+ }
+
+ @NonNull
+ @Override
+ public synchronized NavigableSet<CacheSpan> getCachedSpans(String key) {
+ Assertions.checkState(!released);
+ CachedContent cachedContent = contentIndex.get(key);
+ return cachedContent == null || cachedContent.isEmpty()
+ ? new TreeSet<>()
+ : new TreeSet<CacheSpan>(cachedContent.getSpans());
+ }
+
+ @Override
+ public synchronized Set<String> getKeys() {
+ Assertions.checkState(!released);
+ return new HashSet<>(contentIndex.getKeys());
+ }
+
+ @Override
+ public synchronized long getCacheSpace() {
+ Assertions.checkState(!released);
+ return totalSpace;
+ }
+
+ @Override
+ public synchronized CacheSpan startReadWrite(String key, long position)
+ throws InterruptedException, CacheException {
+ Assertions.checkState(!released);
+ checkInitialization();
+
+ while (true) {
+ CacheSpan span = startReadWriteNonBlocking(key, position);
+ if (span != null) {
+ return span;
+ } else {
+ // Lock not available. We'll be woken up when a span is added, or when a locked span is
+ // released. We'll be able to make progress when either:
+ // 1. A span is added for the requested key that covers the requested position, in which
+ // case a read can be started.
+ // 2. The lock for the requested key is released, in which case a write can be started.
+ wait();
+ }
+ }
+ }
+
+ @Override
+ @Nullable
+ public synchronized CacheSpan startReadWriteNonBlocking(String key, long position)
+ throws CacheException {
+ Assertions.checkState(!released);
+ checkInitialization();
+
+ SimpleCacheSpan span = getSpan(key, position);
+
+ if (span.isCached) {
+ // Read case.
+ return touchSpan(key, span);
+ }
+
+ CachedContent cachedContent = contentIndex.getOrAdd(key);
+ if (!cachedContent.isLocked()) {
+ // Write case.
+ cachedContent.setLocked(true);
+ return span;
+ }
+
+ // Lock not available.
+ return null;
+ }
+
+ @Override
+ public synchronized File startFile(String key, long position, long length) throws CacheException {
+ Assertions.checkState(!released);
+ checkInitialization();
+
+ CachedContent cachedContent = contentIndex.get(key);
+ Assertions.checkNotNull(cachedContent);
+ Assertions.checkState(cachedContent.isLocked());
+ if (!cacheDir.exists()) {
+ // For some reason the cache directory doesn't exist. Make a best effort to create it.
+ cacheDir.mkdirs();
+ removeStaleSpans();
+ }
+ evictor.onStartFile(this, key, position, length);
+ // Randomly distribute files into subdirectories with a uniform distribution.
+ File fileDir = new File(cacheDir, Integer.toString(random.nextInt(SUBDIRECTORY_COUNT)));
+ if (!fileDir.exists()) {
+ fileDir.mkdir();
+ }
+ long lastTouchTimestamp = System.currentTimeMillis();
+ return SimpleCacheSpan.getCacheFile(fileDir, cachedContent.id, position, lastTouchTimestamp);
+ }
+
+ @Override
+ public synchronized void commitFile(File file, long length) throws CacheException {
+ Assertions.checkState(!released);
+ if (!file.exists()) {
+ return;
+ }
+ if (length == 0) {
+ file.delete();
+ return;
+ }
+
+ SimpleCacheSpan span =
+ Assertions.checkNotNull(SimpleCacheSpan.createCacheEntry(file, length, contentIndex));
+ CachedContent cachedContent = Assertions.checkNotNull(contentIndex.get(span.key));
+ Assertions.checkState(cachedContent.isLocked());
+
+ // Check if the span conflicts with the set content length
+ long contentLength = ContentMetadata.getContentLength(cachedContent.getMetadata());
+ if (contentLength != C.LENGTH_UNSET) {
+ Assertions.checkState((span.position + span.length) <= contentLength);
+ }
+
+ if (fileIndex != null) {
+ String fileName = file.getName();
+ try {
+ fileIndex.set(fileName, span.length, span.lastTouchTimestamp);
+ } catch (IOException e) {
+ throw new CacheException(e);
+ }
+ }
+ addSpan(span);
+ try {
+ contentIndex.store();
+ } catch (IOException e) {
+ throw new CacheException(e);
+ }
+ notifyAll();
+ }
+
+ @Override
+ public synchronized void releaseHoleSpan(CacheSpan holeSpan) {
+ Assertions.checkState(!released);
+ CachedContent cachedContent = contentIndex.get(holeSpan.key);
+ Assertions.checkNotNull(cachedContent);
+ Assertions.checkState(cachedContent.isLocked());
+ cachedContent.setLocked(false);
+ contentIndex.maybeRemove(cachedContent.key);
+ notifyAll();
+ }
+
+ @Override
+ public synchronized void removeSpan(CacheSpan span) {
+ Assertions.checkState(!released);
+ removeSpanInternal(span);
+ }
+
+ @Override
+ public synchronized boolean isCached(String key, long position, long length) {
+ Assertions.checkState(!released);
+ CachedContent cachedContent = contentIndex.get(key);
+ return cachedContent != null && cachedContent.getCachedBytesLength(position, length) >= length;
+ }
+
+ @Override
+ public synchronized long getCachedLength(String key, long position, long length) {
+ Assertions.checkState(!released);
+ CachedContent cachedContent = contentIndex.get(key);
+ return cachedContent != null ? cachedContent.getCachedBytesLength(position, length) : -length;
+ }
+
+ @Override
+ public synchronized void applyContentMetadataMutations(
+ String key, ContentMetadataMutations mutations) throws CacheException {
+ Assertions.checkState(!released);
+ checkInitialization();
+
+ contentIndex.applyContentMetadataMutations(key, mutations);
+ try {
+ contentIndex.store();
+ } catch (IOException e) {
+ throw new CacheException(e);
+ }
+ }
+
+ @Override
+ public synchronized ContentMetadata getContentMetadata(String key) {
+ Assertions.checkState(!released);
+ return contentIndex.getContentMetadata(key);
+ }
+
+ /** Ensures that the cache's in-memory representation has been initialized. */
+ private void initialize() {
+ if (!cacheDir.exists()) {
+ if (!cacheDir.mkdirs()) {
+ String message = "Failed to create cache directory: " + cacheDir;
+ Log.e(TAG, message);
+ initializationException = new CacheException(message);
+ return;
+ }
+ }
+
+ File[] files = cacheDir.listFiles();
+ if (files == null) {
+ String message = "Failed to list cache directory files: " + cacheDir;
+ Log.e(TAG, message);
+ initializationException = new CacheException(message);
+ return;
+ }
+
+ uid = loadUid(files);
+ if (uid == UID_UNSET) {
+ try {
+ uid = createUid(cacheDir);
+ } catch (IOException e) {
+ String message = "Failed to create cache UID: " + cacheDir;
+ Log.e(TAG, message, e);
+ initializationException = new CacheException(message, e);
+ return;
+ }
+ }
+
+ try {
+ contentIndex.initialize(uid);
+ if (fileIndex != null) {
+ fileIndex.initialize(uid);
+ Map<String, CacheFileMetadata> fileMetadata = fileIndex.getAll();
+ loadDirectory(cacheDir, /* isRoot= */ true, files, fileMetadata);
+ fileIndex.removeAll(fileMetadata.keySet());
+ } else {
+ loadDirectory(cacheDir, /* isRoot= */ true, files, /* fileMetadata= */ null);
+ }
+ } catch (IOException e) {
+ String message = "Failed to initialize cache indices: " + cacheDir;
+ Log.e(TAG, message, e);
+ initializationException = new CacheException(message, e);
+ return;
+ }
+
+ contentIndex.removeEmpty();
+ try {
+ contentIndex.store();
+ } catch (IOException e) {
+ Log.e(TAG, "Storing index file failed", e);
+ }
+ }
+
+ /**
+ * Loads a cache directory. If the root directory is passed, also loads any subdirectories.
+ *
+ * @param directory The directory.
+ * @param isRoot Whether the directory is the root directory.
+ * @param files The files belonging to the directory.
+ * @param fileMetadata A mutable map containing cache file metadata, keyed by file name. The map
+ * is modified by removing entries for all loaded files. When the method call returns, the map
+ * will contain only metadata that was unused. May be null if no file metadata is available.
+ */
+ private void loadDirectory(
+ File directory,
+ boolean isRoot,
+ @Nullable File[] files,
+ @Nullable Map<String, CacheFileMetadata> fileMetadata) {
+ if (files == null || files.length == 0) {
+ // Either (a) directory isn't really a directory (b) it's empty, or (c) listing files failed.
+ if (!isRoot) {
+ // For (a) and (b) deletion is the desired result. For (c) it will be a no-op if the
+ // directory is non-empty, so there's no harm in trying.
+ directory.delete();
+ }
+ return;
+ }
+ for (File file : files) {
+ String fileName = file.getName();
+ if (isRoot && fileName.indexOf('.') == -1) {
+ loadDirectory(file, /* isRoot= */ false, file.listFiles(), fileMetadata);
+ } else {
+ if (isRoot
+ && (CachedContentIndex.isIndexFile(fileName) || fileName.endsWith(UID_FILE_SUFFIX))) {
+ // Skip expected UID and index files in the root directory.
+ continue;
+ }
+ long length = C.LENGTH_UNSET;
+ long lastTouchTimestamp = C.TIME_UNSET;
+ CacheFileMetadata metadata = fileMetadata != null ? fileMetadata.remove(fileName) : null;
+ if (metadata != null) {
+ length = metadata.length;
+ lastTouchTimestamp = metadata.lastTouchTimestamp;
+ }
+ SimpleCacheSpan span =
+ SimpleCacheSpan.createCacheEntry(file, length, lastTouchTimestamp, contentIndex);
+ if (span != null) {
+ addSpan(span);
+ } else {
+ file.delete();
+ }
+ }
+ }
+ }
+
+ /**
+ * Touches a cache span, returning the updated result. If the evictor does not require cache spans
+ * to be touched, then this method does nothing and the span is returned without modification.
+ *
+ * @param key The key of the span being touched.
+ * @param span The span being touched.
+ * @return The updated span.
+ */
+ private SimpleCacheSpan touchSpan(String key, SimpleCacheSpan span) {
+ if (!touchCacheSpans) {
+ return span;
+ }
+ String fileName = Assertions.checkNotNull(span.file).getName();
+ long length = span.length;
+ long lastTouchTimestamp = System.currentTimeMillis();
+ boolean updateFile = false;
+ if (fileIndex != null) {
+ try {
+ fileIndex.set(fileName, length, lastTouchTimestamp);
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to update index with new touch timestamp.");
+ }
+ } else {
+ // Updating the file itself to incorporate the new last touch timestamp is much slower than
+ // updating the file index. Hence we only update the file if we don't have a file index.
+ updateFile = true;
+ }
+ SimpleCacheSpan newSpan =
+ contentIndex.get(key).setLastTouchTimestamp(span, lastTouchTimestamp, updateFile);
+ notifySpanTouched(span, newSpan);
+ return newSpan;
+ }
+
+ /**
+ * Returns the cache span corresponding to the provided lookup span.
+ *
+ * <p>If the lookup position is contained by an existing entry in the cache, then the returned
+ * span defines the file in which the data is stored. If the lookup position is not contained by
+ * an existing entry, then the returned span defines the maximum extents of the hole in the cache.
+ *
+ * @param key The key of the span being requested.
+ * @param position The position of the span being requested.
+ * @return The corresponding cache {@link SimpleCacheSpan}.
+ */
+ private SimpleCacheSpan getSpan(String key, long position) {
+ CachedContent cachedContent = contentIndex.get(key);
+ if (cachedContent == null) {
+ return SimpleCacheSpan.createOpenHole(key, position);
+ }
+ while (true) {
+ SimpleCacheSpan span = cachedContent.getSpan(position);
+ if (span.isCached && span.file.length() != span.length) {
+ // The file has been modified or deleted underneath us. It's likely that other files will
+ // have been modified too, so scan the whole in-memory representation.
+ removeStaleSpans();
+ continue;
+ }
+ return span;
+ }
+ }
+
+ /**
+ * Adds a cached span to the in-memory representation.
+ *
+ * @param span The span to be added.
+ */
+ private void addSpan(SimpleCacheSpan span) {
+ contentIndex.getOrAdd(span.key).addSpan(span);
+ totalSpace += span.length;
+ notifySpanAdded(span);
+ }
+
+ private void removeSpanInternal(CacheSpan span) {
+ CachedContent cachedContent = contentIndex.get(span.key);
+ if (cachedContent == null || !cachedContent.removeSpan(span)) {
+ return;
+ }
+ totalSpace -= span.length;
+ if (fileIndex != null) {
+ String fileName = span.file.getName();
+ try {
+ fileIndex.remove(fileName);
+ } catch (IOException e) {
+ // This will leave a stale entry in the file index. It will be removed next time the cache
+ // is initialized.
+ Log.w(TAG, "Failed to remove file index entry for: " + fileName);
+ }
+ }
+ contentIndex.maybeRemove(cachedContent.key);
+ notifySpanRemoved(span);
+ }
+
+ /**
+ * Scans all of the cached spans in the in-memory representation, removing any for which the
+ * underlying file lengths no longer match.
+ */
+ private void removeStaleSpans() {
+ ArrayList<CacheSpan> spansToBeRemoved = new ArrayList<>();
+ for (CachedContent cachedContent : contentIndex.getAll()) {
+ for (CacheSpan span : cachedContent.getSpans()) {
+ if (span.file.length() != span.length) {
+ spansToBeRemoved.add(span);
+ }
+ }
+ }
+ for (int i = 0; i < spansToBeRemoved.size(); i++) {
+ removeSpanInternal(spansToBeRemoved.get(i));
+ }
+ }
+
+ private void notifySpanRemoved(CacheSpan span) {
+ ArrayList<Listener> keyListeners = listeners.get(span.key);
+ if (keyListeners != null) {
+ for (int i = keyListeners.size() - 1; i >= 0; i--) {
+ keyListeners.get(i).onSpanRemoved(this, span);
+ }
+ }
+ evictor.onSpanRemoved(this, span);
+ }
+
+ private void notifySpanAdded(SimpleCacheSpan span) {
+ ArrayList<Listener> keyListeners = listeners.get(span.key);
+ if (keyListeners != null) {
+ for (int i = keyListeners.size() - 1; i >= 0; i--) {
+ keyListeners.get(i).onSpanAdded(this, span);
+ }
+ }
+ evictor.onSpanAdded(this, span);
+ }
+
+ private void notifySpanTouched(SimpleCacheSpan oldSpan, CacheSpan newSpan) {
+ ArrayList<Listener> keyListeners = listeners.get(oldSpan.key);
+ if (keyListeners != null) {
+ for (int i = keyListeners.size() - 1; i >= 0; i--) {
+ keyListeners.get(i).onSpanTouched(this, oldSpan, newSpan);
+ }
+ }
+ evictor.onSpanTouched(this, oldSpan, newSpan);
+ }
+
+ /**
+ * Loads the cache UID from the files belonging to the root directory.
+ *
+ * @param files The files belonging to the root directory.
+ * @return The loaded UID, or {@link #UID_UNSET} if a UID has not yet been created.
+ */
+ private static long loadUid(File[] files) {
+ for (File file : files) {
+ String fileName = file.getName();
+ if (fileName.endsWith(UID_FILE_SUFFIX)) {
+ try {
+ return parseUid(fileName);
+ } catch (NumberFormatException e) {
+ // This should never happen, but if it does delete the malformed UID file and continue.
+ Log.e(TAG, "Malformed UID file: " + file);
+ file.delete();
+ }
+ }
+ }
+ return UID_UNSET;
+ }
+
+ @SuppressWarnings("TrulyRandom")
+ private static long createUid(File directory) throws IOException {
+ // Generate a non-negative UID.
+ long uid = new SecureRandom().nextLong();
+ uid = uid == Long.MIN_VALUE ? 0 : Math.abs(uid);
+ // Persist it as a file.
+ String hexUid = Long.toString(uid, /* radix= */ 16);
+ File hexUidFile = new File(directory, hexUid + UID_FILE_SUFFIX);
+ if (!hexUidFile.createNewFile()) {
+ // False means that the file already exists, so this should never happen.
+ throw new IOException("Failed to create UID file: " + hexUidFile);
+ }
+ return uid;
+ }
+
+ private static long parseUid(String fileName) {
+ return Long.parseLong(fileName.substring(0, fileName.indexOf('.')), /* radix= */ 16);
+ }
+
+ private static synchronized boolean lockFolder(File cacheDir) {
+ return lockedCacheDirs.add(cacheDir.getAbsoluteFile());
+ }
+
+ private static synchronized void unlockFolder(File cacheDir) {
+ lockedCacheDirs.remove(cacheDir.getAbsoluteFile());
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java
new file mode 100644
index 0000000000..6e7bec301f
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java
@@ -0,0 +1,217 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
+
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.File;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** This class stores span metadata in filename. */
+/* package */ final class SimpleCacheSpan extends CacheSpan {
+
+ /* package */ static final String COMMON_SUFFIX = ".exo";
+
+ private static final String SUFFIX = ".v3" + COMMON_SUFFIX;
+ private static final Pattern CACHE_FILE_PATTERN_V1 = Pattern.compile(
+ "^(.+)\\.(\\d+)\\.(\\d+)\\.v1\\.exo$", Pattern.DOTALL);
+ private static final Pattern CACHE_FILE_PATTERN_V2 = Pattern.compile(
+ "^(.+)\\.(\\d+)\\.(\\d+)\\.v2\\.exo$", Pattern.DOTALL);
+ private static final Pattern CACHE_FILE_PATTERN_V3 = Pattern.compile(
+ "^(\\d+)\\.(\\d+)\\.(\\d+)\\.v3\\.exo$", Pattern.DOTALL);
+
+ /**
+ * Returns a new {@link File} instance from {@code cacheDir}, {@code id}, {@code position}, {@code
+ * timestamp}.
+ *
+ * @param cacheDir The parent abstract pathname.
+ * @param id The cache file id.
+ * @param position The position of the stored data in the original stream.
+ * @param timestamp The file timestamp.
+ * @return The cache file.
+ */
+ public static File getCacheFile(File cacheDir, int id, long position, long timestamp) {
+ return new File(cacheDir, id + "." + position + "." + timestamp + SUFFIX);
+ }
+
+ /**
+ * Creates a lookup span.
+ *
+ * @param key The cache key.
+ * @param position The position of the {@link CacheSpan} in the original stream.
+ * @return The span.
+ */
+ public static SimpleCacheSpan createLookup(String key, long position) {
+ return new SimpleCacheSpan(key, position, C.LENGTH_UNSET, C.TIME_UNSET, null);
+ }
+
+ /**
+ * Creates an open hole span.
+ *
+ * @param key The cache key.
+ * @param position The position of the {@link CacheSpan} in the original stream.
+ * @return The span.
+ */
+ public static SimpleCacheSpan createOpenHole(String key, long position) {
+ return new SimpleCacheSpan(key, position, C.LENGTH_UNSET, C.TIME_UNSET, null);
+ }
+
+ /**
+ * Creates a closed hole span.
+ *
+ * @param key The cache key.
+ * @param position The position of the {@link CacheSpan} in the original stream.
+ * @param length The length of the {@link CacheSpan}.
+ * @return The span.
+ */
+ public static SimpleCacheSpan createClosedHole(String key, long position, long length) {
+ return new SimpleCacheSpan(key, position, length, C.TIME_UNSET, null);
+ }
+
+ /**
+ * Creates a cache span from an underlying cache file. Upgrades the file if necessary.
+ *
+ * @param file The cache file.
+ * @param length The length of the cache file in bytes, or {@link C#LENGTH_UNSET} to query the
+ * underlying file system. Querying the underlying file system can be expensive, so callers
+ * that already know the length of the file should pass it explicitly.
+ * @return The span, or null if the file name is not correctly formatted, or if the id is not
+ * present in the content index, or if the length is 0.
+ */
+ @Nullable
+ public static SimpleCacheSpan createCacheEntry(File file, long length, CachedContentIndex index) {
+ return createCacheEntry(file, length, /* lastTouchTimestamp= */ C.TIME_UNSET, index);
+ }
+
+ /**
+ * Creates a cache span from an underlying cache file. Upgrades the file if necessary.
+ *
+ * @param file The cache file.
+ * @param length The length of the cache file in bytes, or {@link C#LENGTH_UNSET} to query the
+ * underlying file system. Querying the underlying file system can be expensive, so callers
+ * that already know the length of the file should pass it explicitly.
+ * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} to use the file
+ * timestamp.
+ * @return The span, or null if the file name is not correctly formatted, or if the id is not
+ * present in the content index, or if the length is 0.
+ */
+ @Nullable
+ public static SimpleCacheSpan createCacheEntry(
+ File file, long length, long lastTouchTimestamp, CachedContentIndex index) {
+ String name = file.getName();
+ if (!name.endsWith(SUFFIX)) {
+ @Nullable File upgradedFile = upgradeFile(file, index);
+ if (upgradedFile == null) {
+ return null;
+ }
+ file = upgradedFile;
+ name = file.getName();
+ }
+
+ Matcher matcher = CACHE_FILE_PATTERN_V3.matcher(name);
+ if (!matcher.matches()) {
+ return null;
+ }
+
+ int id = Integer.parseInt(matcher.group(1));
+ String key = index.getKeyForId(id);
+ if (key == null) {
+ return null;
+ }
+
+ if (length == C.LENGTH_UNSET) {
+ length = file.length();
+ }
+ if (length == 0) {
+ return null;
+ }
+
+ long position = Long.parseLong(matcher.group(2));
+ if (lastTouchTimestamp == C.TIME_UNSET) {
+ lastTouchTimestamp = Long.parseLong(matcher.group(3));
+ }
+ return new SimpleCacheSpan(key, position, length, lastTouchTimestamp, file);
+ }
+
+ /**
+ * Upgrades the cache file if it is created by an earlier version of {@link SimpleCache}.
+ *
+ * @param file The cache file.
+ * @param index Cached content index.
+ * @return Upgraded cache file or {@code null} if the file name is not correctly formatted or the
+ * file can not be renamed.
+ */
+ @Nullable
+ private static File upgradeFile(File file, CachedContentIndex index) {
+ String key;
+ String filename = file.getName();
+ Matcher matcher = CACHE_FILE_PATTERN_V2.matcher(filename);
+ if (matcher.matches()) {
+ key = Util.unescapeFileName(matcher.group(1));
+ if (key == null) {
+ return null;
+ }
+ } else {
+ matcher = CACHE_FILE_PATTERN_V1.matcher(filename);
+ if (!matcher.matches()) {
+ return null;
+ }
+ key = matcher.group(1); // Keys were not escaped in version 1.
+ }
+
+ File newCacheFile =
+ getCacheFile(
+ Assertions.checkStateNotNull(file.getParentFile()),
+ index.assignIdForKey(key),
+ Long.parseLong(matcher.group(2)),
+ Long.parseLong(matcher.group(3)));
+ if (!file.renameTo(newCacheFile)) {
+ return null;
+ }
+ return newCacheFile;
+ }
+
+ /**
+ * @param key The cache key.
+ * @param position The position of the {@link CacheSpan} in the original stream.
+ * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an
+ * open-ended hole.
+ * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} if {@link
+ * #isCached} is false.
+ * @param file The file corresponding to this {@link CacheSpan}, or null if it's a hole.
+ */
+ private SimpleCacheSpan(
+ String key, long position, long length, long lastTouchTimestamp, @Nullable File file) {
+ super(key, position, length, lastTouchTimestamp, file);
+ }
+
+ /**
+ * Returns a copy of this CacheSpan with a new file and last touch timestamp.
+ *
+ * @param file The new file.
+ * @param lastTouchTimestamp The new last touch time.
+ * @return A copy with the new file and last touch timestamp.
+ * @throws IllegalStateException If called on a non-cached span (i.e. {@link #isCached} is false).
+ */
+ public SimpleCacheSpan copyWithFileAndLastTouchTimestamp(File file, long lastTouchTimestamp) {
+ Assertions.checkState(isCached);
+ return new SimpleCacheSpan(key, position, length, lastTouchTimestamp, file);
+ }
+
+}