/* * Copyright (C) 2015 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 com.android.usbtuner.exoplayer.cache; import android.media.MediaFormat; import android.os.ConditionVariable; import android.os.HandlerThread; import android.support.annotation.VisibleForTesting; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; import android.util.Pair; import com.google.android.exoplayer.SampleHolder; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; /** * Manages {@link SampleCache} objects. *

* The cache manager can be disabled, while running, if the write throughput to the associated * external storage is detected to be lower than a threshold {@code MINIMUM_DISK_WRITE_SPEED_MBPS}". * This leads to restarting playback flow. */ public class CacheManager { private static final String TAG = "CacheManager"; private static final boolean DEBUG = false; // Constants for the disk write speed checking private static final long MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK = 10L * 1024 * 1024; // Checks for every 10M disk write private static final int MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK = 15 * 1024; private static final int MAXIMUM_SPEED_CHECK_COUNT = 5; // Checks only 5 times private static final int MINIMUM_DISK_WRITE_SPEED_MBPS = 3; // 3 Megabytes per second private final SampleCache.SampleCacheFactory mSampleCacheFactory; private final Map> mCacheMap = new ArrayMap<>(); private final Map mEvictListeners = new ArrayMap<>(); private final StorageManager mStorageManager; private final HandlerThread mIoHandlerThread = new HandlerThread(TAG); private long mCacheSize = 0; private final CacheSet mPendingDelete = new CacheSet(); private final CacheListener mCacheListener = new CacheListener() { @Override public void onWrite(SampleCache cache) { mCacheSize += cache.getSize(); } @Override public void onDelete(SampleCache cache) { mPendingDelete.remove(cache); mCacheSize -= cache.getSize(); } }; private volatile boolean mClosed = false; private int mMinSampleSizeForSpeedCheck = MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK; private long mTotalWriteSize; private long mTotalWriteTimeNs; private volatile int mSpeedCheckCount; private boolean mDisabled = false; public interface CacheListener { void onWrite(SampleCache cache); void onDelete(SampleCache cache); } public interface EvictListener { void onCacheEvicted(String id, long createdTimeMs); } /** * Handles I/O * between CacheManager and {@link com.android.usbtuner.exoplayer.SampleExtractor}. */ public interface SampleBuffer { /** * Initializes SampleBuffer. * @param Ids track identifiers for storage read/write. * @param mediaFormats meta-data for each track, this will be saved to storage in recording. * @throws IOException */ void init(@NonNull List Ids, @Nullable List mediaFormats) throws IOException; /** * Selects the track {@code index} for reading sample data. */ void selectTrack(int index); /** * Deselects the track at {@code index}, * so that no more samples will be read from the track. */ void deselectTrack(int index); /** * Writes sample to storage. * * @param index track index * @param sample sample to write at storage * @param conditionVariable notifies the completion of writing sample. * @throws IOException */ void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable) throws IOException; /** * Checks whether storage write speed is slow. */ boolean isWriteSpeedSlow(int sampleSize, long writeDurationNs); /** * Handles when write speed is slow. */ void handleWriteSpeedSlow(); /** * Sets the flag when EoS was met. */ void setEos(); /** * Reads the next sample in the track at index {@code track} into {@code sampleHolder}, * returning {@link com.google.android.exoplayer.SampleSource#SAMPLE_READ} * if it is available. * If the next sample is not available, * returns {@link com.google.android.exoplayer.SampleSource#NOTHING_READ}. */ int readSample(int index, SampleHolder outSample); /** * Seeks to the specified time in microseconds. */ void seekTo(long positionUs); /** * Returns an estimate of the position up to which data is buffered. */ long getBufferedPositionUs(); /** * Returns whether there is buffered data. */ boolean continueBuffering(long positionUs); /** * Cleans up and releases everything. */ void release(); } /** * Storage configuration and policy manager for {@link CacheManager} */ public interface StorageManager { /** * Provides eligible storage directory for {@link CacheManager}. * * @return a directory to save cache chunks and meta files */ File getCacheDir(); /** * Cleans up storage. */ void clearStorage(); /** * Informs whether the storage is used for persistent use. (eg. dvr recording/play) * * @return {@code true} if stored files are persistent */ boolean isPersistent(); /** * Informs whether the storage usage exceeds pre-determined size. * * @param cacheSize the current total usage of Storage in bytes. * @param pendingDelete the current storage usage which will be deleted in near future by * bytes * @return {@code true} if it reached pre-determined max size */ boolean reachedStorageMax(long cacheSize, long pendingDelete); /** * Informs whether the storage has enough remained space. * * @param pendingDelete the current storage usage which will be deleted in near future by * bytes * @return {@code true} if it has enough space */ boolean hasEnoughBuffer(long pendingDelete); /** * Reads track name & {@link MediaFormat} from storage. * * @param isAudio {@code true} if it is for audio track * @return {@link Pair} of track name & {@link MediaFormat} * @throws {@link java.io.IOException} */ Pair readTrackInfoFile(boolean isAudio) throws IOException; /** * Reads sample indexes for each written sample from storage. * * @param trackId track name * @return * @throws {@link java.io.IOException} */ ArrayList readIndexFile(String trackId) throws IOException; /** * Writes track information to storage. * * @param trackId track name * @param format {@link android.media.MediaFormat} of the track * @param isAudio {@code true} if it is for audio track * @throws {@link java.io.IOException} */ void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio) throws IOException; /** * Writes index file to storage. * * @param trackName track name * @param index {@link SampleCache} container * @throws {@link java.io.IOException} */ void writeIndexFile(String trackName, SortedMap index) throws IOException; } private static class CacheSet { private final Set mCaches = new ArraySet<>(); public synchronized void add(SampleCache cache) { mCaches.add(cache); } public synchronized void remove(SampleCache cache) { mCaches.remove(cache); } public synchronized long getSize() { long size = 0; for (SampleCache cache : mCaches) { size += cache.getSize(); } return size; } } public CacheManager(StorageManager storageManager) { this(storageManager, new SampleCache.SampleCacheFactory()); } public CacheManager(StorageManager storageManager, SampleCache.SampleCacheFactory sampleCacheFactory) { mStorageManager = storageManager; mSampleCacheFactory = sampleCacheFactory; clearCache(true); mIoHandlerThread.start(); } public void registerEvictListener(String id, EvictListener evictListener) { mEvictListeners.put(id, evictListener); } public void unregisterEvictListener(String id) { mEvictListeners.remove(id); } private void clearCache(boolean deleteFiles) { mCacheMap.clear(); if (deleteFiles) { mStorageManager.clearStorage(); } mCacheSize = 0; } private static String getFileName(String id, long positionUs) { return String.format(Locale.ENGLISH, "%s_%016x.cache", id, positionUs); } /** * Creates a new {@link SampleCache} for caching samples. * * @param id the name of the track * @param positionUs starting position of the {@link SampleCache} in micro seconds. * @param samplePool {@link SamplePool} for the fast creation of samples. * @return returns the created {@link SampleCache}. * @throws {@link java.io.IOException} */ public SampleCache createNewWriteFile(String id, long positionUs, SamplePool samplePool) throws IOException { if (!maybeEvictCache()) { throw new IOException("Not enough storage space"); } SortedMap map = mCacheMap.get(id); if (map == null) { map = new TreeMap<>(); mCacheMap.put(id, map); } File file = new File(mStorageManager.getCacheDir(), getFileName(id, positionUs)); SampleCache sampleCache = mSampleCacheFactory.createSampleCache(samplePool, file, positionUs, mCacheListener, mIoHandlerThread.getLooper()); map.put(positionUs, sampleCache); return sampleCache; } /** * Loads a track using {@link CacheManager.StorageManager}. * * @param trackId the name of the track. * @param samplePool {@link SamplePool} for the fast creation of samples. * @throws {@link java.io.IOException} */ public void loadTrackFormStorage(String trackId, SamplePool samplePool) throws IOException { ArrayList keyPositions = mStorageManager.readIndexFile(trackId); // TODO: notify the end position SortedMap map = mCacheMap.get(trackId); if (map == null) { map = new TreeMap<>(); mCacheMap.put(trackId, map); } SampleCache cache = null; for (long positionUs: keyPositions) { cache = mSampleCacheFactory.createSampleCacheFromFile(samplePool, mStorageManager.getCacheDir(), getFileName(trackId, positionUs), positionUs, mCacheListener, mIoHandlerThread.getLooper(), cache); map.put(positionUs, cache); } } /** * Finds a {@link SampleCache} for the specified track name and the position. * * @param id the name of the track. * @param positionUs the position. * @return returns the found {@link SampleCache}. */ public SampleCache getReadFile(String id, long positionUs) { SortedMap map = mCacheMap.get(id); if (map == null) { return null; } SampleCache sampleCache; SortedMap headMap = map.headMap(positionUs + 1); if (!headMap.isEmpty()) { sampleCache = headMap.get(headMap.lastKey()); } else { sampleCache = map.get(map.firstKey()); } return sampleCache; } private boolean maybeEvictCache() { long pendingDelete = mPendingDelete.getSize(); while (mStorageManager.reachedStorageMax(mCacheSize, pendingDelete) || !mStorageManager.hasEnoughBuffer(pendingDelete)) { if (mStorageManager.isPersistent()) { // Since cache is persistent, we cannot evict caches. return false; } SortedMap earliestCacheMap = null; SampleCache earliestCache = null; String earliestCacheId = null; for (Map.Entry> entry : mCacheMap.entrySet()) { SortedMap map = entry.getValue(); if (map.isEmpty()) { continue; } SampleCache cache = map.get(map.firstKey()); if (earliestCache == null || cache.getCreatedTimeMs() < earliestCache.getCreatedTimeMs()) { earliestCacheMap = map; earliestCache = cache; earliestCacheId = entry.getKey(); } } if (earliestCache == null) { break; } mPendingDelete.add(earliestCache); earliestCache.delete(); earliestCacheMap.remove(earliestCache.getStartPositionUs()); if (DEBUG) { Log.d(TAG, String.format("cacheSize = %d; pendingDelete = %b; " + "earliestCache size = %d; %s@%d (%s)", mCacheSize, pendingDelete, earliestCache.getSize(), earliestCacheId, earliestCache.getStartPositionUs(), new SimpleDateFormat().format(new Date(earliestCache.getCreatedTimeMs())))); } EvictListener listener = mEvictListeners.get(earliestCacheId); if (listener != null) { listener.onCacheEvicted(earliestCacheId, earliestCache.getCreatedTimeMs()); } pendingDelete = mPendingDelete.getSize(); } return true; } /** * Reads track information which includes {@link MediaFormat}. * * @return returns all track information which is found by {@link CacheManager.StorageManager}. * @throws {@link java.io.IOException} */ public ArrayList> readTrackInfoFiles() throws IOException { ArrayList> trackInfos = new ArrayList<>(); try { trackInfos.add(mStorageManager.readTrackInfoFile(false)); } catch (FileNotFoundException e) { // There can be a single track only recording. (eg. audio-only, video-only) // So the exception should not stop the read. } try { trackInfos.add(mStorageManager.readTrackInfoFile(true)); } catch (FileNotFoundException e) { // See above catch block. } return trackInfos; } /** * Writes track information and index information for all tracks. * * @param audio audio information. * @param video video information. */ public void writeMetaFiles(Pair audio, Pair video) { try { if (audio != null) { mStorageManager.writeTrackInfoFile(audio.first, audio.second, true); SortedMap map = mCacheMap.get(audio.first); if (map == null) { throw new IOException("Audio track index missing"); } mStorageManager.writeIndexFile(audio.first, map); } if (video != null) { mStorageManager.writeTrackInfoFile(video.first, video.second, false); SortedMap map = mCacheMap.get(video.first); if (map == null) { throw new IOException("Video track index missing"); } mStorageManager.writeIndexFile(video.first, map); } } catch (IOException e) { // TODO: throw exception and notify this failure properly. } } /** * Marks it is closed and it is not used anymore. */ public void close() { // Clean-up may happen after this is called. mClosed = true; } /** * Cleans up the specified track. * * @param trackId the name of the track. */ public void clearTrack(String trackId) { SortedMap map = mCacheMap.get(trackId); if (map == null) { Log.w(TAG, "Cache with specified ID (" + trackId + ") not found"); return; } for (SampleCache cache : map.values()) { cache.clear(); cache.close(); if (!mStorageManager.isPersistent()) { cache.delete(); } } mCacheMap.remove(trackId); if (mCacheMap.isEmpty() && mClosed) { mIoHandlerThread.quitSafely(); clearCache(!mStorageManager.isPersistent()); } } private void resetWriteStat() { mTotalWriteSize = 0; mTotalWriteTimeNs = 0; } /** * Adds a disk write sample size to calculate the average disk write bandwidth. */ public void addWriteStat(long size, long timeNs) { if (size >= mMinSampleSizeForSpeedCheck) { mTotalWriteSize += size; mTotalWriteTimeNs += timeNs; } } /** * Returns if the average disk write bandwidth is slower than * threshold {@code MINIMUM_DISK_WRITE_SPEED_MBPS}. */ public boolean isWriteSlow() { if (mTotalWriteSize < MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK) { return false; } // Checks write speed for only MAXIMUM_SPEED_CHECK_COUNT times to ignore outliers // by temporary system overloading during the playback. if (mSpeedCheckCount > MAXIMUM_SPEED_CHECK_COUNT) { return false; } mSpeedCheckCount++; float megabytePerSecond = getWriteBandwidth(); resetWriteStat(); if (DEBUG) { Log.d(TAG, "Measured disk write performance: " + megabytePerSecond + "MBps"); } return megabytePerSecond < MINIMUM_DISK_WRITE_SPEED_MBPS; } /** * Returns the disk write speed in megabytes per second. */ private float getWriteBandwidth() { if (mTotalWriteTimeNs == 0) { return -1; } return ((float) mTotalWriteSize * 1000 / mTotalWriteTimeNs); } /** * Marks {@link CacheManger} object disabled to prevent it from the future use. */ public void disable() { mDisabled = true; } /** * Returns if {@link CacheManger} object is disabled. */ public boolean isDisabled() { return mDisabled; } /** * Returns if {@link CacheManager} has checked the write speed, which is suitable for Trickplay. */ @VisibleForTesting public boolean hasSpeedCheckDone() { return mSpeedCheckCount > 0; } /** * Sets minimum sample size for write speed check. * @param sampleSize minimum sample size for write speed check. */ @VisibleForTesting public void setMinimumSampleSizeForSpeedCheck(int sampleSize) { mMinSampleSizeForSpeedCheck = sampleSize; } }