Skip to content

Commit

Permalink
add seeking video support (#21) and fix streaming while caching (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
danikula committed Oct 3, 2015
1 parent 9ffa983 commit 241d232
Show file tree
Hide file tree
Showing 16 changed files with 208 additions and 46 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ repositories {
maven { url 'https://dl.bintray.com/alexeydanilov/maven' }
}
dependencies {
compile 'com.danikula:videocache:2.1.4'
compile 'com.danikula:videocache:2.2.0'
}
```

Expand All @@ -34,7 +34,7 @@ private HttpProxyCacheServer getProxy() {
```

To guarantee normal work you should use **single** instance of `HttpProxyCacheServer` for whole app.
For example you can store shared proxy on your `Application`:
For example you can store shared proxy in your `Application`:

```java
public class App extends Application {
Expand All @@ -59,6 +59,9 @@ More preferable way is use some dependency injector like [Dagger](http://square.
See `sample` app for details.

## Whats new
### 2.2.0
- allow to [seek video](https://github.com/danikula/AndroidVideoCache/issues/21) in any position and [fix](https://github.com/danikula/AndroidVideoCache/issues/17) streaming while caching

### 2.1.4
- [fix](https://github.com/danikula/AndroidVideoCache/issues/18) available cache percents callback

Expand Down
2 changes: 1 addition & 1 deletion library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ publish {
userOrg = 'alexeydanilov'
groupId = 'com.danikula'
artifactId = 'videocache'
publishVersion = '2.1.4'
publishVersion = '2.2.0'
description = 'Cache support for android VideoView'
website = 'https://github.com/danikula/AndroidVideoCache'
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public int read(byte[] buffer) throws ProxyCacheException {
}

@Override
public int available() throws ProxyCacheException {
public int length() throws ProxyCacheException {
return data.length;
}

Expand Down
4 changes: 2 additions & 2 deletions library/src/main/java/com/danikula/videocache/GetRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ private String findUri(String request) {
@Override
public String toString() {
return "GetRequest{" +
"uri='" + uri + '\'' +
", rangeOffset=" + rangeOffset +
"rangeOffset=" + rangeOffset +
", partial=" + partial +
", uri='" + uri + '\'' +
'}';
}
}
60 changes: 46 additions & 14 deletions library/src/main/java/com/danikula/videocache/HttpProxyCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
import java.io.OutputStream;
import java.net.Socket;

import static com.danikula.videocache.ProxyCacheUtils.DEFAULT_BUFFER_SIZE;

/**
* {@link ProxyCache} that read http url and writes data to {@link Socket}
*
* @author Alexey Danilov ([email protected]).
*/
class HttpProxyCache extends ProxyCache {

private static final float NO_CACHE_BARRIER = .2f;

private final HttpUrlSource source;
private final FileCache cache;
private CacheListener listener;
Expand All @@ -30,27 +34,29 @@ public void registerCacheListener(CacheListener cacheListener) {

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;
boolean headersWrote = false;
String responseHeaders = newResponseHeaders(request);
out.write(responseHeaders.getBytes("UTF-8"));

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) {
String responseHeaders = newResponseHeaders(request);
out.write(responseHeaders.getBytes("UTF-8"));
headersWrote = true;
}
out.write(buffer, 0, readBytes);
offset += readBytes;
if (isUseCache(request)) {
responseWithCache(out, offset);
} else {
responseWithoutCache(out, offset);
}
out.flush();
}

private boolean isUseCache(GetRequest request) throws ProxyCacheException {
int sourceLength = source.length();
boolean sourceLengthKnown = sourceLength > 0;
int cacheAvailable = cache.available();
// do not use cache for partial requests which too far from available cache. It seems user seek video.
return !sourceLengthKnown || !request.partial || request.rangeOffset <= cacheAvailable + sourceLength * NO_CACHE_BARRIER;
}

private String newResponseHeaders(GetRequest request) throws IOException, ProxyCacheException {
String mime = source.getMime();
boolean mimeKnown = !TextUtils.isEmpty(mime);
int length = cache.isCompleted() ? cache.available() : source.available();
int length = cache.isCompleted() ? cache.available() : source.length();
boolean lengthKnown = length >= 0;
long contentLength = request.partial ? length - request.rangeOffset : length;
boolean addRange = lengthKnown && request.partial;
Expand All @@ -64,6 +70,32 @@ private String newResponseHeaders(GetRequest request) throws IOException, ProxyC
.toString();
}

private void responseWithCache(OutputStream out, long offset) throws ProxyCacheException, IOException {
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int readBytes;
while ((readBytes = read(buffer, offset, buffer.length)) != -1) {
out.write(buffer, 0, readBytes);
offset += readBytes;
}
out.flush();
}

private void responseWithoutCache(OutputStream out, long offset) throws ProxyCacheException, IOException {
try {
HttpUrlSource source = new HttpUrlSource(this.source);
source.open((int) offset);
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int readBytes;
while ((readBytes = source.read(buffer)) != -1) {
out.write(buffer, 0, readBytes);
offset += readBytes;
}
out.flush();
} finally {
source.close();
}
}

@Override
protected void onCachePercentsAvailableChanged(int percents) {
if (listener != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public HttpProxyCacheServer(FileNameGenerator fileNameGenerator) {

private void makeSureServerWorks() {
int maxPingAttempts = 3;
int delay = 100;
int delay = 200;
int pingAttempts = 0;
while (pingAttempts < maxPingAttempts) {
try {
Expand All @@ -92,13 +92,13 @@ private void makeSureServerWorks() {
SystemClock.sleep(delay);
delay *= 2;
} catch (InterruptedException | ExecutionException | TimeoutException e) {
Log.e(LOG_TAG, "Error pinging server. Shutdown it... If you see this message, please, email me [email protected]", e);
Log.e(LOG_TAG, "Error pinging server [attempt: " + pingAttempts + ", timeout: " + delay + "]. ", e);
}
}

if (!pinged) {
shutdown();
}
Log.e(LOG_TAG, "Shutdown server… Error pinging server [attempt: " + pingAttempts + ", timeout: " + delay + "]. " +
"If you see this message, please, email me [email protected]");
shutdown();
}

private boolean pingServer() throws ProxyCacheException {
Expand Down Expand Up @@ -212,7 +212,7 @@ private void processSocket(Socket 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");
Log.d(LOG_TAG, "Closing socket… Socket is closed by client.");
} catch (ProxyCacheException | IOException e) {
onError(new ProxyCacheException("Error processing request", e));
} finally {
Expand Down Expand Up @@ -262,7 +262,7 @@ private void closeSocketInput(Socket 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, "Error closing client's input stream: it seems client closed connection");
Log.d(LOG_TAG, "Releasing input stream… Socket is closed by client.");
} catch (IOException e) {
onError(new ProxyCacheException("Error closing socket input stream", e));
}
Expand Down
22 changes: 14 additions & 8 deletions library/src/main/java/com/danikula/videocache/HttpUrlSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class HttpUrlSource implements Source {
public final String url;
private HttpURLConnection connection;
private InputStream inputStream;
private volatile int available = Integer.MIN_VALUE;
private volatile int length = Integer.MIN_VALUE;
private volatile String mime;

public HttpUrlSource(String url) {
Expand All @@ -41,12 +41,18 @@ public HttpUrlSource(String url, String mime) {
this.mime = mime;
}

public HttpUrlSource(HttpUrlSource source) {
this.url = source.url;
this.mime = source.mime;
this.length = source.length;
}

@Override
public synchronized int available() throws ProxyCacheException {
if (available == Integer.MIN_VALUE) {
public synchronized int length() throws ProxyCacheException {
if (length == Integer.MIN_VALUE) {
fetchContentInfo();
}
return available;
return length;
}

@Override
Expand All @@ -55,7 +61,7 @@ public void open(int offset) throws ProxyCacheException {
connection = openConnection(offset, "GET", -1);
mime = connection.getContentType();
inputStream = new BufferedInputStream(connection.getInputStream(), DEFAULT_BUFFER_SIZE);
available = readSourceAvailableBytes(connection, offset, connection.getResponseCode());
length = readSourceAvailableBytes(connection, offset, connection.getResponseCode());
} catch (IOException e) {
throw new ProxyCacheException("Error opening connection for " + url + " with offset " + offset, e);
}
Expand All @@ -64,7 +70,7 @@ public void open(int offset) throws ProxyCacheException {
private int readSourceAvailableBytes(HttpURLConnection connection, int offset, int responseCode) throws IOException {
int contentLength = connection.getContentLength();
return responseCode == HTTP_OK ? contentLength
: responseCode == HTTP_PARTIAL ? contentLength + offset : available;
: responseCode == HTTP_PARTIAL ? contentLength + offset : length;
}

@Override
Expand Down Expand Up @@ -94,10 +100,10 @@ private void fetchContentInfo() throws ProxyCacheException {
InputStream inputStream = null;
try {
urlConnection = openConnection(0, "HEAD", 10000);
available = urlConnection.getContentLength();
length = urlConnection.getContentLength();
mime = urlConnection.getContentType();
inputStream = urlConnection.getInputStream();
Log.i(LOG_TAG, "Content info for `" + url + "`: mime: " + mime + ", content-length: " + available);
Log.i(LOG_TAG, "Content info for `" + url + "`: mime: " + mime + ", content-length: " + length);
} catch (IOException e) {
Log.e(LOG_TAG, "Error fetching info from " + url, e);
} finally {
Expand Down
4 changes: 2 additions & 2 deletions library/src/main/java/com/danikula/videocache/ProxyCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ private void readSource() {
try {
offset = cache.available();
source.open(offset);
sourceAvailable = source.available();
sourceAvailable = source.length();
byte[] buffer = new byte[ProxyCacheUtils.DEFAULT_BUFFER_SIZE];
int readBytes;
while ((readBytes = source.read(buffer)) != -1) {
Expand All @@ -144,7 +144,7 @@ private void readSource() {

private void tryComplete() throws ProxyCacheException {
synchronized (stopLock) {
if (!isStopped() && cache.available() == source.available()) {
if (!isStopped() && cache.available() == source.length()) {
cache.complete();
}
}
Expand Down
6 changes: 3 additions & 3 deletions library/src/main/java/com/danikula/videocache/Source.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ public interface Source {
void open(int offset) throws ProxyCacheException;

/**
* Returns available bytes or <b>negative value</b> if available bytes count is unknown.
* Returns length bytes or <b>negative value</b> if length is unknown.
*
* @return bytes available
* @return bytes length
* @throws ProxyCacheException if error occur while fetching source data.
*/
int available() throws ProxyCacheException;
int length() throws ProxyCacheException;

/**
* Read data to byte buffer from source with current offset.
Expand Down
2 changes: 1 addition & 1 deletion sample/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ dependencies {
// compile project(':library')
compile 'com.android.support:support-v4:23.0.1'
compile 'org.androidannotations:androidannotations-api:3.3.2'
compile 'com.danikula:videocache:2.1.4'
compile 'com.danikula:videocache:2.2.0'
compile 'com.viewpagerindicator:library:2.4.2-SNAPSHOT@aar'
apt 'org.androidannotations:androidannotations:3.3.2'
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public void testProxyFullResponseWithRedirect() throws Exception {

@Test
public void testProxyPartialResponse() throws Exception {
int offset = 42000;
int offset = 18000;
Pair<File, Response> response = readProxyData(HTTP_DATA_BIG_URL, offset);

assertThat(response.second.code).isEqualTo(206);
Expand All @@ -116,7 +116,7 @@ public void testProxyPartialResponse() throws Exception {

@Test
public void testProxyPartialResponseWithRedirect() throws Exception {
int offset = 42000;
int offset = 18000;
Pair<File, Response> response = readProxyData(HTTP_DATA_BIG_URL_ONE_REDIRECT, offset);

assertThat(response.second.code).isEqualTo(206);
Expand Down
75 changes: 75 additions & 0 deletions test/src/test/java/com/danikula/videocache/HttpProxyCacheTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.danikula.videocache;

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.mockito.Mockito;
import org.robolectric.RobolectricGradleTestRunner;
import org.robolectric.annotation.Config;

import java.io.ByteArrayOutputStream;
import java.net.Socket;

import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_URL;
import static com.danikula.videocache.support.ProxyCacheTestUtils.loadTestData;
import static org.fest.assertions.api.Assertions.assertThat;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyInt;
import static org.mockito.Matchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
* Test {@link HttpProxyCache}.
*
* @author Alexey Danilov ([email protected]).
*/
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, emulateSdk = BuildConfig.MIN_SDK_VERSION)
public class HttpProxyCacheTest {

@Test
public void testProcessRequestNoCache() throws Exception {
HttpUrlSource source = new HttpUrlSource(HTTP_DATA_URL);
FileCache cache = new FileCache(ProxyCacheTestUtils.newCacheFile());
HttpProxyCache proxyCache = new HttpProxyCache(source, cache);
GetRequest request = new GetRequest("GET /" + HTTP_DATA_URL + " HTTP/1.1");
ByteArrayOutputStream out = new ByteArrayOutputStream();
Socket socket = mock(Socket.class);
when(socket.getOutputStream()).thenReturn(out);

proxyCache.processRequest(request, socket);
Response response = new Response(out.toByteArray());

assertThat(response.data).isEqualTo(loadTestData());
assertThat(response.code).isEqualTo(200);
assertThat(response.contentLength).isEqualTo(ProxyCacheTestUtils.HTTP_DATA_SIZE);
assertThat(response.contentType).isEqualTo("image/jpeg");
}

@Test
public void testProcessPartialRequestWithoutCache() throws Exception {
HttpUrlSource source = new HttpUrlSource(HTTP_DATA_URL);
FileCache fileCache = new FileCache(ProxyCacheTestUtils.newCacheFile());
FileCache spyFileCache = Mockito.spy(fileCache);
doThrow(new RuntimeException()).when(spyFileCache).read(any(byte[].class), anyLong(), anyInt());
HttpProxyCache proxyCache = new HttpProxyCache(source, spyFileCache);
GetRequest request = new GetRequest("GET /" + HTTP_DATA_URL + " HTTP/1.1\nRange: bytes=2000-");
ByteArrayOutputStream out = new ByteArrayOutputStream();
Socket socket = mock(Socket.class);
when(socket.getOutputStream()).thenReturn(out);

proxyCache.processRequest(request, socket);
Response response = new Response(out.toByteArray());

byte[] fullData = loadTestData();
byte[] partialData = new byte[fullData.length - 2000];
System.arraycopy(fullData, 2000, partialData, 0, partialData.length);
assertThat(response.data).isEqualTo(partialData);
assertThat(response.code).isEqualTo(206);
}
}
Loading

0 comments on commit 241d232

Please sign in to comment.