CacheFileMetadataIndex.java

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

import static androidx.media3.common.util.Assertions.checkNotNull;

import android.content.ContentValues;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import androidx.annotation.WorkerThread;
import androidx.media3.common.util.Assertions;
import androidx.media3.database.DatabaseIOException;
import androidx.media3.database.DatabaseProvider;
import androidx.media3.database.VersionTable;
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 = checkNotNull(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;
  }
}