/*
* Copyright (C) 2014 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.google.android.exoplayer.upstream.cache;
import com.google.android.exoplayer.util.Assertions;
import android.os.ConditionVariable;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map.Entry;
import java.util.NavigableSet;
import java.util.Set;
import java.util.TreeSet;
/**
* A {@link Cache} implementation that maintains an in-memory representation.
*/
public class SimpleCache implements Cache {
private final File cacheDir;
private final CacheEvictor evictor;
private final HashMap<String, CacheSpan> lockedSpans;
private final HashMap<String, TreeSet<CacheSpan>> cachedSpans;
private final HashMap<String, ArrayList<Listener>> listeners;
private long totalSpace = 0;
/**
* Constructs the cache. The cache will delete any unrecognized files from the directory. Hence
* the directory cannot be used to store other files.
*
* @param cacheDir A dedicated cache directory.
*/
public SimpleCache(File cacheDir, CacheEvictor evictor) {
this.cacheDir = cacheDir;
this.evictor = evictor;
this.lockedSpans = new HashMap<String, CacheSpan>();
this.cachedSpans = new HashMap<String, TreeSet<CacheSpan>>();
this.listeners = new HashMap<String, ArrayList<Listener>>();
// Start cache initialization.
final ConditionVariable conditionVariable = new ConditionVariable();
new Thread() {
@Override
public void run() {
synchronized (SimpleCache.this) {
conditionVariable.open();
initialize();
}
}
}.start();
conditionVariable.block();
}
@Override
public synchronized NavigableSet<CacheSpan> addListener(String key, Listener listener) {
ArrayList<Listener> listenersForKey = listeners.get(key);
if (listenersForKey == null) {
listenersForKey = new ArrayList<Listener>();
listeners.put(key, listenersForKey);
}
listenersForKey.add(listener);
return getCachedSpans(key);
}
@Override
public synchronized void removeListener(String key, Listener listener) {
ArrayList<Listener> listenersForKey = listeners.get(key);
if (listenersForKey != null) {
listenersForKey.remove(listener);
if (listenersForKey.isEmpty()) {
listeners.remove(key);
}
}
}
@Override
public synchronized NavigableSet<CacheSpan> getCachedSpans(String key) {
TreeSet<CacheSpan> spansForKey = cachedSpans.get(key);
return spansForKey == null ? null : new TreeSet<CacheSpan>(spansForKey);
}
@Override
public synchronized Set<String> getKeys() {
return new HashSet<String>(cachedSpans.keySet());
}
@Override
public synchronized long getCacheSpace() {
return totalSpace;
}
@Override
public synchronized CacheSpan startReadWrite(String key, long position)
throws InterruptedException {
CacheSpan lookupSpan = CacheSpan.createLookup(key, position);
while (true) {
CacheSpan span = startReadWriteNonBlocking(lookupSpan);
if (span != null) {
return span;
} else {
// Write case, lock not available. We'll be woken up when a locked span is released (if the
// released lock is for the requested key then we'll be able to make progress) or when a
// span is added to the cache (if the span is for the requested key and covers the requested
// position, then we'll become a read and be able to make progress).
wait();
}
}
}
@Override
public synchronized CacheSpan startReadWriteNonBlocking(String key, long position) {
return startReadWriteNonBlocking(CacheSpan.createLookup(key, position));
}
private synchronized CacheSpan startReadWriteNonBlocking(CacheSpan lookupSpan) {
CacheSpan spanningRegion = getSpan(lookupSpan);
// Read case.
if (spanningRegion.isCached) {
CacheSpan oldCacheSpan = spanningRegion;
// Remove the old span from the in-memory representation.
TreeSet<CacheSpan> spansForKey = cachedSpans.get(oldCacheSpan.key);
Assertions.checkState(spansForKey.remove(oldCacheSpan));
// Obtain a new span with updated last access timestamp.
spanningRegion = oldCacheSpan.touch();
// Add the updated span back into the in-memory representation.
spansForKey.add(spanningRegion);
notifySpanTouched(oldCacheSpan, spanningRegion);
return spanningRegion;
}
// Write case, lock available.
if (!lockedSpans.containsKey(lookupSpan.key)) {
lockedSpans.put(lookupSpan.key, spanningRegion);
return spanningRegion;
}
// Write case, lock not available.
return null;
}
@Override
public synchronized File startFile(String key, long position, long length) {
Assertions.checkState(lockedSpans.containsKey(key));
if (!cacheDir.exists()) {
// For some reason the cache directory doesn't exist. Make a best effort to create it.
removeStaleSpans();
cacheDir.mkdirs();
}
evictor.onStartFile(this, key, position, length);
return CacheSpan.getCacheFileName(cacheDir, key, position, System.currentTimeMillis());
}
@Override
public synchronized void commitFile(File file) {
CacheSpan span = CacheSpan.createCacheEntry(file);
Assertions.checkState(span != null);
Assertions.checkState(lockedSpans.containsKey(span.key));
// If the file doesn't exist, don't add it to the in-memory representation.
if (!file.exists()) {
return;
}
// If the file has length 0, delete it and don't add it to the in-memory representation.
long length = file.length();
if (length == 0) {
file.delete();
return;
}
addSpan(span);
notifyAll();
}
@Override
public synchronized void releaseHoleSpan(CacheSpan holeSpan) {
Assertions.checkState(holeSpan == lockedSpans.remove(holeSpan.key));
notifyAll();
}
/**
* Returns the cache {@link CacheSpan} corresponding to the provided lookup {@link CacheSpan}.
* <p>
* If the lookup position is contained by an existing entry in the cache, then the returned
* {@link CacheSpan} defines the file in which the data is stored. If the lookup position is not
* contained by an existing entry, then the returned {@link CacheSpan} defines the maximum extents
* of the hole in the cache.
*
* @param lookupSpan A lookup {@link CacheSpan} specifying a key and position.
* @return The corresponding cache {@link CacheSpan}.
*/
private CacheSpan getSpan(CacheSpan lookupSpan) {
String key = lookupSpan.key;
long offset = lookupSpan.position;
TreeSet<CacheSpan> entries = cachedSpans.get(key);
if (entries == null) {
return CacheSpan.createOpenHole(key, lookupSpan.position);
}
CacheSpan floorSpan = entries.floor(lookupSpan);
if (floorSpan != null &&
floorSpan.position <= offset && offset < floorSpan.position + floorSpan.length) {
// The lookup position is contained within floorSpan.
if (floorSpan.file.exists()) {
return floorSpan;
} else {
// The file has been deleted from under us. It's likely that other files will have been
// deleted too, so scan the whole in-memory representation.
removeStaleSpans();
return getSpan(lookupSpan);
}
}
CacheSpan ceilEntry = entries.ceiling(lookupSpan);
return ceilEntry == null ? CacheSpan.createOpenHole(key, lookupSpan.position) :
CacheSpan.createClosedHole(key, lookupSpan.position,
ceilEntry.position - lookupSpan.position);
}
/**
* Ensures that the cache's in-memory representation has been initialized.
*/
private void initialize() {
if (!cacheDir.exists()) {
cacheDir.mkdirs();
}
File[] files = cacheDir.listFiles();
if (files == null) {
return;
}
for (int i = 0; i < files.length; i++) {
File file = files[i];
if (file.length() == 0) {
file.delete();
} else {
CacheSpan span = CacheSpan.createCacheEntry(file);
if (span == null) {
file.delete();
} else {
addSpan(span);
}
}
}
}
/**
* Adds a cached span to the in-memory representation.
*
* @param span The span to be added.
*/
private void addSpan(CacheSpan span) {
TreeSet<CacheSpan> spansForKey = cachedSpans.get(span.key);
if (spansForKey == null) {
spansForKey = new TreeSet<CacheSpan>();
cachedSpans.put(span.key, spansForKey);
}
spansForKey.add(span);
totalSpace += span.length;
notifySpanAdded(span);
}
@Override
public synchronized void removeSpan(CacheSpan span) {
TreeSet<CacheSpan> spansForKey = cachedSpans.get(span.key);
totalSpace -= span.length;
Assertions.checkState(spansForKey.remove(span));
span.file.delete();
if (spansForKey.isEmpty()) {
cachedSpans.remove(span.key);
}
notifySpanRemoved(span);
}
/**
* Scans all of the cached spans in the in-memory representation, removing any for which files
* no longer exist.
*/
private void removeStaleSpans() {
Iterator<Entry<String, TreeSet<CacheSpan>>> iterator = cachedSpans.entrySet().iterator();
while (iterator.hasNext()) {
Entry<String, TreeSet<CacheSpan>> next = iterator.next();
Iterator<CacheSpan> spanIterator = next.getValue().iterator();
boolean isEmpty = true;
while (spanIterator.hasNext()) {
CacheSpan span = spanIterator.next();
if (!span.file.exists()) {
spanIterator.remove();
if (span.isCached) {
totalSpace -= span.length;
}
notifySpanRemoved(span);
} else {
isEmpty = false;
}
}
if (isEmpty) {
iterator.remove();
}
}
}
private void notifySpanRemoved(CacheSpan span) {
ArrayList<Listener> keyListeners = listeners.get(span.key);
if (keyListeners != null) {
for (int i = keyListeners.size() - 1; i >= 0; i--) {
keyListeners.get(i).onSpanRemoved(this, span);
}
}
evictor.onSpanRemoved(this, span);
}
private void notifySpanAdded(CacheSpan span) {
ArrayList<Listener> keyListeners = listeners.get(span.key);
if (keyListeners != null) {
for (int i = keyListeners.size() - 1; i >= 0; i--) {
keyListeners.get(i).onSpanAdded(this, span);
}
}
evictor.onSpanAdded(this, span);
}
private void notifySpanTouched(CacheSpan oldSpan, CacheSpan newSpan) {
ArrayList<Listener> keyListeners = listeners.get(oldSpan.key);
if (keyListeners != null) {
for (int i = keyListeners.size() - 1; i >= 0; i--) {
keyListeners.get(i).onSpanTouched(this, oldSpan, newSpan);
}
}
evictor.onSpanTouched(this, oldSpan, newSpan);
}
@Override
public synchronized boolean isCached(String key, long position, long length) {
TreeSet<CacheSpan> entries = cachedSpans.get(key);
if (entries == null) {
return false;
}
CacheSpan lookupSpan = CacheSpan.createLookup(key, position);
CacheSpan floorSpan = entries.floor(lookupSpan);
if (floorSpan == null || floorSpan.position + floorSpan.length <= position) {
// We don't have a span covering the start of the queried region.
return false;
}
long queryEndPosition = position + length;
long currentEndPosition = floorSpan.position + floorSpan.length;
if (currentEndPosition >= queryEndPosition) {
// floorSpan covers the queried region.
return true;
}
Iterator<CacheSpan> iterator = entries.tailSet(floorSpan, false).iterator();
while (iterator.hasNext()) {
CacheSpan next = iterator.next();
if (next.position > currentEndPosition) {
// There's a hole in the cache within the queried region.
return false;
}
// We expect currentEndPosition to always equal (next.position + next.length), but
// perform a max check anyway to guard against the existence of overlapping spans.
currentEndPosition = Math.max(currentEndPosition, next.position + next.length);
if (currentEndPosition >= queryEndPosition) {
// We've found spans covering the queried region.
return true;
}
}
// We ran out of spans before covering the queried region.
return false;
}
}