blob: eb596e9325c0df98028de9883718df812a973173 [file] [log] [blame]
/*
* 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.tv.tuner.exoplayer.buffer;
import android.media.MediaFormat;
import android.os.ConditionVariable;
import android.support.annotation.NonNull;
import android.support.annotation.VisibleForTesting;
import android.util.ArrayMap;
import android.util.Log;
import android.util.Pair;
import com.google.android.exoplayer.SampleHolder;
import com.android.tv.tuner.exoplayer.SampleExtractor;
import com.android.tv.util.Utils;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* Manages {@link SampleChunk} objects.
* <p>
* The buffer 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 BufferManager {
private static final String TAG = "BufferManager";
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 SampleChunk.SampleChunkCreator mSampleChunkCreator;
// Maps from track name to a map which maps from starting position to {@link SampleChunk}.
private final Map<String, SortedMap<Long, SampleChunk>> mChunkMap = new ArrayMap<>();
private final Map<String, Long> mStartPositionMap = new ArrayMap<>();
private final Map<String, ChunkEvictedListener> mEvictListeners = new ArrayMap<>();
private final StorageManager mStorageManager;
private long mBufferSize = 0;
private final EvictChunkQueueMap mPendingDelete = new EvictChunkQueueMap();
private final SampleChunk.ChunkCallback mChunkCallback = new SampleChunk.ChunkCallback() {
@Override
public void onChunkWrite(SampleChunk chunk) {
mBufferSize += chunk.getSize();
}
@Override
public void onChunkDelete(SampleChunk chunk) {
mBufferSize -= chunk.getSize();
}
};
private volatile boolean mClosed = false;
private int mMinSampleSizeForSpeedCheck = MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK;
private long mTotalWriteSize;
private long mTotalWriteTimeNs;
private float mWriteBandwidth = 0.0f;
private volatile int mSpeedCheckCount;
private boolean mDisabled = false;
public interface ChunkEvictedListener {
void onChunkEvicted(String id, long createdTimeMs);
}
/**
* Handles I/O
* between BufferManager and {@link SampleExtractor}.
*/
public interface SampleBuffer {
/**
* Initializes SampleBuffer.
* @param Ids track identifiers for storage read/write.
* @param mediaFormats meta-data for each track.
* @throws IOException
*/
void init(@NonNull List<String> Ids,
@NonNull List<com.google.android.exoplayer.MediaFormat> 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.
* @throws IOException
*/
void handleWriteSpeedSlow() throws IOException;
/**
* Sets the flag when EoS was reached.
*/
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.
* @throws IOException
*/
void release() throws IOException;
}
/**
* Storage configuration and policy manager for {@link BufferManager}
*/
public interface StorageManager {
/**
* Provides eligible storage directory for {@link BufferManager}.
*
* @return a directory to save buffer(chunks) and meta files
*/
File getBufferDir();
/**
* 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 bufferSize 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 bufferSize, 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 IOException
*/
Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) throws IOException;
/**
* Reads sample indexes for each written sample from storage.
*
* @param trackId track name
* @return indexes of the specified track
* @throws 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 IOException
*/
void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio)
throws IOException;
/**
* Writes index file to storage.
*
* @param trackName track name
* @param index {@link SampleChunk} container
* @throws IOException
*/
void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index)
throws IOException;
}
private static class EvictChunkQueueMap {
private final Map<String, LinkedList<SampleChunk>> mEvictMap = new ArrayMap<>();
private long mSize;
private void init(String key) {
mEvictMap.put(key, new LinkedList<>());
}
private void add(String key, SampleChunk chunk) {
LinkedList<SampleChunk> queue = mEvictMap.get(key);
if (queue != null) {
mSize += chunk.getSize();
queue.add(chunk);
}
}
private SampleChunk poll(String key, long startPositionUs) {
LinkedList<SampleChunk> queue = mEvictMap.get(key);
if (queue != null) {
SampleChunk chunk = queue.peek();
if (chunk != null && chunk.getStartPositionUs() < startPositionUs) {
mSize -= chunk.getSize();
return queue.poll();
}
}
return null;
}
private long getSize() {
return mSize;
}
private void release() {
for (Map.Entry<String, LinkedList<SampleChunk>> entry : mEvictMap.entrySet()) {
for (SampleChunk chunk : entry.getValue()) {
SampleChunk.IoState.release(chunk, true);
}
}
mEvictMap.clear();
mSize = 0;
}
}
public BufferManager(StorageManager storageManager) {
this(storageManager, new SampleChunk.SampleChunkCreator());
}
public BufferManager(StorageManager storageManager,
SampleChunk.SampleChunkCreator sampleChunkCreator) {
mStorageManager = storageManager;
mSampleChunkCreator = sampleChunkCreator;
clearBuffer(true);
}
public void registerChunkEvictedListener(String id, ChunkEvictedListener listener) {
mEvictListeners.put(id, listener);
}
public void unregisterChunkEvictedListener(String id) {
mEvictListeners.remove(id);
}
private void clearBuffer(boolean deleteFiles) {
mChunkMap.clear();
if (deleteFiles) {
mStorageManager.clearStorage();
}
mBufferSize = 0;
}
private static String getFileName(String id, long positionUs) {
return String.format(Locale.ENGLISH, "%s_%016x.chunk", id, positionUs);
}
/**
* Creates a new {@link SampleChunk} for caching samples.
*
* @param id the name of the track
* @param positionUs starting position of the {@link SampleChunk} in micro seconds.
* @param samplePool {@link SamplePool} for the fast creation of samples.
* @return returns the created {@link SampleChunk}.
* @throws IOException
*/
public SampleChunk createNewWriteFile(String id, long positionUs,
SamplePool samplePool) throws IOException {
if (!maybeEvictChunk()) {
throw new IOException("Not enough storage space");
}
SortedMap<Long, SampleChunk> map = mChunkMap.get(id);
if (map == null) {
map = new TreeMap<>();
mChunkMap.put(id, map);
mStartPositionMap.put(id, positionUs);
mPendingDelete.init(id);
}
File file = new File(mStorageManager.getBufferDir(), getFileName(id, positionUs));
SampleChunk sampleChunk = mSampleChunkCreator.createSampleChunk(samplePool, file,
positionUs, mChunkCallback);
map.put(positionUs, sampleChunk);
return sampleChunk;
}
/**
* Loads a track using {@link BufferManager.StorageManager}.
*
* @param trackId the name of the track.
* @param samplePool {@link SamplePool} for the fast creation of samples.
* @throws IOException
*/
public void loadTrackFromStorage(String trackId, SamplePool samplePool) throws IOException {
ArrayList<Long> keyPositions = mStorageManager.readIndexFile(trackId);
long startPositionUs = keyPositions.size() > 0 ? keyPositions.get(0) : 0;
SortedMap<Long, SampleChunk> map = mChunkMap.get(trackId);
if (map == null) {
map = new TreeMap<>();
mChunkMap.put(trackId, map);
mStartPositionMap.put(trackId, startPositionUs);
mPendingDelete.init(trackId);
}
SampleChunk chunk = null;
for (long positionUs: keyPositions) {
chunk = mSampleChunkCreator.loadSampleChunkFromFile(samplePool,
mStorageManager.getBufferDir(), getFileName(trackId, positionUs), positionUs,
mChunkCallback, chunk);
map.put(positionUs, chunk);
}
}
/**
* Finds a {@link SampleChunk} for the specified track name and the position.
*
* @param id the name of the track.
* @param positionUs the position.
* @return returns the found {@link SampleChunk}.
*/
public SampleChunk getReadFile(String id, long positionUs) {
SortedMap<Long, SampleChunk> map = mChunkMap.get(id);
if (map == null) {
return null;
}
SampleChunk sampleChunk;
SortedMap<Long, SampleChunk> headMap = map.headMap(positionUs + 1);
if (!headMap.isEmpty()) {
sampleChunk = headMap.get(headMap.lastKey());
} else {
sampleChunk = map.get(map.firstKey());
}
return sampleChunk;
}
/**
* Evicts chunks which are ready to be evicted for the specified track
*
* @param id the specified track
* @param earlierThanPositionUs the start position of the {@link SampleChunk}
* should be earlier than
*/
public void evictChunks(String id, long earlierThanPositionUs) {
SampleChunk chunk = null;
while ((chunk = mPendingDelete.poll(id, earlierThanPositionUs)) != null) {
SampleChunk.IoState.release(chunk, !mStorageManager.isPersistent()) ;
}
}
/**
* Returns the start position of the specified track in micro seconds.
*
* @param id the specified track
*/
public long getStartPositionUs(String id) {
Long ret = mStartPositionMap.get(id);
return ret == null ? 0 : ret;
}
private boolean maybeEvictChunk() {
long pendingDelete = mPendingDelete.getSize();
while (mStorageManager.reachedStorageMax(mBufferSize, pendingDelete)
|| !mStorageManager.hasEnoughBuffer(pendingDelete)) {
if (mStorageManager.isPersistent()) {
// Since chunks are persistent, we cannot evict chunks.
return false;
}
SortedMap<Long, SampleChunk> earliestChunkMap = null;
SampleChunk earliestChunk = null;
String earliestChunkId = null;
for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) {
SortedMap<Long, SampleChunk> map = entry.getValue();
if (map.isEmpty()) {
continue;
}
SampleChunk chunk = map.get(map.firstKey());
if (earliestChunk == null
|| chunk.getCreatedTimeMs() < earliestChunk.getCreatedTimeMs()) {
earliestChunkMap = map;
earliestChunk = chunk;
earliestChunkId = entry.getKey();
}
}
if (earliestChunk == null) {
break;
}
mPendingDelete.add(earliestChunkId, earliestChunk);
earliestChunkMap.remove(earliestChunk.getStartPositionUs());
if (DEBUG) {
Log.d(TAG, String.format("bufferSize = %d; pendingDelete = %b; "
+ "earliestChunk size = %d; %s@%d (%s)",
mBufferSize, pendingDelete, earliestChunk.getSize(), earliestChunkId,
earliestChunk.getStartPositionUs(),
Utils.toIsoDateTimeString(earliestChunk.getCreatedTimeMs())));
}
ChunkEvictedListener listener = mEvictListeners.get(earliestChunkId);
if (listener != null) {
listener.onChunkEvicted(earliestChunkId, earliestChunk.getCreatedTimeMs());
}
pendingDelete = mPendingDelete.getSize();
}
for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) {
SortedMap<Long, SampleChunk> map = entry.getValue();
if (map.isEmpty()) {
continue;
}
mStartPositionMap.put(entry.getKey(), map.firstKey());
}
return true;
}
/**
* Reads track information which includes {@link MediaFormat}.
*
* @return returns all track information which is found by {@link BufferManager.StorageManager}.
* @throws 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.
* @throws IOException
*/
public void writeMetaFiles(Pair<String, MediaFormat> audio, Pair<String, MediaFormat> video)
throws IOException {
if (audio != null) {
mStorageManager.writeTrackInfoFile(audio.first, audio.second, true);
SortedMap<Long, SampleChunk> map = mChunkMap.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, SampleChunk> map = mChunkMap.get(video.first);
if (map == null) {
throw new IOException("Video track index missing");
}
mStorageManager.writeIndexFile(video.first, map);
}
}
/**
* Marks it is closed and it is not used anymore.
*/
public void close() {
// Clean-up may happen after this is called.
mClosed = true;
}
/**
* Releases all the resources.
*/
public void release() {
mPendingDelete.release();
for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) {
for (SampleChunk chunk : entry.getValue().values()) {
SampleChunk.IoState.release(chunk, !mStorageManager.isPersistent());
}
}
mChunkMap.clear();
if (mClosed) {
clearBuffer(!mStorageManager.isPersistent());
}
}
private void resetWriteStat(float writeBandwidth) {
mWriteBandwidth = writeBandwidth;
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 = calculateWriteBandwidth();
resetWriteStat(megabytePerSecond);
if (DEBUG) {
Log.d(TAG, "Measured disk write performance: " + megabytePerSecond + "MBps");
}
return megabytePerSecond < MINIMUM_DISK_WRITE_SPEED_MBPS;
}
/**
* Returns recent write bandwidth in MBps. If recent bandwidth is not available,
* returns {float -1.0f}.
*/
public float getWriteBandwidth() {
return mWriteBandwidth == 0.0f ? -1.0f : mWriteBandwidth;
}
private float calculateWriteBandwidth() {
if (mTotalWriteTimeNs == 0) {
return -1;
}
return ((float) mTotalWriteSize * 1000 / mTotalWriteTimeNs);
}
/**
* Marks {@link BufferManager} object disabled to prevent it from the future use.
*/
public void disable() {
mDisabled = true;
}
/**
* Returns if {@link BufferManager} object is disabled.
*/
public boolean isDisabled() {
return mDisabled;
}
/**
* Returns if {@link BufferManager} 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;
}
}