summaryrefslogtreecommitdiffstats
path: root/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java')
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java812
1 files changed, 812 insertions, 0 deletions
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());
+ }
+}