diff --git a/README.md b/README.md index 93abe15..6dca619 100644 --- a/README.md +++ b/README.md @@ -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' } ``` @@ -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 { @@ -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 diff --git a/library/build.gradle b/library/build.gradle index 6390e75..3bdd043 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -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' } diff --git a/library/src/main/java/com/danikula/videocache/ByteArraySource.java b/library/src/main/java/com/danikula/videocache/ByteArraySource.java index d66efea..8e1ca30 100644 --- a/library/src/main/java/com/danikula/videocache/ByteArraySource.java +++ b/library/src/main/java/com/danikula/videocache/ByteArraySource.java @@ -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; } diff --git a/library/src/main/java/com/danikula/videocache/GetRequest.java b/library/src/main/java/com/danikula/videocache/GetRequest.java index b154da0..c25464e 100644 --- a/library/src/main/java/com/danikula/videocache/GetRequest.java +++ b/library/src/main/java/com/danikula/videocache/GetRequest.java @@ -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 + '\'' + '}'; } } diff --git a/library/src/main/java/com/danikula/videocache/HttpProxyCache.java b/library/src/main/java/com/danikula/videocache/HttpProxyCache.java index 6bde7be..61681bd 100644 --- a/library/src/main/java/com/danikula/videocache/HttpProxyCache.java +++ b/library/src/main/java/com/danikula/videocache/HttpProxyCache.java @@ -7,6 +7,8 @@ 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} * @@ -14,6 +16,8 @@ */ class HttpProxyCache extends ProxyCache { + private static final float NO_CACHE_BARRIER = .2f; + private final HttpUrlSource source; private final FileCache cache; private CacheListener listener; @@ -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; @@ -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) { diff --git a/library/src/main/java/com/danikula/videocache/HttpProxyCacheServer.java b/library/src/main/java/com/danikula/videocache/HttpProxyCacheServer.java index 49920ae..53f89d1 100644 --- a/library/src/main/java/com/danikula/videocache/HttpProxyCacheServer.java +++ b/library/src/main/java/com/danikula/videocache/HttpProxyCacheServer.java @@ -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 { @@ -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 danikula@gmail.com", 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 danikula@gmail.com"); + shutdown(); } private boolean pingServer() throws ProxyCacheException { @@ -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 { @@ -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)); } diff --git a/library/src/main/java/com/danikula/videocache/HttpUrlSource.java b/library/src/main/java/com/danikula/videocache/HttpUrlSource.java index a8ec0da..cc8bd22 100644 --- a/library/src/main/java/com/danikula/videocache/HttpUrlSource.java +++ b/library/src/main/java/com/danikula/videocache/HttpUrlSource.java @@ -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) { @@ -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 @@ -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); } @@ -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 @@ -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 { diff --git a/library/src/main/java/com/danikula/videocache/ProxyCache.java b/library/src/main/java/com/danikula/videocache/ProxyCache.java index 1ec1613..2e7ef45 100644 --- a/library/src/main/java/com/danikula/videocache/ProxyCache.java +++ b/library/src/main/java/com/danikula/videocache/ProxyCache.java @@ -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) { @@ -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(); } } diff --git a/library/src/main/java/com/danikula/videocache/Source.java b/library/src/main/java/com/danikula/videocache/Source.java index 1aa5fbc..6740486 100644 --- a/library/src/main/java/com/danikula/videocache/Source.java +++ b/library/src/main/java/com/danikula/videocache/Source.java @@ -16,12 +16,12 @@ public interface Source { void open(int offset) throws ProxyCacheException; /** - * Returns available bytes or negative value if available bytes count is unknown. + * Returns length bytes or negative value 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. diff --git a/sample/build.gradle b/sample/build.gradle index 5d4a212..89d6c83 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -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' } diff --git a/test/src/test/java/com/danikula/videocache/HttpProxyCacheServerTest.java b/test/src/test/java/com/danikula/videocache/HttpProxyCacheServerTest.java index f58a616..8aaa9da 100644 --- a/test/src/test/java/com/danikula/videocache/HttpProxyCacheServerTest.java +++ b/test/src/test/java/com/danikula/videocache/HttpProxyCacheServerTest.java @@ -98,7 +98,7 @@ public void testProxyFullResponseWithRedirect() throws Exception { @Test public void testProxyPartialResponse() throws Exception { - int offset = 42000; + int offset = 18000; Pair response = readProxyData(HTTP_DATA_BIG_URL, offset); assertThat(response.second.code).isEqualTo(206); @@ -116,7 +116,7 @@ public void testProxyPartialResponse() throws Exception { @Test public void testProxyPartialResponseWithRedirect() throws Exception { - int offset = 42000; + int offset = 18000; Pair response = readProxyData(HTTP_DATA_BIG_URL_ONE_REDIRECT, offset); assertThat(response.second.code).isEqualTo(206); diff --git a/test/src/test/java/com/danikula/videocache/HttpProxyCacheTest.java b/test/src/test/java/com/danikula/videocache/HttpProxyCacheTest.java new file mode 100644 index 0000000..b1b2d7b --- /dev/null +++ b/test/src/test/java/com/danikula/videocache/HttpProxyCacheTest.java @@ -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 (danikula@gmail.com). + */ +@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); + } +} diff --git a/test/src/test/java/com/danikula/videocache/HttpUrlSourceTest.java b/test/src/test/java/com/danikula/videocache/HttpUrlSourceTest.java index e4e2ea7..76162fb 100644 --- a/test/src/test/java/com/danikula/videocache/HttpUrlSourceTest.java +++ b/test/src/test/java/com/danikula/videocache/HttpUrlSourceTest.java @@ -63,14 +63,14 @@ public void testHttpUrlSourceWithOffset() throws Exception { @Test public void testFetchContentLength() throws Exception { Source source = new HttpUrlSource(HTTP_DATA_URL); - assertThat(source.available()).isEqualTo(loadAssetFile(ASSETS_DATA_NAME).length); + assertThat(source.length()).isEqualTo(loadAssetFile(ASSETS_DATA_NAME).length); } @Test public void testFetchInfoWithRedirect() throws Exception { HttpUrlSource source = new HttpUrlSource(HTTP_DATA_URL_ONE_REDIRECT); source.open(0); - int available = source.available(); + int available = source.length(); String mime = source.getMime(); source.close(); diff --git a/test/src/test/java/com/danikula/videocache/support/AngryHttpUrlSource.java b/test/src/test/java/com/danikula/videocache/support/AngryHttpUrlSource.java index 985dc94..951d725 100644 --- a/test/src/test/java/com/danikula/videocache/support/AngryHttpUrlSource.java +++ b/test/src/test/java/com/danikula/videocache/support/AngryHttpUrlSource.java @@ -11,7 +11,7 @@ public class AngryHttpUrlSource implements Source { @Override - public int available() throws ProxyCacheException { + public int length() throws ProxyCacheException { throw new IllegalStateException(); } diff --git a/test/src/test/java/com/danikula/videocache/support/ProxyCacheTestUtils.java b/test/src/test/java/com/danikula/videocache/support/ProxyCacheTestUtils.java index 8acbee5..ba0c0dd 100644 --- a/test/src/test/java/com/danikula/videocache/support/ProxyCacheTestUtils.java +++ b/test/src/test/java/com/danikula/videocache/support/ProxyCacheTestUtils.java @@ -52,6 +52,10 @@ public static Response readProxyResponse(HttpProxyCacheServer proxy, String url, } } + public static byte[] loadTestData() throws IOException { + return loadAssetFile(ASSETS_DATA_NAME); + } + public static byte[] loadAssetFile(String name) throws IOException { InputStream in = RuntimeEnvironment.application.getResources().getAssets().open(name); ByteArrayOutputStream out = new ByteArrayOutputStream(); diff --git a/test/src/test/java/com/danikula/videocache/support/Response.java b/test/src/test/java/com/danikula/videocache/support/Response.java index 6b68b64..158ed15 100644 --- a/test/src/test/java/com/danikula/videocache/support/Response.java +++ b/test/src/test/java/com/danikula/videocache/support/Response.java @@ -1,14 +1,27 @@ package com.danikula.videocache.support; +import android.text.TextUtils; + +import com.google.common.base.Preconditions; import com.google.common.io.ByteStreams; +import java.io.BufferedReader; import java.io.IOException; +import java.io.StringReader; import java.net.HttpURLConnection; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class Response { + private static final String CONTENT_TYPE_HEADER = "Content-Type"; + private static final String CONTENT_LENGTH_HEADER = "Content-Length"; + private static final Pattern STATUS_CODE_PATTERN = Pattern.compile("HTTP/1.1 (\\d{3}) "); + public final int code; public final byte[] data; public final int contentLength; @@ -22,4 +35,33 @@ public Response(HttpURLConnection connection) throws IOException { this.headers = connection.getHeaderFields(); this.data = ByteStreams.toByteArray(connection.getInputStream()); } + + public Response(byte[] responseData) throws IOException { + int read = 0; + BufferedReader reader = new BufferedReader(new StringReader(new String(responseData, "ascii"))); + String statusLine = reader.readLine(); + read += statusLine.length() + 1; + Matcher matcher = STATUS_CODE_PATTERN.matcher(statusLine); + boolean hasCode = matcher.find(); + Preconditions.checkArgument(hasCode, "Status code not found in `" + statusLine + "`"); + this.code = Integer.parseInt(matcher.group(1)); + + String header; + this.headers = new HashMap<>(); + while (!TextUtils.isEmpty(header = reader.readLine())) { + read += header.length() + 1; + String[] keyValue = header.split(":"); + String headerName = keyValue[0].trim(); + String headerValue = keyValue[1].trim(); + headers.put(headerName, Collections.singletonList(headerValue)); + } + read++; + + this.contentType = headers.containsKey(CONTENT_TYPE_HEADER) ? headers.get(CONTENT_TYPE_HEADER).get(0) : null; + this.contentLength = headers.containsKey(CONTENT_LENGTH_HEADER) ? Integer.parseInt(headers.get(CONTENT_LENGTH_HEADER).get(0)) : -1; + + int bodySize = responseData.length - read; + this.data = new byte[bodySize]; + System.arraycopy(responseData, read, data, 0, bodySize); + } } \ No newline at end of file