SimpleCache.java

/*
 * 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 androidx.media3.datasource.cache;

import android.os.ConditionVariable;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.media3.common.C;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.database.DatabaseIOException;
import androidx.media3.database.DatabaseProvider;
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.
 */
@UnstableApi
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
  @SuppressWarnings("deprecation")
  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("ExoPlayer:SimpleCacheInit") {
      @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);
    Assertions.checkNotNull(key);
    Assertions.checkNotNull(listener);
    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);
      }
    }
  }

  @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, long length)
      throws InterruptedException, CacheException {
    Assertions.checkState(!released);
    checkInitialization();

    while (true) {
      CacheSpan span = startReadWriteNonBlocking(key, position, length);
      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, long length)
      throws CacheException {
    Assertions.checkState(!released);
    checkInitialization();

    SimpleCacheSpan span = getSpan(key, position, length);

    if (span.isCached) {
      // Read case.
      return touchSpan(key, span);
    }

    CachedContent cachedContent = contentIndex.getOrAdd(key);
    if (cachedContent.lockRange(position, span.length)) {
      // Write case.
      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.isFullyLocked(position, length));
    if (!cacheDir.exists()) {
      // The cache directory has been deleted from underneath us. Recreate it, and remove in-memory
      // spans corresponding to cache files that no longer exist.
      createCacheDirectories(cacheDir);
      removeStaleSpans();
    }
    evictor.onStartFile(this, key, position, length);
    // Randomly distribute files into subdirectories with a uniform distribution.
    File cacheSubDir = new File(cacheDir, Integer.toString(random.nextInt(SUBDIRECTORY_COUNT)));
    if (!cacheSubDir.exists()) {
      createCacheDirectories(cacheSubDir);
    }
    long lastTouchTimestamp = System.currentTimeMillis();
    return SimpleCacheSpan.getCacheFile(
        cacheSubDir, 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.isFullyLocked(span.position, span.length));

    // 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 = Assertions.checkNotNull(contentIndex.get(holeSpan.key));
    cachedContent.unlockRange(holeSpan.position);
    contentIndex.maybeRemove(cachedContent.key);
    notifyAll();
  }

  @Override
  public synchronized void removeResource(String key) {
    Assertions.checkState(!released);
    for (CacheSpan span : getCachedSpans(key)) {
      removeSpanInternal(span);
    }
  }

  @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);
    @Nullable 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);
    if (length == C.LENGTH_UNSET) {
      length = Long.MAX_VALUE;
    }
    @Nullable CachedContent cachedContent = contentIndex.get(key);
    return cachedContent != null ? cachedContent.getCachedBytesLength(position, length) : -length;
  }

  @Override
  public synchronized long getCachedBytes(String key, long position, long length) {
    long endPosition = length == C.LENGTH_UNSET ? Long.MAX_VALUE : position + length;
    if (endPosition < 0) {
      // The calculation rolled over (length is probably Long.MAX_VALUE).
      endPosition = Long.MAX_VALUE;
    }
    long currentPosition = position;
    long cachedBytes = 0;
    while (currentPosition < endPosition) {
      long maxRemainingLength = endPosition - currentPosition;
      long blockLength = getCachedLength(key, currentPosition, maxRemainingLength);
      if (blockLength > 0) {
        cachedBytes += blockLength;
      } else {
        // There's a hole of length -blockLength.
        blockLength = -blockLength;
      }
      currentPosition += blockLength;
    }
    return cachedBytes;
  }

  @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()) {
      try {
        createCacheDirectories(cacheDir);
      } catch (CacheException e) {
        initializationException = e;
        return;
      }
    }

    @Nullable 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;
        @Nullable
        CacheFileMetadata metadata = fileMetadata != null ? fileMetadata.remove(fileName) : null;
        if (metadata != null) {
          length = metadata.length;
          lastTouchTimestamp = metadata.lastTouchTimestamp;
        }
        @Nullable
        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 key and range. See {@link
   * Cache#startReadWrite(String, long, long)} for detailed descriptions of the returned spans.
   *
   * @param key The key of the span being requested.
   * @param position The position of the span being requested.
   * @param length The length of the span, or {@link C#LENGTH_UNSET} if unbounded.
   * @return The corresponding cache {@link SimpleCacheSpan}.
   */
  private SimpleCacheSpan getSpan(String key, long position, long length) {
    @Nullable CachedContent cachedContent = contentIndex.get(key);
    if (cachedContent == null) {
      return SimpleCacheSpan.createHole(key, position, length);
    }
    while (true) {
      SimpleCacheSpan span = cachedContent.getSpan(position, length);
      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) {
    @Nullable 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) {
    @Nullable 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) {
    @Nullable 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) {
    @Nullable 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 void createCacheDirectories(File cacheDir) throws CacheException {
    // If mkdirs() returns false, double check that the directory doesn't exist before throwing.
    if (!cacheDir.mkdirs() && !cacheDir.isDirectory()) {
      String message = "Failed to create cache directory: " + cacheDir;
      Log.e(TAG, message);
      throw new CacheException(message);
    }
  }

  private static synchronized boolean lockFolder(File cacheDir) {
    return lockedCacheDirs.add(cacheDir.getAbsoluteFile());
  }

  private static synchronized void unlockFolder(File cacheDir) {
    lockedCacheDirs.remove(cacheDir.getAbsoluteFile());
  }
}