aboutsummaryrefslogtreecommitdiff
path: root/usbtuner/src/com/android/usbtuner/exoplayer/CacheManager.java
diff options
context:
space:
mode:
Diffstat (limited to 'usbtuner/src/com/android/usbtuner/exoplayer/CacheManager.java')
-rw-r--r--usbtuner/src/com/android/usbtuner/exoplayer/CacheManager.java499
1 files changed, 499 insertions, 0 deletions
diff --git a/usbtuner/src/com/android/usbtuner/exoplayer/CacheManager.java b/usbtuner/src/com/android/usbtuner/exoplayer/CacheManager.java
new file mode 100644
index 00000000..c52a0a44
--- /dev/null
+++ b/usbtuner/src/com/android/usbtuner/exoplayer/CacheManager.java
@@ -0,0 +1,499 @@
+/*
+ * 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;
+
+import android.media.MediaFormat;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.support.annotation.VisibleForTesting;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Pair;
+
+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.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * Manages {@link SampleCache} objects.
+ * <p>
+ * 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 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<String, SortedMap<Long, SampleCache>> mCacheMap = new ArrayMap<>();
+ private final Map<String, EvictListener> 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 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);
+ }
+
+ /**
+ * 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<String, MediaFormat> 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<Long> 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<Long, SampleCache> index)
+ throws IOException;
+ }
+
+ private static class CacheSet {
+ private final Set<SampleCache> 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<Long, SampleCache> 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 com.android.usbtuner.exoplayer.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<Long> keyPositions = mStorageManager.readIndexFile(trackId);
+
+ // TODO: notify the end position
+ SortedMap<Long, SampleCache> 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<Long, SampleCache> map = mCacheMap.get(id);
+ if (map == null) {
+ return null;
+ }
+ SampleCache sampleCache;
+ SortedMap<Long, SampleCache> 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<Long, SampleCache> earliestCacheMap = null;
+ SampleCache earliestCache = null;
+ String earliestCacheId = null;
+ for (Map.Entry<String, SortedMap<Long, SampleCache>> entry : mCacheMap.entrySet()) {
+ SortedMap<Long, SampleCache> 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 com.android.usbtuner.exoplayer.CacheManager.StorageManager}.
+ * @throws {@link java.io.IOException}
+ */
+ public ArrayList<Pair<String, MediaFormat>> readTrackInfoFiles() throws IOException {
+ ArrayList<Pair<String, MediaFormat>> 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<String, MediaFormat> audio, Pair<String, MediaFormat> video) {
+ try {
+ if (audio != null) {
+ mStorageManager.writeTrackInfoFile(audio.first, audio.second, true);
+ SortedMap<Long, SampleCache> 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<Long, SampleCache> 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<Long, SampleCache> 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) {
+ 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;
+ }
+}