diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache')
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); + } + +} |