allow to use shared proxy with shared cache for multiple clients

This commit is contained in:
Alexey Danilov
2015-09-09 17:49:49 +03:00
parent 7f22a66941
commit 5115b96a9e
27 changed files with 904 additions and 542 deletions

View File

@@ -26,7 +26,7 @@ publish {
userOrg = 'alexeydanilov' userOrg = 'alexeydanilov'
groupId = 'com.danikula' groupId = 'com.danikula'
artifactId = 'videocache' artifactId = 'videocache'
publishVersion = '1.0.1' publishVersion = '2.0.7'
description = 'Cache support for android VideoView' description = 'Cache support for android VideoView'
website = 'https://github.com/danikula/AndroidVideoCache' website = 'https://github.com/danikula/AndroidVideoCache'
} }

View File

@@ -3,9 +3,6 @@ package com.danikula.videocache;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.util.Arrays; import java.util.Arrays;
import static com.danikula.videocache.Preconditions.checkArgument;
import static com.danikula.videocache.Preconditions.checkNotNull;
/** /**
* Simple memory based {@link Cache} implementation. * Simple memory based {@link Cache} implementation.
* *
@@ -24,7 +21,6 @@ public class ByteArrayCache implements Cache {
this.data = Preconditions.checkNotNull(data); this.data = Preconditions.checkNotNull(data);
} }
@Override @Override
public int read(byte[] buffer, long offset, int length) throws ProxyCacheException { public int read(byte[] buffer, long offset, int length) throws ProxyCacheException {
if (offset >= data.length) { if (offset >= data.length) {

View File

@@ -1,10 +1,14 @@
package com.danikula.videocache; package com.danikula.videocache;
import java.io.File;
/** /**
* @author Egor Makovsky (yahor.makouski@gmail.com). * Listener for cache availability.
*
* @author Egor Makovsky (yahor.makouski@gmail.com)
* @author Alexey Danilov (danikula@gmail.com).
*/ */
public interface CacheListener { public interface CacheListener {
void onError(ProxyCacheException e);
void onCacheDataAvailable(int cachePercentage); void onCacheAvailable(File cacheFile, String url, int percentsAvailable);
} }

View File

@@ -15,22 +15,16 @@ public class FileCache implements Cache {
private static final String TEMP_POSTFIX = ".download"; private static final String TEMP_POSTFIX = ".download";
public File file;
private RandomAccessFile dataFile; private RandomAccessFile dataFile;
private File file;
public FileCache(File file) throws ProxyCacheException { public FileCache(File file) throws ProxyCacheException {
try { try {
checkNotNull(file); checkNotNull(file);
boolean partialFile = isTempFile(file); ProxyCacheUtils.createDirectory(file.getParentFile());
boolean completed = file.exists() && !partialFile; boolean completed = file.exists();
if (completed) { this.file = completed ? file : new File(file.getParentFile(), file.getName() + TEMP_POSTFIX);
this.dataFile = new RandomAccessFile(file, "r"); this.dataFile = new RandomAccessFile(this.file, completed ? "r" : "rw");
this.file = file;
} else {
ProxyCacheUtils.createDirectory(file.getParentFile());
this.file = partialFile ? file : new File(file.getParentFile(), file.getName() + TEMP_POSTFIX);
this.dataFile = new RandomAccessFile(this.file, "rw");
}
} catch (IOException e) { } catch (IOException e) {
throw new ProxyCacheException("Error using file " + file + " as disc cache", e); throw new ProxyCacheException("Error using file " + file + " as disc cache", e);
} }
@@ -41,7 +35,7 @@ public class FileCache implements Cache {
try { try {
return (int) dataFile.length(); return (int) dataFile.length();
} catch (IOException e) { } catch (IOException e) {
throw new ProxyCacheException("Error reading length of file " + dataFile, e); throw new ProxyCacheException("Error reading length of file " + file, e);
} }
} }
@@ -117,4 +111,5 @@ public class FileCache implements Cache {
private boolean isTempFile(File file) { private boolean isTempFile(File file) {
return file.getName().endsWith(TEMP_POSTFIX); return file.getName().endsWith(TEMP_POSTFIX);
} }
} }

View File

@@ -0,0 +1,14 @@
package com.danikula.videocache;
import java.io.File;
/**
* Generator for files to be used for caching.
*
* @author Alexey Danilov (danikula@gmail.com).
*/
public interface FileNameGenerator {
File generate(String url);
}

View File

@@ -0,0 +1,71 @@
package com.danikula.videocache;
import android.text.TextUtils;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.danikula.videocache.Preconditions.checkNotNull;
/**
* Model for Http GET request.
*
* @author Alexey Danilov (danikula@gmail.com).
*/
class GetRequest {
private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("[R,r]ange:[ ]?bytes=(\\d*)-");
private static final Pattern URL_PATTERN = Pattern.compile("GET /(.*) HTTP");
public final String uri;
public final long rangeOffset;
public final boolean partial;
public GetRequest(String request) {
checkNotNull(request);
long offset = findRangeOffset(request);
this.rangeOffset = Math.max(0, offset);
this.partial = offset >= 0;
this.uri = findUri(request);
}
public static GetRequest read(InputStream inputStream) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
StringBuilder stringRequest = new StringBuilder();
String line;
while (!TextUtils.isEmpty(line = reader.readLine())) { // until new line (headers ending)
stringRequest.append(line).append('\n');
}
return new GetRequest(stringRequest.toString());
}
private long findRangeOffset(String request) {
Matcher matcher = RANGE_HEADER_PATTERN.matcher(request);
if (matcher.find()) {
String rangeValue = matcher.group(1);
return Long.parseLong(rangeValue);
}
return -1;
}
private String findUri(String request) {
Matcher matcher = URL_PATTERN.matcher(request);
if (matcher.find()) {
return matcher.group(1);
}
throw new IllegalArgumentException("Invalid request `" + request + "`: url not found!");
}
@Override
public String toString() {
return "GetRequest{" +
"uri='" + uri + '\'' +
", rangeOffset=" + rangeOffset +
", partial=" + partial +
'}';
}
}

View File

@@ -1,240 +1,76 @@
package com.danikula.videocache; package com.danikula.videocache;
import android.net.Uri;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** /**
* {@link ProxyCache} that uses local server to handle requests and cache data. * {@link ProxyCache} that read http url and writes data to {@link Socket}
* Typical usage:
* <pre><code>
* HttpProxyCache proxy;
* public onCreate(Bundle state) {
* super.onCreate(state);
* ...
* try{
* HttpUrlSource source = new HttpUrlSource(YOUR_VIDEO_URI);
* Cache cache = new FileCache(new File(context.getCacheDir(), "video.mp4"));
* proxy = new HttpProxyCache(source, cache);
* videoView.setVideoPath(proxy.getUrl());
* } catch(ProxyCacheException e) {
* Log.e(LOG_TAG, "Error playing video", e);
* }
* }
* public onDestroy(){
* super.onDestroy();
*
* if (proxy != null) {
* proxy.shutdown();
* }
* }
* <code/></pre>
* *
* @author Alexey Danilov (danikula@gmail.com). * @author Alexey Danilov (danikula@gmail.com).
*/ */
public class HttpProxyCache extends ProxyCache { class HttpProxyCache extends ProxyCache {
private static final int CLIENT_COUNT = 3; private final HttpUrlSource source;
private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("[R,r]ange:[ ]?bytes=(\\d*)-"); private final FileCache cache;
private static final String PROXY_HOST = "127.0.0.1"; private CacheListener listener;
private final HttpUrlSource httpUrlSource; public HttpProxyCache(HttpUrlSource source, FileCache cache) {
private final Cache cache; super(source, cache);
private final ServerSocket serverSocket;
private final int port;
private final Thread waitConnectionThread;
private final ExecutorService executorService;
public HttpProxyCache(HttpUrlSource source, Cache cache, boolean logEnabled) throws ProxyCacheException {
super(source, cache, logEnabled);
this.httpUrlSource = source;
this.cache = cache; this.cache = cache;
this.executorService = Executors.newFixedThreadPool(CLIENT_COUNT); this.source = source;
try {
InetAddress inetAddress = InetAddress.getByName(PROXY_HOST);
this.serverSocket = new ServerSocket(0, CLIENT_COUNT, inetAddress);
this.port = serverSocket.getLocalPort();
CountDownLatch startSignal = new CountDownLatch(1);
this.waitConnectionThread = new Thread(new WaitRequestsRunnable(startSignal));
this.waitConnectionThread.start();
startSignal.await(); // freeze thread, wait for server starts
} catch (IOException | InterruptedException e) {
executorService.shutdown();
throw new ProxyCacheException("Error starting local server", e);
}
} }
public HttpProxyCache(HttpUrlSource source, Cache cache) throws ProxyCacheException { public void registerCacheListener(CacheListener cacheListener) {
this(source, cache, false); this.listener = cacheListener;
} }
public String getUrl() { public void processRequest(GetRequest request, Socket socket) throws IOException, ProxyCacheException {
return "http://" + PROXY_HOST + ":" + port + Uri.parse(httpUrlSource.url).getPath();
}
@Override
public void shutdown() {
super.shutdown();
Log.i(ProxyCacheUtils.LOG_TAG, "Shutdown proxy");
waitConnectionThread.interrupt();
try {
if (!serverSocket.isClosed()) {
serverSocket.close();
}
} catch (IOException e) {
onError(new ProxyCacheException("Error shutting down local server", e));
}
}
private void waitForRequest() {
try {
while (!Thread.currentThread().isInterrupted()) {
Socket socket = serverSocket.accept();
Log.d(ProxyCacheUtils.LOG_TAG, "Accept new socket " + socket);
processSocketInBackground(socket);
}
} catch (IOException e) {
onError(new ProxyCacheException("Error during waiting connection", e));
}
}
private void processSocketInBackground(final Socket socket) throws IOException {
executorService.submit(new Runnable() {
@Override
public void run() {
try {
processSocket(socket);
} catch (Throwable e) {
onError(e);
}
}
});
}
private void processSocket(Socket socket) {
try {
InputStream inputStream = socket.getInputStream();
String request = readRequest(inputStream);
Log.i(ProxyCacheUtils.LOG_TAG, "Request to cache proxy:\n" + request);
long rangeOffset = getRangeOffset(request);
writeResponse(socket, rangeOffset);
} catch (ProxyCacheException | IOException e) {
onError(new ProxyCacheException("Error processing request", e));
} finally {
releaseSocket(socket);
}
}
private void writeResponse(Socket socket, long rangeOffset) throws ProxyCacheException, IOException {
OutputStream out = new BufferedOutputStream(socket.getOutputStream()); OutputStream out = new BufferedOutputStream(socket.getOutputStream());
byte[] buffer = new byte[ProxyCacheUtils.DEFAULT_BUFFER_SIZE]; byte[] buffer = new byte[ProxyCacheUtils.DEFAULT_BUFFER_SIZE];
int readBytes; int readBytes;
long offset = Math.max(rangeOffset, 0);
boolean headersWrote = false; boolean headersWrote = false;
long offset = request.rangeOffset;
while ((readBytes = read(buffer, offset, buffer.length)) != -1) { while ((readBytes = read(buffer, offset, buffer.length)) != -1) {
// tiny optimization: to prevent HEAD request in source for content-length. content-length 'll available after reading source // tiny optimization: to prevent HEAD request in source for content-length. content-length 'll available after reading source
if (!headersWrote) { if (!headersWrote) {
writeResponseHeaders(out, rangeOffset); String responseHeaders = newResponseHeaders(request);
out.write(responseHeaders.getBytes("UTF-8"));
headersWrote = true; headersWrote = true;
} }
out.write(buffer, 0, readBytes); out.write(buffer, 0, readBytes);
if (isLogEnabled()) {
Log.d(ProxyCacheUtils.LOG_TAG, "Write data[" + readBytes + " bytes] to socket " + socket + " with offset " + offset + ": " + ProxyCacheUtils.preview(buffer, readBytes));
}
offset += readBytes; offset += readBytes;
if (cache.isCompleted()) {
onCacheAvailable(100);
}
} }
out.flush(); out.flush();
} }
private void writeResponseHeaders(OutputStream out, long rangeOffset) throws IOException, ProxyCacheException { private String newResponseHeaders(GetRequest request) throws IOException, ProxyCacheException {
String responseHeaders = newResponseHeaders(rangeOffset); String mime = source.getMime();
out.write(responseHeaders.getBytes("UTF-8"));
Log.i(ProxyCacheUtils.LOG_TAG, "Response headers:\n" + responseHeaders);
}
private String newResponseHeaders(long offset) throws IOException, ProxyCacheException {
boolean partial = offset >= 0;
String mime = httpUrlSource.getMime();
boolean mimeKnown = !TextUtils.isEmpty(mime); boolean mimeKnown = !TextUtils.isEmpty(mime);
int length = cache.isCompleted() ? cache.available() : httpUrlSource.available(); int length = cache.isCompleted() ? cache.available() : source.available();
boolean lengthKnown = length >= 0; boolean lengthKnown = length >= 0;
long contentLength = partial ? length - offset : length; long contentLength = request.partial ? length - request.rangeOffset : length;
boolean addRange = lengthKnown && request.partial;
return new StringBuilder() return new StringBuilder()
.append(partial ? "HTTP/1.1 206 PARTIAL CONTENT\n" : "HTTP/1.1 200 OK\n") .append(request.partial ? "HTTP/1.1 206 PARTIAL CONTENT\n" : "HTTP/1.1 200 OK\n")
.append("Accept-Ranges: bytes\n") .append("Accept-Ranges: bytes\n")
.append(lengthKnown ? String.format("Content-Length: %d\n", contentLength) : "") .append(lengthKnown ? String.format("Content-Length: %d\n", contentLength) : "")
.append(lengthKnown && partial ? String.format("Content-Range: bytes %d-%d/%d\n", offset, length, length) : "") .append(addRange ? String.format("Content-Range: bytes %d-%d/%d\n", request.rangeOffset, length, length) : "")
.append(mimeKnown ? String.format("Content-Type: %s\n", mime) : "") .append(mimeKnown ? String.format("Content-Type: %s\n", mime) : "")
.append("\n") // headers end .append("\n") // headers end
.toString(); .toString();
} }
private String readRequest(InputStream inputStream) throws IOException { @Override
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); protected void onCacheAvailable(int percents) {
StringBuilder str = new StringBuilder(); if (listener != null) {
String line; listener.onCacheAvailable(cache.file, source.url, percents);
while (!TextUtils.isEmpty(line = reader.readLine())) { // until new line (headers ending)
str.append(line).append('\n');
}
return str.toString();
}
private long getRangeOffset(String request) {
Matcher matcher = RANGE_HEADER_PATTERN.matcher(request);
if (matcher.find()) {
String rangeValue = matcher.group(1);
return Long.parseLong(rangeValue);
}
return -1;
}
private void releaseSocket(Socket socket) {
try {
socket.shutdownInput();
} catch (IOException e) {
onError(new ProxyCacheException("Error closing socket input stream", e));
}
try {
socket.shutdownOutput();
} catch (IOException e) {
onError(new ProxyCacheException("Error closing socket output stream", e));
}
try {
socket.close();
} catch (IOException e) {
onError(new ProxyCacheException("Error closing socket", e));
}
}
private final class WaitRequestsRunnable implements Runnable {
private final CountDownLatch startSignal;
public WaitRequestsRunnable(CountDownLatch startSignal) {
this.startSignal = startSignal;
}
@Override
public void run() {
startSignal.countDown();
waitForRequest();
} }
} }
} }

View File

@@ -0,0 +1,240 @@
package com.danikula.videocache;
import android.util.Log;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static com.danikula.videocache.Preconditions.checkAllNotNull;
import static com.danikula.videocache.Preconditions.checkNotNull;
import static com.danikula.videocache.ProxyCacheUtils.LOG_TAG;
/**
* Simple lightweight proxy server with file caching support that handles HTTP requests.
* Typical usage:
* <pre><code>
* public onCreate(Bundle state) {
* super.onCreate(state);
* <p/>
* HttpProxyCacheServer proxy = getProxy();
* String proxyUrl = proxy.getProxyUrl(VIDEO_URL);
* videoView.setVideoPath(proxyUrl);
* }
* <p/>
* private HttpProxyCacheServer getProxy() {
* // should return single instance of HttpProxyCacheServer shared for whole app.
* }
* <code/></pre>
*
* @author Alexey Danilov (danikula@gmail.com).
*/
public class HttpProxyCacheServer {
private static final String PROXY_HOST = "127.0.0.1";
private final Object clientsLock = new Object();
private final ExecutorService socketProcessor = Executors.newFixedThreadPool(8);
private final Map<String, HttpProxyCacheServerClients> clientsMap = new ConcurrentHashMap<>();
private final ServerSocket serverSocket;
private final int port;
private final Thread waitConnectionThread;
private final FileNameGenerator fileNameGenerator;
public HttpProxyCacheServer(FileNameGenerator fileNameGenerator) {
this.fileNameGenerator = checkNotNull(fileNameGenerator);
try {
InetAddress inetAddress = InetAddress.getByName(PROXY_HOST);
this.serverSocket = new ServerSocket(0, 8, inetAddress);
this.port = serverSocket.getLocalPort();
CountDownLatch startSignal = new CountDownLatch(1);
this.waitConnectionThread = new Thread(new WaitRequestsRunnable(startSignal));
this.waitConnectionThread.start();
startSignal.await(); // freeze thread, wait for server starts
} catch (IOException | InterruptedException e) {
socketProcessor.shutdown();
throw new IllegalStateException("Error starting local proxy server", e);
}
}
public String getProxyUrl(String url) {
return String.format("http://%s:%d/%s", PROXY_HOST, port, ProxyCacheUtils.encode(url));
}
public void registerCacheListener(CacheListener cacheListener, String url) {
checkAllNotNull(cacheListener, url);
synchronized (clientsLock) {
try {
getClients(url).registerCacheListener(cacheListener);
} catch (ProxyCacheException e) {
Log.d(LOG_TAG, "Error registering cache listener", e);
}
}
}
public void unregisterCacheListener(CacheListener cacheListener, String url) {
checkAllNotNull(cacheListener, url);
synchronized (clientsLock) {
try {
getClients(url).unregisterCacheListener(cacheListener);
} catch (ProxyCacheException e) {
Log.d(LOG_TAG, "Error registering cache listener", e);
}
}
}
public void unregisterCacheListener(CacheListener cacheListener) {
checkNotNull(cacheListener);
synchronized (clientsLock) {
for (HttpProxyCacheServerClients clients : clientsMap.values()) {
clients.unregisterCacheListener(cacheListener);
}
}
}
public void shutdown() {
Log.i(LOG_TAG, "Shutdown proxy server");
shutdownClients();
waitConnectionThread.interrupt();
try {
if (!serverSocket.isClosed()) {
serverSocket.close();
}
} catch (IOException e) {
onError(new ProxyCacheException("Error shutting down proxy server", e));
}
}
private void shutdownClients() {
synchronized (clientsLock) {
for (HttpProxyCacheServerClients clients : clientsMap.values()) {
clients.shutdown();
}
clientsMap.clear();
}
}
private void waitForRequest() {
try {
while (!Thread.currentThread().isInterrupted()) {
Socket socket = serverSocket.accept();
Log.d(LOG_TAG, "Accept new socket " + socket);
socketProcessor.submit(new SocketProcessorRunnable(socket));
}
} catch (IOException e) {
onError(new ProxyCacheException("Error during waiting connection", e));
}
}
private void processSocket(Socket socket) {
try {
GetRequest request = GetRequest.read(socket.getInputStream());
Log.i(LOG_TAG, "Request to cache proxy:" + request);
String url = ProxyCacheUtils.decode(request.uri);
HttpProxyCacheServerClients clients = getClients(url);
clients.processRequest(request, socket);
} catch (SocketException e) {
// There is no way to determine that client closed connection http://stackoverflow.com/a/10241044/999458
// So just to prevent log flooding don't log stacktrace
Log.d(LOG_TAG, "Client communication problem. It seems client closed connection");
} catch (ProxyCacheException | IOException e) {
onError(new ProxyCacheException("Error processing request", e));
} finally {
releaseSocket(socket);
}
}
private HttpProxyCacheServerClients getClients(String url) throws ProxyCacheException {
synchronized (clientsLock) {
HttpProxyCacheServerClients clients = clientsMap.get(url);
if (clients == null) {
clients = new HttpProxyCacheServerClients(url, fileNameGenerator);
clientsMap.put(url, clients);
}
return clients;
}
}
private void releaseSocket(Socket socket) {
closeSocketInput(socket);
closeSocketOutput(socket);
closeSocket(socket);
}
private void closeSocketInput(Socket socket) {
try {
if (!socket.isInputShutdown()) {
socket.shutdownInput();
}
} catch (SocketException e) {
// There is no way to determine that client closed connection http://stackoverflow.com/a/10241044/999458
// So just to prevent log flooding don't log stacktrace
Log.d(LOG_TAG, "Error closing client's input stream: it seems client closed connection");
} catch (IOException e) {
onError(new ProxyCacheException("Error closing socket input stream", e));
}
}
private void closeSocketOutput(Socket socket) {
try {
if (socket.isOutputShutdown()) {
socket.shutdownOutput();
}
} catch (IOException e) {
onError(new ProxyCacheException("Error closing socket output stream", e));
}
}
private void closeSocket(Socket socket) {
try {
if (!socket.isClosed()) {
socket.close();
}
} catch (IOException e) {
onError(new ProxyCacheException("Error closing socket", e));
}
}
private void onError(Throwable e) {
Log.e(LOG_TAG, "HttpProxyCacheServer error", e);
}
private final class WaitRequestsRunnable implements Runnable {
private final CountDownLatch startSignal;
public WaitRequestsRunnable(CountDownLatch startSignal) {
this.startSignal = startSignal;
}
@Override
public void run() {
startSignal.countDown();
waitForRequest();
}
}
private final class SocketProcessorRunnable implements Runnable {
private final Socket socket;
public SocketProcessorRunnable(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
processSocket(socket);
}
}
}

View File

@@ -0,0 +1,102 @@
package com.danikula.videocache;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import java.io.File;
import java.io.IOException;
import java.net.Socket;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import static com.danikula.videocache.Preconditions.checkNotNull;
/**
* Client for {@link HttpProxyCacheServer}
*
* @author Alexey Danilov (danikula@gmail.com).
*/
final class HttpProxyCacheServerClients {
private final AtomicInteger clientsCount = new AtomicInteger(0);
private final String url;
private volatile HttpProxyCache proxyCache;
private final List<CacheListener> listeners = new CopyOnWriteArrayList<>();
private final FileNameGenerator fileNameGenerator;
private final CacheListener uiCacheListener;
public HttpProxyCacheServerClients(String url, FileNameGenerator fileNameGenerator) {
this.url = checkNotNull(url);
this.fileNameGenerator = checkNotNull(fileNameGenerator);
this.uiCacheListener = new UiListenerHandler(url, listeners);
}
public void processRequest(GetRequest request, Socket socket) throws ProxyCacheException, IOException {
proxyCache = proxyCache == null ? newHttpProxyCache() : proxyCache;
try {
clientsCount.incrementAndGet();
proxyCache.processRequest(request, socket);
} finally {
int count = clientsCount.decrementAndGet();
if (count <= 0) {
proxyCache.shutdown();
proxyCache = null;
}
}
}
public void registerCacheListener(CacheListener cacheListener) {
listeners.add(cacheListener);
}
public void unregisterCacheListener(CacheListener cacheListener) {
listeners.remove(cacheListener);
}
public void shutdown() {
listeners.clear();
if (proxyCache != null) {
proxyCache.registerCacheListener(null);
proxyCache.shutdown();
proxyCache = null;
}
clientsCount.set(0);
}
private HttpProxyCache newHttpProxyCache() throws ProxyCacheException {
HttpUrlSource source = new HttpUrlSource(url);
FileCache cache = new FileCache(fileNameGenerator.generate(url));
HttpProxyCache httpProxyCache = new HttpProxyCache(source, cache);
httpProxyCache.registerCacheListener(uiCacheListener);
return httpProxyCache;
}
private static final class UiListenerHandler extends Handler implements CacheListener {
private final String url;
private final List<CacheListener> listeners;
public UiListenerHandler(String url, List<CacheListener> listeners) {
super(Looper.getMainLooper());
this.url = url;
this.listeners = listeners;
}
@Override
public void onCacheAvailable(File file, String url, int percentsAvailable) {
Message message = obtainMessage();
message.arg1 = percentsAvailable;
message.obj = file;
sendMessage(message);
}
@Override
public void handleMessage(Message msg) {
for (CacheListener cacheListener : listeners) {
cacheListener.onCacheAvailable((File) msg.obj, url, msg.arg1);
}
}
}
}

View File

@@ -5,10 +5,11 @@ import android.util.Log;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InterruptedIOException;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import static com.danikula.videocache.Preconditions.checkNotNull; import static com.danikula.videocache.ProxyCacheUtils.LOG_TAG;
import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_OK;
import static java.net.HttpURLConnection.HTTP_PARTIAL; import static java.net.HttpURLConnection.HTTP_PARTIAL;
@@ -19,7 +20,7 @@ import static java.net.HttpURLConnection.HTTP_PARTIAL;
*/ */
public class HttpUrlSource implements Source { public class HttpUrlSource implements Source {
final String url; public final String url;
private HttpURLConnection connection; private HttpURLConnection connection;
private InputStream inputStream; private InputStream inputStream;
private volatile int available = Integer.MIN_VALUE; private volatile int available = Integer.MIN_VALUE;
@@ -45,23 +46,23 @@ public class HttpUrlSource implements Source {
@Override @Override
public void open(int offset) throws ProxyCacheException { public void open(int offset) throws ProxyCacheException {
try { try {
Log.d(ProxyCacheUtils.LOG_TAG, "Open connection " + (offset > 0 ? " with offset " + offset : "") + " to " + url); Log.d(LOG_TAG, "Open connection " + (offset > 0 ? " with offset " + offset : "") + " to " + url);
connection = (HttpURLConnection) new URL(url).openConnection(); connection = (HttpURLConnection) new URL(url).openConnection();
if (offset > 0) { if (offset > 0) {
connection.setRequestProperty("Range", "bytes=" + offset + "-"); connection.setRequestProperty("Range", "bytes=" + offset + "-");
} }
mime = connection.getContentType(); mime = connection.getContentType();
inputStream = connection.getInputStream(); inputStream = connection.getInputStream();
readSourceAvailableBytes(connection, offset); available = readSourceAvailableBytes(connection, offset);
} catch (IOException e) { } catch (IOException e) {
throw new ProxyCacheException("Error opening connection for " + url + " with offset " + offset, e); throw new ProxyCacheException("Error opening connection for " + url + " with offset " + offset, e);
} }
} }
private void readSourceAvailableBytes(HttpURLConnection connection, int offset) throws IOException { private int readSourceAvailableBytes(HttpURLConnection connection, int offset) throws IOException {
int contentLength = connection.getContentLength(); int contentLength = connection.getContentLength();
int responseCode = connection.getResponseCode(); int responseCode = connection.getResponseCode();
available = responseCode == HTTP_OK ? contentLength : return responseCode == HTTP_OK ? contentLength :
responseCode == HTTP_PARTIAL ? contentLength + offset : responseCode == HTTP_PARTIAL ? contentLength + offset :
available; available;
} }
@@ -80,20 +81,22 @@ public class HttpUrlSource implements Source {
} }
try { try {
return inputStream.read(buffer, 0, buffer.length); return inputStream.read(buffer, 0, buffer.length);
} catch (InterruptedIOException e) {
throw new InterruptedProxyCacheException("Reading source " + url + " is interrupted", e);
} catch (IOException e) { } catch (IOException e) {
throw new ProxyCacheException("Error reading data from " + url, e); throw new ProxyCacheException("Error reading data from " + url, e);
} }
} }
private void fetchContentInfo() throws ProxyCacheException { private void fetchContentInfo() throws ProxyCacheException {
Log.d(ProxyCacheUtils.LOG_TAG, "Read content info from " + url); Log.d(LOG_TAG, "Read content info from " + url);
HttpURLConnection urlConnection = null; HttpURLConnection urlConnection = null;
try { try {
urlConnection = (HttpURLConnection) new URL(url).openConnection(); urlConnection = (HttpURLConnection) new URL(url).openConnection();
urlConnection.setRequestMethod("HEAD"); urlConnection.setRequestMethod("HEAD");
available = urlConnection.getContentLength(); available = urlConnection.getContentLength();
mime = urlConnection.getContentType(); mime = urlConnection.getContentType();
Log.d(ProxyCacheUtils.LOG_TAG, "Content-Length of " + url + " is " + available + " bytes, mime is " + mime); Log.i(LOG_TAG, "Info read: " + this);
} catch (IOException e) { } catch (IOException e) {
throw new ProxyCacheException("Error fetching Content-Length from " + url); throw new ProxyCacheException("Error fetching Content-Length from " + url);
} finally { } finally {
@@ -109,4 +112,13 @@ public class HttpUrlSource implements Source {
} }
return mime; return mime;
} }
@Override
public String toString() {
return "HttpUrlSource{" +
"url='" + url + '\'' +
", available=" + available +
", mime='" + mime + '\'' +
'}';
}
} }

View File

@@ -0,0 +1,21 @@
package com.danikula.videocache;
/**
* Indicates interruption error in work of {@link ProxyCache} fired by user.
*
* @author Alexey Danilov
*/
public class InterruptedProxyCacheException extends ProxyCacheException {
public InterruptedProxyCacheException(String message) {
super(message);
}
public InterruptedProxyCacheException(String message, Throwable cause) {
super(message, cause);
}
public InterruptedProxyCacheException(Throwable cause) {
super(cause);
}
}

View File

@@ -0,0 +1,56 @@
package com.danikula.videocache;
import android.text.TextUtils;
import java.io.File;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import static com.danikula.videocache.Preconditions.checkNotNull;
/**
* Implementation of {@link FileNameGenerator} that uses MD5 of url as file name
*
* @author Alexey Danilov (danikula@gmail.com).
*/
public class Md5FileNameGenerator implements FileNameGenerator {
private final File cacheDirectory;
public Md5FileNameGenerator(File cacheDirectory) {
this.cacheDirectory = checkNotNull(cacheDirectory);
}
@Override
public File generate(String url) {
checkNotNull(url);
String extension = getExtension(url);
String name = computeMD5(url);
name = TextUtils.isEmpty(extension) ? name : name + "." + extension;
return new File(cacheDirectory, name);
}
private String getExtension(String url) {
int dotIndex = url.lastIndexOf('.');
int slashIndex = url.lastIndexOf(File.separator);
return dotIndex != -1 && dotIndex > slashIndex ? url.substring(dotIndex + 1, url.length()) : "";
}
private String computeMD5(String string) {
try {
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
byte[] digestBytes = messageDigest.digest(string.getBytes());
return bytesToHexString(digestBytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private String bytesToHexString(byte[] bytes) {
StringBuffer sb = new StringBuffer();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}

View File

@@ -9,6 +9,14 @@ final class Preconditions {
return reference; return reference;
} }
static void checkAllNotNull(Object... references) {
for (Object reference : references) {
if (reference == null) {
throw new NullPointerException();
}
}
}
static <T> T checkNotNull(T reference, String errorMessage) { static <T> T checkNotNull(T reference, String errorMessage) {
if (reference == null) { if (reference == null) {
throw new NullPointerException(errorMessage); throw new NullPointerException(errorMessage);

View File

@@ -1,8 +1,5 @@
package com.danikula.videocache; package com.danikula.videocache;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log; import android.util.Log;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@@ -19,35 +16,22 @@ import static com.danikula.videocache.ProxyCacheUtils.LOG_TAG;
* *
* @author Alexey Danilov (danikula@gmail.com). * @author Alexey Danilov (danikula@gmail.com).
*/ */
public class ProxyCache { class ProxyCache {
private static final int MAX_READ_SOURCE_ATTEMPTS = 1; private static final int MAX_READ_SOURCE_ATTEMPTS = 1;
private final Source source; private final Source source;
private final Cache cache; private final Cache cache;
private final Object wc; private final Object wc = new Object();
private final ListenerHandler handler; private final Object stopLock = new Object();
private volatile Thread sourceReaderThread; private volatile Thread sourceReaderThread;
private volatile boolean stopped; private volatile boolean stopped;
private final AtomicInteger readSourceErrorsCount; private final AtomicInteger readSourceErrorsCount;
private CacheListener cacheListener;
private final boolean logEnabled;
public ProxyCache(Source source, Cache cache, boolean logEnabled) {
this.source = checkNotNull(source);
this.cache = checkNotNull(cache);
this.logEnabled = logEnabled;
this.wc = new Object();
this.handler = new ListenerHandler();
this.readSourceErrorsCount = new AtomicInteger();
}
public ProxyCache(Source source, Cache cache) { public ProxyCache(Source source, Cache cache) {
this(source, cache, false); this.source = checkNotNull(source);
} this.cache = checkNotNull(cache);
this.readSourceErrorsCount = new AtomicInteger();
public void setCacheListener(CacheListener cacheListener) {
this.cacheListener = cacheListener;
} }
public int read(byte[] buffer, long offset, int length) throws ProxyCacheException { public int read(byte[] buffer, long offset, int length) throws ProxyCacheException {
@@ -59,11 +43,7 @@ public class ProxyCache {
checkIsCacheValid(); checkIsCacheValid();
checkReadSourceErrorsCount(); checkReadSourceErrorsCount();
} }
int read = cache.read(buffer, offset, length); return cache.read(buffer, offset, length);
if (isLogEnabled()) {
Log.d(LOG_TAG, "Read data[" + read + " bytes] from cache with offset " + offset + ": " + ProxyCacheUtils.preview(buffer, read));
}
return read;
} }
private void checkIsCacheValid() throws ProxyCacheException { private void checkIsCacheValid() throws ProxyCacheException {
@@ -82,14 +62,17 @@ public class ProxyCache {
} }
public void shutdown() { public void shutdown() {
try { synchronized (stopLock) {
stopped = true; Log.d(LOG_TAG, "Shutdown proxy for " + source);
if (sourceReaderThread != null) { try {
sourceReaderThread.interrupt(); stopped = true;
if (sourceReaderThread != null) {
sourceReaderThread.interrupt();
}
cache.close();
} catch (ProxyCacheException e) {
onError(e);
} }
cache.close();
} catch (ProxyCacheException e) {
onError(e);
} }
} }
@@ -112,13 +95,16 @@ public class ProxyCache {
} }
private void notifyNewCacheDataAvailable(int cachePercentage) { private void notifyNewCacheDataAvailable(int cachePercentage) {
handler.deliverCachePercentage(cachePercentage); onCacheAvailable(cachePercentage);
synchronized (wc) { synchronized (wc) {
wc.notifyAll(); wc.notifyAll();
} }
} }
protected void onCacheAvailable(int percents){
}
private void readSource() { private void readSource() {
int cachePercentage = 0; int cachePercentage = 0;
try { try {
@@ -126,19 +112,19 @@ public class ProxyCache {
source.open(offset); source.open(offset);
byte[] buffer = new byte[ProxyCacheUtils.DEFAULT_BUFFER_SIZE]; byte[] buffer = new byte[ProxyCacheUtils.DEFAULT_BUFFER_SIZE];
int readBytes; int readBytes;
while ((readBytes = source.read(buffer)) != -1 && !Thread.currentThread().isInterrupted() && !stopped) { while ((readBytes = source.read(buffer)) != -1) {
if (isLogEnabled()) { synchronized (stopLock) {
Log.d(LOG_TAG, "Write data[" + readBytes + " bytes] to cache from source with offset " + offset + ": " + ProxyCacheUtils.preview(buffer, readBytes)); if (isStopped()) {
return;
}
cache.append(buffer, readBytes);
} }
cache.append(buffer, readBytes);
offset += readBytes; offset += readBytes;
cachePercentage = offset * 100 / source.available(); cachePercentage = offset * 100 / source.available();
notifyNewCacheDataAvailable(cachePercentage); notifyNewCacheDataAvailable(cachePercentage);
} }
if (cache.available() == source.available()) { tryComplete();
cache.complete();
}
} catch (Throwable e) { } catch (Throwable e) {
readSourceErrorsCount.incrementAndGet(); readSourceErrorsCount.incrementAndGet();
onError(e); onError(e);
@@ -148,6 +134,18 @@ public class ProxyCache {
} }
} }
private void tryComplete() throws ProxyCacheException {
synchronized (stopLock) {
if (!isStopped() && cache.available() == source.available()) {
cache.complete();
}
}
}
private boolean isStopped() {
return Thread.currentThread().isInterrupted() || stopped;
}
private void closeSource() { private void closeSource() {
try { try {
source.close(); source.close();
@@ -157,12 +155,12 @@ public class ProxyCache {
} }
protected final void onError(final Throwable e) { protected final void onError(final Throwable e) {
Log.e(LOG_TAG, "ProxyCache error", e); boolean interruption = e instanceof InterruptedProxyCacheException;
handler.deliverError(e); if (interruption) {
} Log.d(LOG_TAG, "ProxyCache is interrupted");
} else {
protected boolean isLogEnabled() { Log.e(LOG_TAG, "ProxyCache error", e);
return logEnabled; }
} }
private class SourceReaderRunnable implements Runnable { private class SourceReaderRunnable implements Runnable {
@@ -172,55 +170,4 @@ public class ProxyCache {
readSource(); readSource();
} }
} }
private final class ListenerHandler extends Handler {
private static final int MSG_ERROR = 1;
private static final int MSG_CACHE_PERCENTAGE = 2;
public ListenerHandler() {
super(Looper.getMainLooper());
}
public void deliverCachePercentage(int percents) {
if (cacheListener != null) {
send(MSG_CACHE_PERCENTAGE, percents, null);
}
}
public void deliverError(Throwable error) {
if (isFatalError(error) || cacheListener != null) {
send(MSG_ERROR, 0, error);
}
}
private boolean isFatalError(Throwable error) {
return !(error instanceof ProxyCacheException);
}
private void send(int what, int arg1, Object data) {
Message message = obtainMessage(what);
message.arg1 = arg1;
message.obj = data;
sendMessage(message);
}
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MSG_CACHE_PERCENTAGE:
cacheListener.onCacheDataAvailable(msg.arg1);
break;
case MSG_ERROR:
Throwable error = (Throwable) msg.obj;
if (isFatalError(error)) {
throw new RuntimeException("Unexpected error!", error);
}
cacheListener.onError((ProxyCacheException) error);
break;
default:
throw new RuntimeException("Unknown message " + msg);
}
}
}
} }

View File

@@ -5,6 +5,9 @@ import android.webkit.MimeTypeMap;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.Arrays; import java.util.Arrays;
import static com.danikula.videocache.Preconditions.checkArgument; import static com.danikula.videocache.Preconditions.checkArgument;
@@ -56,5 +59,19 @@ class ProxyCacheUtils {
} }
} }
static String encode(String url) {
try {
return URLEncoder.encode(url, "utf-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Error encoding url", e);
}
}
static String decode(String url) {
try {
return URLDecoder.decode(url, "utf-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Error decoding url", e);
}
}
} }

View File

@@ -8,7 +8,7 @@ buildscript {
} }
repositories { repositories {
maven { url 'https://github.com/danikula/AndroidVideoCache/raw/mvn-repo' } maven { url 'https://dl.bintray.com/alexeydanilov/maven' }
maven { url 'https://github.com/dahlgren/vpi-aar/raw/master' } maven { url 'https://github.com/dahlgren/vpi-aar/raw/master' }
} }
@@ -36,9 +36,10 @@ apt {
} }
dependencies { dependencies {
// compile project(':library')
compile 'com.android.support:support-v4:23.0.0' compile 'com.android.support:support-v4:23.0.0'
compile 'org.androidannotations:androidannotations-api:3.3.2' compile 'org.androidannotations:androidannotations-api:3.3.2'
compile 'com.danikula:videocache:1.0' compile 'com.danikula:videocache:2.0.7'
compile 'com.viewpagerindicator:library:2.4.2-SNAPSHOT@aar' compile 'com.viewpagerindicator:library:2.4.2-SNAPSHOT@aar'
apt 'org.androidannotations:androidannotations:3.3.2' apt 'org.androidannotations:androidannotations:3.3.2'
} }

View File

@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application <application
android:name=".App"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"

View File

@@ -0,0 +1,26 @@
package com.danikula.videocache.sample;
import android.app.Application;
import android.content.Context;
import com.danikula.videocache.FileNameGenerator;
import com.danikula.videocache.HttpProxyCacheServer;
import com.danikula.videocache.Md5FileNameGenerator;
/**
* @author Alexey Danilov (danikula@gmail.com).
*/
public class App extends Application {
private HttpProxyCacheServer proxy;
public static HttpProxyCacheServer getProxy(Context context) {
App app = (App) context.getApplicationContext();
return app.proxy == null ? (app.proxy = app.newProxy()) : app.proxy;
}
private HttpProxyCacheServer newProxy() {
FileNameGenerator nameGenerator = new Md5FileNameGenerator(getExternalCacheDir());
return new HttpProxyCacheServer(nameGenerator);
}
}

View File

@@ -4,16 +4,11 @@ import android.content.Context;
import android.os.Handler; import android.os.Handler;
import android.os.Message; import android.os.Message;
import android.support.v4.app.Fragment; import android.support.v4.app.Fragment;
import android.util.Log;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.VideoView; import android.widget.VideoView;
import com.danikula.videocache.Cache;
import com.danikula.videocache.CacheListener; import com.danikula.videocache.CacheListener;
import com.danikula.videocache.FileCache; import com.danikula.videocache.HttpProxyCacheServer;
import com.danikula.videocache.HttpProxyCache;
import com.danikula.videocache.HttpUrlSource;
import com.danikula.videocache.ProxyCacheException;
import org.androidannotations.annotations.AfterViews; import org.androidannotations.annotations.AfterViews;
import org.androidannotations.annotations.EFragment; import org.androidannotations.annotations.EFragment;
@@ -40,7 +35,6 @@ public class GalleryVideoFragment extends Fragment implements CacheListener {
private boolean visibleForUser; private boolean visibleForUser;
private HttpProxyCache proxyCache;
private final VideoProgressUpdater updater = new VideoProgressUpdater(); private final VideoProgressUpdater updater = new VideoProgressUpdater();
public static Fragment build(Context context, Video video) { public static Fragment build(Context context, Video video) {
@@ -70,18 +64,11 @@ public class GalleryVideoFragment extends Fragment implements CacheListener {
} }
private void startProxy() { private void startProxy() {
try { HttpProxyCacheServer proxy = App.getProxy(getActivity());
Cache cache = new FileCache(new File(cachePath)); proxy.registerCacheListener(this, url);
HttpUrlSource source = new HttpUrlSource(url); videoView.setVideoPath(proxy.getProxyUrl(url));
proxyCache = new HttpProxyCache(source, cache);
proxyCache.setCacheListener(this);
videoView.setVideoPath(proxyCache.getUrl());
} catch (ProxyCacheException e) {
// do nothing. onError() handles all errors
}
} }
// just for videos gallery
@Override @Override
public void setUserVisibleHint(boolean isVisibleToUser) { public void setUserVisibleHint(boolean isVisibleToUser) {
super.setUserVisibleHint(isVisibleToUser); super.setUserVisibleHint(isVisibleToUser);
@@ -113,19 +100,12 @@ public class GalleryVideoFragment extends Fragment implements CacheListener {
public void onDestroy() { public void onDestroy() {
super.onDestroy(); super.onDestroy();
if (proxyCache != null) { App.getProxy(getActivity()).unregisterCacheListener(this);
proxyCache.shutdown();
}
} }
@Override @Override
public void onError(ProxyCacheException e) { public void onCacheAvailable(File file, String url, int percentsAvailable) {
Log.e(LOG_TAG, "Error playing video", e); progressBar.setSecondaryProgress(percentsAvailable);
}
@Override
public void onCacheDataAvailable(int cachePercentage) {
progressBar.setSecondaryProgress(cachePercentage);
} }
private void updateVideoProgress() { private void updateVideoProgress() {
@@ -152,7 +132,7 @@ public class GalleryVideoFragment extends Fragment implements CacheListener {
@Override @Override
public void handleMessage(Message msg) { public void handleMessage(Message msg) {
updateVideoProgress(); updateVideoProgress();
sendEmptyMessageDelayed(0, 200); sendEmptyMessageDelayed(0, 500);
} }
} }
} }

View File

@@ -4,16 +4,11 @@ import android.content.Context;
import android.os.Handler; import android.os.Handler;
import android.os.Message; import android.os.Message;
import android.support.v4.app.Fragment; import android.support.v4.app.Fragment;
import android.util.Log;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.VideoView; import android.widget.VideoView;
import com.danikula.videocache.Cache;
import com.danikula.videocache.CacheListener; import com.danikula.videocache.CacheListener;
import com.danikula.videocache.FileCache; import com.danikula.videocache.HttpProxyCacheServer;
import com.danikula.videocache.HttpProxyCache;
import com.danikula.videocache.HttpUrlSource;
import com.danikula.videocache.ProxyCacheException;
import org.androidannotations.annotations.AfterViews; import org.androidannotations.annotations.AfterViews;
import org.androidannotations.annotations.EFragment; import org.androidannotations.annotations.EFragment;
@@ -34,7 +29,6 @@ public class VideoFragment extends Fragment implements CacheListener {
@ViewById VideoView videoView; @ViewById VideoView videoView;
@ViewById ProgressBar progressBar; @ViewById ProgressBar progressBar;
private HttpProxyCache proxyCache;
private final VideoProgressUpdater updater = new VideoProgressUpdater(); private final VideoProgressUpdater updater = new VideoProgressUpdater();
public static Fragment build(Context context, Video video) { public static Fragment build(Context context, Video video) {
@@ -54,16 +48,10 @@ public class VideoFragment extends Fragment implements CacheListener {
} }
private void startVideo() { private void startVideo() {
try { HttpProxyCacheServer proxy = App.getProxy(getActivity());
Cache cache = new FileCache(new File(cachePath)); proxy.registerCacheListener(this, url);
HttpUrlSource source = new HttpUrlSource(url); videoView.setVideoPath(proxy.getProxyUrl(url));
proxyCache = new HttpProxyCache(source, cache); videoView.start();
proxyCache.setCacheListener(this);
videoView.setVideoPath(proxyCache.getUrl());
videoView.start();
} catch (ProxyCacheException e) {
// do nothing. onError() handles all errors
}
} }
@Override @Override
@@ -82,19 +70,12 @@ public class VideoFragment extends Fragment implements CacheListener {
public void onDestroy() { public void onDestroy() {
super.onDestroy(); super.onDestroy();
if (proxyCache != null) { App.getProxy(getActivity()).unregisterCacheListener(this);
proxyCache.shutdown();
}
} }
@Override @Override
public void onError(ProxyCacheException e) { public void onCacheAvailable(File file, String url, int percentsAvailable) {
Log.e(LOG_TAG, "Error playing video", e); progressBar.setSecondaryProgress(percentsAvailable);
}
@Override
public void onCacheDataAvailable(int cachePercentage) {
progressBar.setSecondaryProgress(cachePercentage);
} }
private void updateVideoProgress() { private void updateVideoProgress() {
@@ -121,7 +102,7 @@ public class VideoFragment extends Fragment implements CacheListener {
@Override @Override
public void handleMessage(Message msg) { public void handleMessage(Message msg) {
updateVideoProgress(); updateVideoProgress();
sendEmptyMessageDelayed(0, 200); sendEmptyMessageDelayed(0, 500);
} }
} }
} }

View File

@@ -42,4 +42,5 @@ dependencies {
testCompile('com.danikula:android-garden:2.1.4') { testCompile('com.danikula:android-garden:2.1.4') {
exclude group: 'com.google.android' exclude group: 'com.google.android'
} }
testCompile 'org.mockito:mockito-all:1.9.5'
} }

View File

@@ -0,0 +1,76 @@
package com.danikula.videocache;
import com.danikula.videocache.test.BuildConfig;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricGradleTestRunner;
import org.robolectric.annotation.Config;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import static org.fest.assertions.api.Assertions.assertThat;
import static org.fest.assertions.api.Assertions.fail;
/**
* @author Alexey Danilov (danikula@gmail.com).
*/
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, emulateSdk = BuildConfig.MIN_SDK_VERSION)
public class GetRequestTest {
@Test
public void testPartialHttpGet() throws Exception {
GetRequest getRequest = new GetRequest("" +
"GET /uri HTTP/1.1\n" +
"Host: 127.0.0.1:44684\n" +
"Range: bytes=9860723-" +
"Accept-Encoding: gzip");
assertThat(getRequest.rangeOffset).isEqualTo(9860723);
assertThat(getRequest.uri).isEqualTo("uri");
assertThat(getRequest.partial).isTrue();
}
@Test
public void testNotPartialHttpGet() throws Exception {
GetRequest getRequest = new GetRequest("" +
"GET /uri HTTP/1.1\n" +
"Host: 127.0.0.1:44684\n" +
"Accept-Encoding: gzip");
assertThat(getRequest.rangeOffset).isEqualTo(0);
assertThat(getRequest.uri).isEqualTo("uri");
assertThat(getRequest.partial).isFalse();
}
@Test
public void testReadStream() throws Exception {
String requestString = "GET /uri HTTP/1.1\nRange: bytes=9860723-\n";
InputStream stream = new ByteArrayInputStream(requestString.getBytes());
GetRequest getRequest = GetRequest.read(stream);
assertThat(getRequest.rangeOffset).isEqualTo(9860723);
assertThat(getRequest.uri).isEqualTo("uri");
assertThat(getRequest.partial).isTrue();
}
@Test
public void testMinimal() throws Exception {
GetRequest getRequest = new GetRequest("GET /uri HTTP/1.1");
assertThat(getRequest.rangeOffset).isEqualTo(0);
assertThat(getRequest.uri).isEqualTo("uri");
assertThat(getRequest.partial).isFalse();
}
@Test(expected = IllegalArgumentException.class)
public void testEmpty() throws Exception {
GetRequest getRequest = new GetRequest("");
fail("Empty request");
}
@Test(expected = IllegalArgumentException.class)
public void testInvalid() throws Exception {
GetRequest getRequest = new GetRequest("/uri HTTP/1.1\n");
fail("Invalid request");
}
}

View File

@@ -0,0 +1,117 @@
package com.danikula.videocache;
import android.util.Pair;
import com.danikula.android.garden.io.IoUtils;
import com.danikula.videocache.support.ProxyCacheTestUtils;
import com.danikula.videocache.support.Response;
import com.danikula.videocache.test.BuildConfig;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricGradleTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import static com.danikula.videocache.support.ProxyCacheTestUtils.ASSETS_DATA_BIG_NAME;
import static com.danikula.videocache.support.ProxyCacheTestUtils.ASSETS_DATA_NAME;
import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_BIG_SIZE;
import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_BIG_URL;
import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_URL;
import static com.danikula.videocache.support.ProxyCacheTestUtils.getFileContent;
import static com.danikula.videocache.support.ProxyCacheTestUtils.loadAssetFile;
import static com.danikula.videocache.support.ProxyCacheTestUtils.readProxyResponse;
import static org.fest.assertions.api.Assertions.assertThat;
/**
* @author Alexey Danilov (danikula@gmail.com).
*/
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, emulateSdk = BuildConfig.MIN_SDK_VERSION)
public class HttpProxyCacheServerTest {
@Test
public void testHttpProxyCache() throws Exception {
Pair<File, Response> response = readProxyData(HTTP_DATA_URL);
assertThat(response.second.code).isEqualTo(200);
assertThat(response.second.data).isEqualTo(getFileContent(response.first));
assertThat(response.second.data).isEqualTo(loadAssetFile(ASSETS_DATA_NAME));
}
@Test
public void testProxyContentWithPartialCache() throws Exception {
FileNameGenerator fileNameGenerator = new Md5FileNameGenerator(RuntimeEnvironment.application.getExternalCacheDir());
File file = fileNameGenerator.generate(HTTP_DATA_URL);
int partialCacheSize = 1000;
byte[] partialData = ProxyCacheTestUtils.generate(partialCacheSize);
File partialCacheFile = ProxyCacheTestUtils.getTempFile(file);
IoUtils.saveToFile(partialData, partialCacheFile);
HttpProxyCacheServer proxy = new HttpProxyCacheServer(fileNameGenerator);
Response response = readProxyResponse(proxy, HTTP_DATA_URL);
proxy.shutdown();
byte[] expected = loadAssetFile(ASSETS_DATA_NAME);
System.arraycopy(partialData, 0, expected, 0, partialCacheSize);
assertThat(response.data).isEqualTo(expected);
}
@Test
public void testMimeFromResponse() throws Exception {
Pair<File, Response> response = readProxyData("https://dl.dropboxusercontent.com/u/15506779/persistent/proxycache/android");
assertThat(response.second.contentType).isEqualTo("application/octet-stream");
}
@Test
public void testProxyFullResponse() throws Exception {
Pair<File, Response> response = readProxyData(HTTP_DATA_BIG_URL);
assertThat(response.second.code).isEqualTo(200);
assertThat(response.second.contentLength).isEqualTo(HTTP_DATA_BIG_SIZE);
assertThat(response.second.contentType).isEqualTo("image/jpeg");
assertThat(response.second.headers.containsKey("Accept-Ranges")).isTrue();
assertThat(response.second.headers.get("Accept-Ranges").get(0)).isEqualTo("bytes");
assertThat(response.second.headers.containsKey("Content-Range")).isFalse();
assertThat(response.second.data).isEqualTo(getFileContent(response.first));
assertThat(response.second.data).isEqualTo(loadAssetFile(ASSETS_DATA_BIG_NAME));
}
@Test
public void testProxyPartialResponse() throws Exception {
int offset = 42000;
Pair<File, Response> response = readProxyData(HTTP_DATA_BIG_URL, offset);
assertThat(response.second.code).isEqualTo(206);
assertThat(response.second.contentLength).isEqualTo(HTTP_DATA_BIG_SIZE - offset);
assertThat(response.second.contentType).isEqualTo("image/jpeg");
assertThat(response.second.headers.containsKey("Accept-Ranges")).isTrue();
assertThat(response.second.headers.get("Accept-Ranges").get(0)).isEqualTo("bytes");
assertThat(response.second.headers.containsKey("Content-Range")).isTrue();
String rangeHeader = String.format("bytes %d-%d/%d", offset, HTTP_DATA_BIG_SIZE, HTTP_DATA_BIG_SIZE);
assertThat(response.second.headers.get("Content-Range").get(0)).isEqualTo(rangeHeader);
byte[] expectedData = Arrays.copyOfRange(loadAssetFile(ASSETS_DATA_BIG_NAME), offset, HTTP_DATA_BIG_SIZE);
assertThat(response.second.data).isEqualTo(expectedData);
assertThat(getFileContent(response.first)).isEqualTo(loadAssetFile(ASSETS_DATA_BIG_NAME));
}
private Pair<File, Response> readProxyData(String url, int offset) throws IOException {
File externalCacheDir = RuntimeEnvironment.application.getExternalCacheDir();
FileNameGenerator fileNameGenerator = new Md5FileNameGenerator(externalCacheDir);
File file = fileNameGenerator.generate(url);
HttpProxyCacheServer proxy = new HttpProxyCacheServer(fileNameGenerator);
Response response = readProxyResponse(proxy, url, offset);
proxy.shutdown();
return new Pair<>(file, response);
}
private Pair<File, Response> readProxyData(String url) throws IOException {
return readProxyData(url, -1);
}
}

View File

@@ -1,142 +0,0 @@
package com.danikula.videocache;
import com.danikula.android.garden.io.IoUtils;
import com.danikula.videocache.support.AngryHttpUrlSource;
import com.danikula.videocache.support.Response;
import com.danikula.videocache.test.BuildConfig;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricGradleTestRunner;
import org.robolectric.annotation.Config;
import java.io.File;
import java.util.Arrays;
import static com.danikula.videocache.support.ProxyCacheTestUtils.ASSETS_DATA_BIG_NAME;
import static com.danikula.videocache.support.ProxyCacheTestUtils.ASSETS_DATA_NAME;
import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_BIG_SIZE;
import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_BIG_URL;
import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_URL;
import static com.danikula.videocache.support.ProxyCacheTestUtils.generate;
import static com.danikula.videocache.support.ProxyCacheTestUtils.getFileContent;
import static com.danikula.videocache.support.ProxyCacheTestUtils.loadAssetFile;
import static com.danikula.videocache.support.ProxyCacheTestUtils.newCacheFile;
import static com.danikula.videocache.support.ProxyCacheTestUtils.readProxyResponse;
import static org.fest.assertions.api.Assertions.assertThat;
/**
* @author Alexey Danilov (danikula@gmail.com).
*/
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, emulateSdk = BuildConfig.MIN_SDK_VERSION)
public class HttpProxyCacheTest {
@Test
public void testHttpProxyCache() throws Exception {
HttpUrlSource source = new HttpUrlSource(HTTP_DATA_URL);
File file = newCacheFile();
HttpProxyCache proxy = new HttpProxyCache(source, new FileCache(file));
Response response = readProxyResponse(proxy);
assertThat(response.code).isEqualTo(200);
assertThat(response.data).isEqualTo(getFileContent(file));
assertThat(response.data).isEqualTo(loadAssetFile(ASSETS_DATA_NAME));
proxy.shutdown();
}
@Test
public void testProxyContentWithPartialCache() throws Exception {
HttpUrlSource source = new HttpUrlSource(HTTP_DATA_URL);
int cacheSize = 1000;
HttpProxyCache proxy = new HttpProxyCache(source, new ByteArrayCache(new byte[cacheSize]));
Response proxyResponse = readProxyResponse(proxy);
byte[] expected = loadAssetFile(ASSETS_DATA_NAME);
Arrays.fill(expected, 0, cacheSize, (byte) 0);
assertThat(proxyResponse.data).isEqualTo(expected);
proxy.shutdown();
}
@Test
public void testMimeFromResponse() throws Exception {
HttpUrlSource source = new HttpUrlSource("https://dl.dropboxusercontent.com/u/15506779/persistent/proxycache/android");
HttpProxyCache proxy = new HttpProxyCache(source, new ByteArrayCache(new byte[0]));
proxy.read(new byte[1], 0, 1);
assertThat(source.getMime()).isEqualTo("application/octet-stream");
proxy.shutdown();
}
@Test
public void testProxyFullResponse() throws Exception {
File file = newCacheFile();
HttpProxyCache proxy = new HttpProxyCache(new HttpUrlSource(HTTP_DATA_BIG_URL), new FileCache(file));
Response response = readProxyResponse(proxy);
assertThat(response.code).isEqualTo(200);
assertThat(response.contentLength).isEqualTo(HTTP_DATA_BIG_SIZE);
assertThat(response.contentType).isEqualTo("image/jpeg");
assertThat(response.headers.containsKey("Accept-Ranges")).isTrue();
assertThat(response.headers.get("Accept-Ranges").get(0)).isEqualTo("bytes");
assertThat(response.headers.containsKey("Content-Range")).isFalse();
assertThat(response.data).isEqualTo(getFileContent(file));
assertThat(response.data).isEqualTo(loadAssetFile(ASSETS_DATA_BIG_NAME));
proxy.shutdown();
}
@Test
public void testProxyPartialResponse() throws Exception {
int offset = 42000;
File file = newCacheFile();
HttpProxyCache proxy = new HttpProxyCache(new HttpUrlSource(HTTP_DATA_BIG_URL), new FileCache(file));
Response response = readProxyResponse(proxy, offset);
assertThat(response.code).isEqualTo(206);
assertThat(response.contentLength).isEqualTo(HTTP_DATA_BIG_SIZE - offset);
assertThat(response.contentType).isEqualTo("image/jpeg");
assertThat(response.headers.containsKey("Accept-Ranges")).isTrue();
assertThat(response.headers.get("Accept-Ranges").get(0)).isEqualTo("bytes");
assertThat(response.headers.containsKey("Content-Range")).isTrue();
String rangeHeader = String.format("bytes %d-%d/%d", offset, HTTP_DATA_BIG_SIZE, HTTP_DATA_BIG_SIZE);
assertThat(response.headers.get("Content-Range").get(0)).isEqualTo(rangeHeader);
byte[] expectedData = Arrays.copyOfRange(loadAssetFile(ASSETS_DATA_BIG_NAME), offset, HTTP_DATA_BIG_SIZE);
assertThat(response.data).isEqualTo(expectedData);
assertThat(getFileContent(file)).isEqualTo(loadAssetFile(ASSETS_DATA_BIG_NAME));
proxy.shutdown();
}
@Test
public void testAppendCache() throws Exception {
byte[] cachedPortion = generate(1200);
File file = newCacheFile();
File partialFile = new File(file.getParentFile(), file.getName() + ".download");
IoUtils.saveToFile(cachedPortion, partialFile);
Cache cache = new FileCache(partialFile);
assertThat(cache.isCompleted()).isFalse();
HttpProxyCache proxy = new HttpProxyCache(new HttpUrlSource(HTTP_DATA_BIG_URL), cache);
readProxyResponse(proxy);
proxy.shutdown();
assertThat(cache.isCompleted()).isTrue();
byte[] expectedData = loadAssetFile(ASSETS_DATA_BIG_NAME);
System.arraycopy(cachedPortion, 0, expectedData, 0, cachedPortion.length);
assertThat(file.length()).isEqualTo(HTTP_DATA_BIG_SIZE);
assertThat(expectedData).isEqualTo(getFileContent(file));
}
@Test
public void testNoTouchSource() throws Exception {
File file = newCacheFile();
IoUtils.saveToFile(loadAssetFile(ASSETS_DATA_BIG_NAME), file);
FileCache cache = new FileCache(file);
HttpProxyCache proxy = new HttpProxyCache(new HttpUrlSource(HTTP_DATA_BIG_URL), cache);
Response response = readProxyResponse(proxy);
proxy.shutdown();
assertThat(response.code).isEqualTo(200);
proxy = new HttpProxyCache(new AngryHttpUrlSource(HTTP_DATA_BIG_URL, "image/jpeg"), new FileCache(file));
readProxyResponse(proxy);
assertThat(response.code).isEqualTo(200);
}
}

View File

@@ -1,5 +1,7 @@
package com.danikula.videocache; package com.danikula.videocache;
import com.danikula.android.garden.io.IoUtils;
import com.danikula.videocache.support.AngryHttpUrlSource;
import com.danikula.videocache.support.PhlegmaticByteArraySource; import com.danikula.videocache.support.PhlegmaticByteArraySource;
import com.danikula.videocache.test.BuildConfig; import com.danikula.videocache.test.BuildConfig;
@@ -178,4 +180,18 @@ public class ProxyCacheTest {
proxyCache.read(new byte[5], 19999, 5); proxyCache.read(new byte[5], 19999, 5);
assertThat(cache.isCompleted()).isTrue(); assertThat(cache.isCompleted()).isTrue();
} }
@Test
public void testNoTouchSource() throws Exception {
int dataSize = 2000;
byte[] data = generate(dataSize);
File file = newCacheFile();
IoUtils.saveToFile(data, file);
ProxyCache proxyCache = new ProxyCache(new AngryHttpUrlSource(), new FileCache(file));
byte[] readData = new byte[dataSize];
proxyCache.read(readData, 0, dataSize);
assertThat(readData).isEqualTo(data);
}
} }

View File

@@ -1,20 +1,14 @@
package com.danikula.videocache.support; package com.danikula.videocache.support;
import android.text.TextUtils;
import com.danikula.videocache.HttpUrlSource;
import com.danikula.videocache.ProxyCacheException; import com.danikula.videocache.ProxyCacheException;
import com.danikula.videocache.Source;
/** /**
* {@link HttpUrlSource} that throws exception in all methods. * {@link Source} that throws exception in all methods.
* *
* @author Alexey Danilov (danikula@gmail.com). * @author Alexey Danilov (danikula@gmail.com).
*/ */
public class AngryHttpUrlSource extends HttpUrlSource { public class AngryHttpUrlSource implements Source {
public AngryHttpUrlSource(String url, String mime) {
super(url, mime);
}
@Override @Override
public int available() throws ProxyCacheException { public int available() throws ProxyCacheException {
@@ -35,12 +29,4 @@ public class AngryHttpUrlSource extends HttpUrlSource {
public int read(byte[] buffer) throws ProxyCacheException { public int read(byte[] buffer) throws ProxyCacheException {
throw new IllegalStateException(); throw new IllegalStateException();
} }
public String getMime() throws ProxyCacheException {
String mime = super.getMime();
if (!TextUtils.isEmpty(mime)) {
return mime;
}
throw new IllegalStateException();
}
} }

View File

@@ -1,7 +1,7 @@
package com.danikula.videocache.support; package com.danikula.videocache.support;
import com.danikula.android.garden.io.IoUtils; import com.danikula.android.garden.io.IoUtils;
import com.danikula.videocache.HttpProxyCache; import com.danikula.videocache.HttpProxyCacheServer;
import com.google.common.io.Files; import com.google.common.io.Files;
import org.robolectric.RuntimeEnvironment; import org.robolectric.RuntimeEnvironment;
@@ -31,13 +31,13 @@ public class ProxyCacheTestUtils {
return Files.asByteSource(file).read(); return Files.asByteSource(file).read();
} }
public static Response readProxyResponse(HttpProxyCache proxy) throws IOException { public static Response readProxyResponse(HttpProxyCacheServer proxy, String url) throws IOException {
return readProxyResponse(proxy, -1); return readProxyResponse(proxy, url, -1);
} }
public static Response readProxyResponse(HttpProxyCache proxy, int offset) throws IOException { public static Response readProxyResponse(HttpProxyCacheServer proxy, String url, int offset) throws IOException {
URL url = new URL(proxy.getUrl()); URL proxiedUrl = new URL(proxy.getProxyUrl(url));
HttpURLConnection connection = (HttpURLConnection) url.openConnection(); HttpURLConnection connection = (HttpURLConnection) proxiedUrl.openConnection();
try { try {
if (offset >= 0) { if (offset >= 0) {
connection.setRequestProperty("Range", "bytes=" + offset + "-"); connection.setRequestProperty("Range", "bytes=" + offset + "-");