SimpleCacheSpan.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 androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Util;
import java.io.File;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** A {@link CacheSpan} that encodes metadata into the names of the underlying cache files. */
/* 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 resource.
   * @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 of the resource.
   * @param position The position of the span in the resource.
   * @return The span.
   */
  public static SimpleCacheSpan createLookup(String key, long position) {
    return new SimpleCacheSpan(key, position, C.LENGTH_UNSET, C.TIME_UNSET, null);
  }

  /**
   * Creates a hole span.
   *
   * @param key The cache key of the resource.
   * @param position The position of the span in the resource.
   * @param length The length of the span, or {@link C#LENGTH_UNSET} if unbounded.
   * @return The hole span.
   */
  public static SimpleCacheSpan createHole(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.
   * @param index The cached content index.
   * @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.
   * @param index The cached content index.
   * @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(Assertions.checkNotNull(matcher.group(1)));
    @Nullable 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(Assertions.checkNotNull(matcher.group(2)));
    if (lastTouchTimestamp == C.TIME_UNSET) {
      lastTouchTimestamp = Long.parseLong(Assertions.checkNotNull(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 The 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) {
    @Nullable String key = null;
    String filename = file.getName();
    Matcher matcher = CACHE_FILE_PATTERN_V2.matcher(filename);
    if (matcher.matches()) {
      key = Util.unescapeFileName(Assertions.checkNotNull(matcher.group(1)));
    } else {
      matcher = CACHE_FILE_PATTERN_V1.matcher(filename);
      if (matcher.matches()) {
        key = Assertions.checkNotNull(matcher.group(1)); // Keys were not escaped in version 1.
      }
    }

    if (key == null) {
      return null;
    }

    File newCacheFile =
        getCacheFile(
            Assertions.checkStateNotNull(file.getParentFile()),
            index.assignIdForKey(key),
            Long.parseLong(Assertions.checkNotNull(matcher.group(2))),
            Long.parseLong(Assertions.checkNotNull(matcher.group(3))));
    if (!file.renameTo(newCacheFile)) {
      return null;
    }
    return newCacheFile;
  }

  /**
   * @param key The cache key of the resource.
   * @param position The position of the span in the resource.
   * @param length The length of the span, 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 span, 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);
  }
}