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

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

View File

@@ -1,10 +1,14 @@
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 {
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";
public File file;
private RandomAccessFile dataFile;
private File file;
public FileCache(File file) throws ProxyCacheException {
try {
checkNotNull(file);
boolean partialFile = isTempFile(file);
boolean completed = file.exists() && !partialFile;
if (completed) {
this.dataFile = new RandomAccessFile(file, "r");
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");
}
ProxyCacheUtils.createDirectory(file.getParentFile());
boolean completed = file.exists();
this.file = completed ? file : new File(file.getParentFile(), file.getName() + TEMP_POSTFIX);
this.dataFile = new RandomAccessFile(this.file, completed ? "r" : "rw");
} catch (IOException e) {
throw new ProxyCacheException("Error using file " + file + " as disc cache", e);
}
@@ -41,7 +35,7 @@ public class FileCache implements Cache {
try {
return (int) dataFile.length();
} 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) {
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;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
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.
* 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>
* {@link ProxyCache} that read http url and writes data to {@link Socket}
*
* @author Alexey Danilov (danikula@gmail.com).
*/
public class HttpProxyCache extends ProxyCache {
class HttpProxyCache extends ProxyCache {
private static final int CLIENT_COUNT = 3;
private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("[R,r]ange:[ ]?bytes=(\\d*)-");
private static final String PROXY_HOST = "127.0.0.1";
private final HttpUrlSource source;
private final FileCache cache;
private CacheListener listener;
private final HttpUrlSource httpUrlSource;
private final Cache 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;
public HttpProxyCache(HttpUrlSource source, FileCache cache) {
super(source, cache);
this.cache = cache;
this.executorService = Executors.newFixedThreadPool(CLIENT_COUNT);
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);
}
this.source = source;
}
public HttpProxyCache(HttpUrlSource source, Cache cache) throws ProxyCacheException {
this(source, cache, false);
public void registerCacheListener(CacheListener cacheListener) {
this.listener = cacheListener;
}
public String getUrl() {
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 {
public void processRequest(GetRequest request, Socket socket) throws IOException, ProxyCacheException {
OutputStream out = new BufferedOutputStream(socket.getOutputStream());
byte[] buffer = new byte[ProxyCacheUtils.DEFAULT_BUFFER_SIZE];
int readBytes;
long offset = Math.max(rangeOffset, 0);
boolean headersWrote = false;
long offset = request.rangeOffset;
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
if (!headersWrote) {
writeResponseHeaders(out, rangeOffset);
String responseHeaders = newResponseHeaders(request);
out.write(responseHeaders.getBytes("UTF-8"));
headersWrote = true;
}
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;
if (cache.isCompleted()) {
onCacheAvailable(100);
}
}
out.flush();
}
private void writeResponseHeaders(OutputStream out, long rangeOffset) throws IOException, ProxyCacheException {
String responseHeaders = newResponseHeaders(rangeOffset);
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();
private String newResponseHeaders(GetRequest request) throws IOException, ProxyCacheException {
String mime = source.getMime();
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;
long contentLength = partial ? length - offset : length;
long contentLength = request.partial ? length - request.rangeOffset : length;
boolean addRange = lengthKnown && request.partial;
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(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("\n") // headers end
.toString();
}
private String readRequest(InputStream inputStream) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
StringBuilder str = new StringBuilder();
String line;
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();
@Override
protected void onCacheAvailable(int percents) {
if (listener != null) {
listener.onCacheAvailable(cache.file, source.url, percents);
}
}
}

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.InputStream;
import java.io.InterruptedIOException;
import java.net.HttpURLConnection;
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_PARTIAL;
@@ -19,7 +20,7 @@ import static java.net.HttpURLConnection.HTTP_PARTIAL;
*/
public class HttpUrlSource implements Source {
final String url;
public final String url;
private HttpURLConnection connection;
private InputStream inputStream;
private volatile int available = Integer.MIN_VALUE;
@@ -45,23 +46,23 @@ public class HttpUrlSource implements Source {
@Override
public void open(int offset) throws ProxyCacheException {
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();
if (offset > 0) {
connection.setRequestProperty("Range", "bytes=" + offset + "-");
}
mime = connection.getContentType();
inputStream = connection.getInputStream();
readSourceAvailableBytes(connection, offset);
available = readSourceAvailableBytes(connection, offset);
} catch (IOException 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 responseCode = connection.getResponseCode();
available = responseCode == HTTP_OK ? contentLength :
return responseCode == HTTP_OK ? contentLength :
responseCode == HTTP_PARTIAL ? contentLength + offset :
available;
}
@@ -80,20 +81,22 @@ public class HttpUrlSource implements Source {
}
try {
return inputStream.read(buffer, 0, buffer.length);
} catch (InterruptedIOException e) {
throw new InterruptedProxyCacheException("Reading source " + url + " is interrupted", e);
} catch (IOException e) {
throw new ProxyCacheException("Error reading data from " + url, e);
}
}
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;
try {
urlConnection = (HttpURLConnection) new URL(url).openConnection();
urlConnection.setRequestMethod("HEAD");
available = urlConnection.getContentLength();
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) {
throw new ProxyCacheException("Error fetching Content-Length from " + url);
} finally {
@@ -109,4 +112,13 @@ public class HttpUrlSource implements Source {
}
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;
}
static void checkAllNotNull(Object... references) {
for (Object reference : references) {
if (reference == null) {
throw new NullPointerException();
}
}
}
static <T> T checkNotNull(T reference, String errorMessage) {
if (reference == null) {
throw new NullPointerException(errorMessage);

View File

@@ -1,8 +1,5 @@
package com.danikula.videocache;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import java.util.concurrent.atomic.AtomicInteger;
@@ -19,35 +16,22 @@ import static com.danikula.videocache.ProxyCacheUtils.LOG_TAG;
*
* @author Alexey Danilov (danikula@gmail.com).
*/
public class ProxyCache {
class ProxyCache {
private static final int MAX_READ_SOURCE_ATTEMPTS = 1;
private final Source source;
private final Cache cache;
private final Object wc;
private final ListenerHandler handler;
private final Object wc = new Object();
private final Object stopLock = new Object();
private volatile Thread sourceReaderThread;
private volatile boolean stopped;
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) {
this(source, cache, false);
}
public void setCacheListener(CacheListener cacheListener) {
this.cacheListener = cacheListener;
this.source = checkNotNull(source);
this.cache = checkNotNull(cache);
this.readSourceErrorsCount = new AtomicInteger();
}
public int read(byte[] buffer, long offset, int length) throws ProxyCacheException {
@@ -59,11 +43,7 @@ public class ProxyCache {
checkIsCacheValid();
checkReadSourceErrorsCount();
}
int read = 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;
return cache.read(buffer, offset, length);
}
private void checkIsCacheValid() throws ProxyCacheException {
@@ -82,14 +62,17 @@ public class ProxyCache {
}
public void shutdown() {
try {
stopped = true;
if (sourceReaderThread != null) {
sourceReaderThread.interrupt();
synchronized (stopLock) {
Log.d(LOG_TAG, "Shutdown proxy for " + source);
try {
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) {
handler.deliverCachePercentage(cachePercentage);
onCacheAvailable(cachePercentage);
synchronized (wc) {
wc.notifyAll();
}
}
protected void onCacheAvailable(int percents){
}
private void readSource() {
int cachePercentage = 0;
try {
@@ -126,19 +112,19 @@ public class ProxyCache {
source.open(offset);
byte[] buffer = new byte[ProxyCacheUtils.DEFAULT_BUFFER_SIZE];
int readBytes;
while ((readBytes = source.read(buffer)) != -1 && !Thread.currentThread().isInterrupted() && !stopped) {
if (isLogEnabled()) {
Log.d(LOG_TAG, "Write data[" + readBytes + " bytes] to cache from source with offset " + offset + ": " + ProxyCacheUtils.preview(buffer, readBytes));
while ((readBytes = source.read(buffer)) != -1) {
synchronized (stopLock) {
if (isStopped()) {
return;
}
cache.append(buffer, readBytes);
}
cache.append(buffer, readBytes);
offset += readBytes;
cachePercentage = offset * 100 / source.available();
notifyNewCacheDataAvailable(cachePercentage);
}
if (cache.available() == source.available()) {
cache.complete();
}
tryComplete();
} catch (Throwable e) {
readSourceErrorsCount.incrementAndGet();
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() {
try {
source.close();
@@ -157,12 +155,12 @@ public class ProxyCache {
}
protected final void onError(final Throwable e) {
Log.e(LOG_TAG, "ProxyCache error", e);
handler.deliverError(e);
}
protected boolean isLogEnabled() {
return logEnabled;
boolean interruption = e instanceof InterruptedProxyCacheException;
if (interruption) {
Log.d(LOG_TAG, "ProxyCache is interrupted");
} else {
Log.e(LOG_TAG, "ProxyCache error", e);
}
}
private class SourceReaderRunnable implements Runnable {
@@ -172,55 +170,4 @@ public class ProxyCache {
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.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.Arrays;
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);
}
}
}