diff --git a/extensions/cronet/src/androidTest/AndroidManifest.xml b/extensions/cronet/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..48edf5aca6 --- /dev/null +++ b/extensions/cronet/src/androidTest/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java b/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java new file mode 100644 index 0000000000..4282244a7a --- /dev/null +++ b/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.ext.cronet; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.annotation.TargetApi; +import android.os.Build.VERSION_CODES; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import org.chromium.net.UploadDataSink; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; + +/** + * Tests for {@link ByteArrayUploadDataProvider}. + */ +@RunWith(AndroidJUnit4.class) +public final class ByteArrayUploadDataProviderTest { + + private static final byte[] TEST_DATA = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + + @Mock private UploadDataSink mockUploadDataSink; + private ByteBuffer byteBuffer; + private ByteArrayUploadDataProvider byteArrayUploadDataProvider; + + @Before + public void setUp() { + System.setProperty("dexmaker.dexcache", + InstrumentationRegistry.getTargetContext().getCacheDir().getPath()); + initMocks(this); + byteBuffer = ByteBuffer.allocate(TEST_DATA.length); + byteArrayUploadDataProvider = new ByteArrayUploadDataProvider(TEST_DATA); + } + + @Test + public void testGetLength() { + assertEquals(TEST_DATA.length, byteArrayUploadDataProvider.getLength()); + } + + @Test + public void testReadFullBuffer() throws IOException { + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertArrayEquals(TEST_DATA, byteBuffer.array()); + } + + @TargetApi(VERSION_CODES.GINGERBREAD) + @Test + public void testReadPartialBuffer() throws IOException { + byte[] firstHalf = Arrays.copyOfRange(TEST_DATA, 0, TEST_DATA.length / 2); + byte[] secondHalf = Arrays.copyOfRange(TEST_DATA, TEST_DATA.length / 2, TEST_DATA.length); + byteBuffer = ByteBuffer.allocate(TEST_DATA.length / 2); + // Read half of the data. + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertArrayEquals(firstHalf, byteBuffer.array()); + + // Read the second half of the data. + byteBuffer.rewind(); + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertArrayEquals(secondHalf, byteBuffer.array()); + verify(mockUploadDataSink, times(2)).onReadSucceeded(false); + } + + @Test + public void testRewind() throws IOException { + // Read all the data. + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertArrayEquals(TEST_DATA, byteBuffer.array()); + + // Rewind and make sure it can be read again. + byteBuffer.clear(); + byteArrayUploadDataProvider.rewind(mockUploadDataSink); + byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer); + assertArrayEquals(TEST_DATA, byteBuffer.array()); + verify(mockUploadDataSink).onRewindSucceeded(); + } + +} diff --git a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java new file mode 100644 index 0000000000..fca73af98b --- /dev/null +++ b/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java @@ -0,0 +1,840 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.ext.cronet; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.net.Uri; +import android.os.ConditionVariable; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.Predicate; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; +import org.chromium.net.CronetEngine; +import org.chromium.net.UrlRequest; +import org.chromium.net.UrlRequestException; +import org.chromium.net.UrlResponseInfo; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +/** + * Tests for {@link CronetDataSource}. + */ +@RunWith(AndroidJUnit4.class) +public final class CronetDataSourceTest { + + private static final int TEST_CONNECT_TIMEOUT_MS = 100; + private static final int TEST_READ_TIMEOUT_MS = 50; + private static final String TEST_URL = "http://google.com"; + private static final String TEST_CONTENT_TYPE = "test/test"; + private static final byte[] TEST_POST_BODY = "test post body".getBytes(); + private static final long TEST_CONTENT_LENGTH = 16000L; + private static final int TEST_BUFFER_SIZE = 16; + private static final int TEST_CONNECTION_STATUS = 5; + + private DataSpec testDataSpec; + private DataSpec testPostDataSpec; + private Map testResponseHeader; + private UrlResponseInfo testUrlResponseInfo; + + /** + * MockableCronetEngine is an abstract class for helping creating new Requests. + */ + public abstract static class MockableCronetEngine extends CronetEngine { + + @Override + public abstract UrlRequest createRequest(String url, UrlRequest.Callback callback, + Executor executor, int priority, + Collection connectionAnnotations, + boolean disableCache, + boolean disableConnectionMigration); + } + + @Mock + private UrlRequest mockUrlRequest; + @Mock + private Predicate mockContentTypePredicate; + @Mock + private TransferListener mockTransferListener; + @Mock + private Clock mockClock; + @Mock + private Executor mockExecutor; + @Mock + private UrlRequestException mockUrlRequestException; + @Mock + private MockableCronetEngine mockCronetEngine; + + private CronetDataSource dataSourceUnderTest; + + @Before + public void setUp() throws Exception { + System.setProperty("dexmaker.dexcache", + InstrumentationRegistry.getTargetContext().getCacheDir().getPath()); + initMocks(this); + dataSourceUnderTest = spy( + new CronetDataSource( + mockCronetEngine, + mockExecutor, + mockContentTypePredicate, + mockTransferListener, + TEST_CONNECT_TIMEOUT_MS, + TEST_READ_TIMEOUT_MS, + true, // resetTimeoutOnRedirects + mockClock)); + when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true); + when(mockCronetEngine.createRequest( + anyString(), + any(UrlRequest.Callback.class), + any(Executor.class), + anyInt(), + eq(Collections.emptyList()), + any(Boolean.class), + any(Boolean.class))).thenReturn(mockUrlRequest); + mockStatusResponse(); + + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, C.LENGTH_UNSET, null); + testPostDataSpec = new DataSpec( + Uri.parse(TEST_URL), TEST_POST_BODY, 0, 0, C.LENGTH_UNSET, null, 0); + testResponseHeader = new HashMap<>(); + testResponseHeader.put("Content-Type", TEST_CONTENT_TYPE); + // This value can be anything since the DataSpec is unset. + testResponseHeader.put("Content-Length", Long.toString(TEST_CONTENT_LENGTH)); + testUrlResponseInfo = createUrlResponseInfo(200); // statusCode + } + + private UrlResponseInfo createUrlResponseInfo(int statusCode) { + ArrayList> responseHeaderList = new ArrayList<>(); + responseHeaderList.addAll(testResponseHeader.entrySet()); + return new UrlResponseInfo( + Collections.singletonList(TEST_URL), + statusCode, + null, // httpStatusText + responseHeaderList, + false, // wasCached + null, // negotiatedProtocol + null); // proxyServer + } + + @Test(expected = IllegalStateException.class) + public void testOpeningTwiceThrows() throws HttpDataSourceException, IllegalStateException { + mockResponesStartSuccess(); + + assertConnectionState(CronetDataSource.IDLE_CONNECTION); + dataSourceUnderTest.open(testDataSpec); + assertConnectionState(CronetDataSource.OPEN_CONNECTION); + dataSourceUnderTest.open(testDataSpec); + } + + @Test + public void testCallbackFromPreviousRequest() throws HttpDataSourceException { + mockResponesStartSuccess(); + + dataSourceUnderTest.open(testDataSpec); + dataSourceUnderTest.close(); + // Prepare a mock UrlRequest to be used in the second open() call. + final UrlRequest mockUrlRequest2 = mock(UrlRequest.class); + when(mockCronetEngine.createRequest( + anyString(), + any(UrlRequest.Callback.class), + any(Executor.class), + anyInt(), + eq(Collections.emptyList()), + any(Boolean.class), + any(Boolean.class))).thenReturn(mockUrlRequest2); + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + // Invoke the callback for the previous request. + dataSourceUnderTest.onFailed( + mockUrlRequest, + testUrlResponseInfo, + null); + dataSourceUnderTest.onResponseStarted( + mockUrlRequest2, + testUrlResponseInfo); + return null; + } + }).when(mockUrlRequest2).start(); + dataSourceUnderTest.open(testDataSpec); + } + + @Test + public void testRequestStartCalled() throws HttpDataSourceException { + mockResponesStartSuccess(); + + dataSourceUnderTest.open(testDataSpec); + verify(mockCronetEngine).createRequest( + eq(TEST_URL), + any(UrlRequest.Callback.class), + any(Executor.class), + anyInt(), + eq(Collections.emptyList()), + any(Boolean.class), + any(Boolean.class)); + verify(mockUrlRequest).start(); + } + + @Test + public void testRequestHeadersSet() throws HttpDataSourceException { + mockResponesStartSuccess(); + + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); + testResponseHeader.put("Content-Length", Long.toString(5000L)); + + dataSourceUnderTest.setRequestProperty("firstHeader", "firstValue"); + dataSourceUnderTest.setRequestProperty("secondHeader", "secondValue"); + + dataSourceUnderTest.open(testDataSpec); + // The header value to add is current position to current position + length - 1. + verify(mockUrlRequest).addHeader("Range", "bytes=1000-5999"); + verify(mockUrlRequest).addHeader("firstHeader", "firstValue"); + verify(mockUrlRequest).addHeader("secondHeader", "secondValue"); + verify(mockUrlRequest).start(); + } + + @Test + public void testRequestOpen() throws HttpDataSourceException { + mockResponesStartSuccess(); + + assertEquals(TEST_CONTENT_LENGTH, dataSourceUnderTest.open(testDataSpec)); + assertConnectionState(CronetDataSource.OPEN_CONNECTION); + verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testDataSpec); + } + + @Test + public void testRequestOpenFail() { + mockResponseStartFailure(); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + // Check for connection not automatically closed. + assertFalse(e.getCause() instanceof UnknownHostException); + verify(mockUrlRequest, never()).cancel(); + assertConnectionState(CronetDataSource.OPENING_CONNECTION); + verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); + } + } + + @Test + public void testRequestOpenFailDueToDnsFailure() { + mockResponseStartFailure(); + when(mockUrlRequestException.getErrorCode()).thenReturn( + UrlRequestException.ERROR_HOSTNAME_NOT_RESOLVED); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + // Check for connection not automatically closed. + assertTrue(e.getCause() instanceof UnknownHostException); + verify(mockUrlRequest, never()).cancel(); + assertConnectionState(CronetDataSource.OPENING_CONNECTION); + verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); + } + } + + @Test + public void testRequestOpenValidatesStatusCode() { + mockResponesStartSuccess(); + testUrlResponseInfo = createUrlResponseInfo(500); // statusCode + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + assertTrue(e instanceof HttpDataSource.InvalidResponseCodeException); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + assertConnectionState(CronetDataSource.OPENING_CONNECTION); + verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); + } + } + + @Test + public void testRequestOpenValidatesContentTypePredicate() { + mockResponesStartSuccess(); + when(mockContentTypePredicate.evaluate(anyString())).thenReturn(false); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + assertTrue(e instanceof HttpDataSource.InvalidContentTypeException); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + assertConnectionState(CronetDataSource.OPENING_CONNECTION); + verify(mockContentTypePredicate).evaluate(TEST_CONTENT_TYPE); + } + } + + @Test + public void testRequestOpenValidatesContentLength() { + mockResponesStartSuccess(); + + // Data spec's requested length, 5000. Test response's length, 16,000. + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null); + + try { + dataSourceUnderTest.open(testDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + verify(mockUrlRequest).addHeader("Range", "bytes=1000-5999"); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + assertConnectionState(CronetDataSource.OPENING_CONNECTION); + verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testPostDataSpec); + } + } + + @Test + public void testPostRequestOpen() throws HttpDataSourceException { + mockResponesStartSuccess(); + + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + assertEquals(TEST_CONTENT_LENGTH, dataSourceUnderTest.open(testPostDataSpec)); + assertConnectionState(CronetDataSource.OPEN_CONNECTION); + verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testPostDataSpec); + } + + @Test + public void testPostRequestOpenValidatesContentType() { + mockResponesStartSuccess(); + + try { + dataSourceUnderTest.open(testPostDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + verify(mockUrlRequest, never()).start(); + } + } + + @Test + public void testPostRequestOpenRejects307Redirects() { + mockResponesStartSuccess(); + mockResponseStartRedirect(); + + try { + dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); + dataSourceUnderTest.open(testPostDataSpec); + fail("HttpDataSource.HttpDataSourceException expected"); + } catch (HttpDataSourceException e) { + verify(mockUrlRequest, never()).followRedirect(); + } + } + + @Test + public void testRequestReadTwice() throws HttpDataSourceException { + mockResponesStartSuccess(); + mockReadSuccess(); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[8]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer); + assertEquals(8, bytesRead); + + returnedBuffer = new byte[8]; + bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertArrayEquals(buildTestDataArray(8, 8), returnedBuffer); + assertEquals(8, bytesRead); + + // Should have only called read on cronet once. + verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); + verify(mockTransferListener, times(2)).onBytesTransferred(dataSourceUnderTest, 8); + } + + @Test + public void testSecondRequestNoContentLength() throws HttpDataSourceException { + mockResponesStartSuccess(); + mockReadSuccess(); + + byte[] returnedBuffer = new byte[8]; + + // First request. + testResponseHeader.put("Content-Length", Long.toString(1L)); + testUrlResponseInfo = createUrlResponseInfo(200); // statusCode + dataSourceUnderTest.open(testDataSpec); + dataSourceUnderTest.read(returnedBuffer, 0, 1); + dataSourceUnderTest.close(); + + // Second request. There's no Content-Length response header. + testResponseHeader.remove("Content-Length"); + testUrlResponseInfo = createUrlResponseInfo(200); // statusCode + dataSourceUnderTest.open(testDataSpec); + returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); + assertEquals(10, bytesRead); + + mockResponseFinished(); + + // Should read whats left in the buffer first. + bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); + assertEquals(6, bytesRead); + bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10); + assertEquals(C.RESULT_END_OF_INPUT, bytesRead); + } + + @Test + public void testReadWithOffset() throws HttpDataSourceException { + mockResponesStartSuccess(); + mockReadSuccess(); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[16]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8); + assertArrayEquals(prefixZeros(buildTestDataArray(0, 8), 16), returnedBuffer); + assertEquals(8, bytesRead); + verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 8); + } + + @Test + public void testReadReturnsWhatItCan() throws HttpDataSourceException { + mockResponesStartSuccess(); + mockReadSuccess(); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[24]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 24); + assertArrayEquals(suffixZeros(buildTestDataArray(0, 16), 24), returnedBuffer); + assertEquals(16, bytesRead); + verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16); + } + + @Test + public void testClosedMeansClosed() throws HttpDataSourceException { + mockResponesStartSuccess(); + mockReadSuccess(); + + int bytesRead = 0; + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[8]; + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer); + assertEquals(8, bytesRead); + + dataSourceUnderTest.close(); + verify(mockTransferListener).onTransferEnd(dataSourceUnderTest); + assertConnectionState(CronetDataSource.IDLE_CONNECTION); + + try { + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); + fail(); + } catch (IllegalStateException e) { + // Expected. + } + + // 16 bytes were attempted but only 8 should have been successfully read. + assertEquals(8, bytesRead); + } + + @Test + public void testOverread() throws HttpDataSourceException { + mockResponesStartSuccess(); + mockReadSuccess(); + + // Ask for 16 bytes + testDataSpec = new DataSpec(Uri.parse(TEST_URL), 10000, 16, null); + // Let the response promise to give 16 bytes back. + testResponseHeader.put("Content-Length", Long.toString(16L)); + + dataSourceUnderTest.open(testDataSpec); + + byte[] returnedBuffer = new byte[8]; + int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer); + assertEquals(8, bytesRead); + + // The current buffer is kept if not completely consumed by DataSource reader. + returnedBuffer = new byte[8]; + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 6); + assertArrayEquals(suffixZeros(buildTestDataArray(8, 6), 8), returnedBuffer); + assertEquals(14, bytesRead); + + // 2 bytes left at this point. + returnedBuffer = new byte[8]; + bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8); + assertArrayEquals(suffixZeros(buildTestDataArray(14, 2), 8), returnedBuffer); + assertEquals(16, bytesRead); + + // Should have only called read on cronet once. + verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); + verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 8); + verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 6); + verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 2); + + // Now we already returned the 16 bytes initially asked. + // Try to read again even though all requested 16 bytes are already returned. + // Return C.RESULT_END_OF_INPUT + returnedBuffer = new byte[16]; + int bytesOverRead = dataSourceUnderTest.read(returnedBuffer, 0, 16); + assertEquals(C.RESULT_END_OF_INPUT, bytesOverRead); + assertArrayEquals(new byte[16], returnedBuffer); + // C.RESULT_END_OF_INPUT should not be reported though the TransferListener. + verify(mockTransferListener, never()).onBytesTransferred(dataSourceUnderTest, + C.RESULT_END_OF_INPUT); + // There should still be only one call to read on cronet. + verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class)); + // Check for connection not automatically closed. + verify(mockUrlRequest, never()).cancel(); + assertConnectionState(CronetDataSource.OPEN_CONNECTION); + assertEquals(16, bytesRead); + } + + @Test + public void testConnectTimeout() { + when(mockClock.elapsedRealtime()).thenReturn(0L); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final ConditionVariable timedOutCondition = new ConditionVariable(); + + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertTrue(e instanceof CronetDataSource.OpenException); + assertTrue(e.getCause() instanceof SocketTimeoutException); + assertEquals( + TEST_CONNECTION_STATUS, + ((CronetDataSource.OpenException) e).cronetConnectionStatus); + timedOutCondition.open(); + } + } + }.start(); + startCondition.block(); + + // We should still be trying to open. + assertFalse(timedOutCondition.block(50)); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + // We should still be trying to open as we approach the timeout. + when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1); + assertFalse(timedOutCondition.block(50)); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + // Now we timeout. + when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS); + timedOutCondition.block(); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + + verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); + } + + @Test + public void testConnectResponseBeforeTimeout() { + when(mockClock.elapsedRealtime()).thenReturn(0L); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final ConditionVariable openCondition = new ConditionVariable(); + + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + openCondition.open(); + } catch (HttpDataSourceException e) { + fail(); + } + } + }.start(); + startCondition.block(); + + // We should still be trying to open. + assertFalse(openCondition.block(50)); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + // We should still be trying to open as we approach the timeout. + when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1); + assertFalse(openCondition.block(50)); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + // The response arrives just in time. + dataSourceUnderTest.onResponseStarted(mockUrlRequest, testUrlResponseInfo); + openCondition.block(); + assertEquals(CronetDataSource.OPEN_CONNECTION, dataSourceUnderTest.connectionState); + } + + @Test + public void testRedirectIncreasesConnectionTimeout() throws InterruptedException { + when(mockClock.elapsedRealtime()).thenReturn(0L); + final ConditionVariable startCondition = buildUrlRequestStartedCondition(); + final ConditionVariable timedOutCondition = new ConditionVariable(); + final AtomicInteger openExceptions = new AtomicInteger(0); + + new Thread() { + @Override + public void run() { + try { + dataSourceUnderTest.open(testDataSpec); + fail(); + } catch (HttpDataSourceException e) { + // Expected. + assertTrue(e instanceof CronetDataSource.OpenException); + assertTrue(e.getCause() instanceof SocketTimeoutException); + openExceptions.getAndIncrement(); + timedOutCondition.open(); + } + } + }.start(); + startCondition.block(); + + // We should still be trying to open. + assertFalse(timedOutCondition.block(50)); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + // We should still be trying to open as we approach the timeout. + when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1); + assertFalse(timedOutCondition.block(50)); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + // A redirect arrives just in time. + dataSourceUnderTest.onRedirectReceived(mockUrlRequest, testUrlResponseInfo, + "RandomRedirectedUrl1"); + + long newTimeoutMs = 2 * TEST_CONNECT_TIMEOUT_MS - 1; + when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs - 1); + // Give the thread some time to run. + assertFalse(timedOutCondition.block(newTimeoutMs)); + // We should still be trying to open as we approach the new timeout. + assertFalse(timedOutCondition.block(50)); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + // A redirect arrives just in time. + dataSourceUnderTest.onRedirectReceived(mockUrlRequest, testUrlResponseInfo, + "RandomRedirectedUrl2"); + + newTimeoutMs = 3 * TEST_CONNECT_TIMEOUT_MS - 2; + when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs - 1); + // Give the thread some time to run. + assertFalse(timedOutCondition.block(newTimeoutMs)); + // We should still be trying to open as we approach the new timeout. + assertFalse(timedOutCondition.block(50)); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + // Now we timeout. + when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs); + timedOutCondition.block(); + assertEquals(CronetDataSource.OPENING_CONNECTION, dataSourceUnderTest.connectionState); + + verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec); + assertEquals(1, openExceptions.get()); + } + + @Test + public void testExceptionFromTransferListener() throws HttpDataSourceException { + mockResponesStartSuccess(); + + // Make mockTransferListener throw an exception in CronetDataSource.close(). Ensure that + // the subsequent open() call succeeds. + doThrow(new NullPointerException()).when(mockTransferListener).onTransferEnd( + dataSourceUnderTest); + dataSourceUnderTest.open(testDataSpec); + try { + dataSourceUnderTest.close(); + fail("NullPointerException expected"); + } catch (NullPointerException e) { + // Expected. + } + // Open should return successfully. + dataSourceUnderTest.open(testDataSpec); + } + + @Test + public void testReadFailure() throws HttpDataSourceException { + mockResponesStartSuccess(); + mockReadFailure(); + + dataSourceUnderTest.open(testDataSpec); + byte[] returnedBuffer = new byte[8]; + try { + dataSourceUnderTest.read(returnedBuffer, 0, 8); + fail("dataSourceUnderTest.read() returned, but IOException expected"); + } catch (IOException e) { + // Expected. + } + } + + // Helper methods. + + private void mockStatusResponse() { + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + UrlRequest.StatusListener statusListener = + (UrlRequest.StatusListener) invocation.getArguments()[0]; + statusListener.onStatus(TEST_CONNECTION_STATUS); + return null; + } + }).when(mockUrlRequest).getStatus(any(UrlRequest.StatusListener.class)); + } + + private void mockResponesStartSuccess() { + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + dataSourceUnderTest.onResponseStarted( + mockUrlRequest, + testUrlResponseInfo); + return null; + } + }).when(mockUrlRequest).start(); + } + + private void mockResponseStartRedirect() { + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + dataSourceUnderTest.onRedirectReceived( + mockUrlRequest, + createUrlResponseInfo(307), // statusCode + "http://redirect.location.com"); + return null; + } + }).when(mockUrlRequest).start(); + } + + private void mockResponseStartFailure() { + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + dataSourceUnderTest.onFailed( + mockUrlRequest, + createUrlResponseInfo(500), // statusCode + mockUrlRequestException); + return null; + } + }).when(mockUrlRequest).start(); + } + + private void mockReadSuccess() { + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0]; + inputBuffer.put(buildTestDataBuffer()); + dataSourceUnderTest.onReadCompleted( + mockUrlRequest, + testUrlResponseInfo, + inputBuffer); + return null; + } + }).when(mockUrlRequest).read(any(ByteBuffer.class)); + } + + private void mockReadFailure() { + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + dataSourceUnderTest.onFailed( + mockUrlRequest, + createUrlResponseInfo(500), // statusCode + null); + return null; + } + }).when(mockUrlRequest).read(any(ByteBuffer.class)); + } + + private void mockResponseFinished() { + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + dataSourceUnderTest.onSucceeded(mockUrlRequest, testUrlResponseInfo); + return null; + } + }).when(mockUrlRequest).read(any(ByteBuffer.class)); + } + + private ConditionVariable buildUrlRequestStartedCondition() { + final ConditionVariable startedCondition = new ConditionVariable(); + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + startedCondition.open(); + return null; + } + }).when(mockUrlRequest).start(); + return startedCondition; + } + + private static byte[] buildTestDataArray(int start, int length) { + return Arrays.copyOfRange(buildTestDataBuffer().array(), start, start + length); + } + + public static byte[] prefixZeros(byte[] data, int requiredLength) { + byte[] prefixedData = new byte[requiredLength]; + System.arraycopy(data, 0, prefixedData, requiredLength - data.length, data.length); + return prefixedData; + } + + public static byte[] suffixZeros(byte[] data, int requiredLength) { + return Arrays.copyOf(data, requiredLength); + } + + private static ByteBuffer buildTestDataBuffer() { + ByteBuffer testBuffer = ByteBuffer.allocate(TEST_BUFFER_SIZE); + for (byte i = 1; i <= TEST_BUFFER_SIZE; i++) { + testBuffer.put(i); + } + testBuffer.flip(); + return testBuffer; + } + + private void assertConnectionState(int state) { + assertEquals(state, dataSourceUnderTest.connectionState); + } + +} diff --git a/extensions/cronet/src/main/AndroidManifest.xml b/extensions/cronet/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..4de726be98 --- /dev/null +++ b/extensions/cronet/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java new file mode 100644 index 0000000000..314e06900e --- /dev/null +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cronet; + +import java.io.IOException; +import java.nio.ByteBuffer; +import org.chromium.net.UploadDataProvider; +import org.chromium.net.UploadDataSink; + +/** + * A {@link UploadDataProvider} implementation that provides data from a {@code byte[]}. + */ +/* package */ final class ByteArrayUploadDataProvider extends UploadDataProvider { + + private final byte[] data; + + private int position; + + public ByteArrayUploadDataProvider(byte[] data) { + this.data = data; + } + + @Override + public long getLength() { + return data.length; + } + + @Override + public void read(UploadDataSink uploadDataSink, ByteBuffer byteBuffer) throws IOException { + int readLength = Math.min(byteBuffer.remaining(), data.length - position); + byteBuffer.put(data, position, readLength); + position += readLength; + uploadDataSink.onReadSucceeded(false); + } + + @Override + public void rewind(UploadDataSink uploadDataSink) throws IOException { + position = 0; + uploadDataSink.onRewindSucceeded(); + } + +} diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java new file mode 100644 index 0000000000..9e4c463c87 --- /dev/null +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java @@ -0,0 +1,556 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cronet; + +import android.net.Uri; +import android.os.ConditionVariable; +import android.text.TextUtils; +import android.util.Log; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.Predicate; +import com.google.android.exoplayer2.util.SystemClock; +import com.google.android.exoplayer2.util.TraceUtil; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.chromium.net.CronetEngine; +import org.chromium.net.UrlRequest; +import org.chromium.net.UrlRequestException; +import org.chromium.net.UrlResponseInfo; + +/** + * DataSource without intermediate buffer based on Cronet API set using UrlRequest. + * This class's methods are organized in the sequence of expected calls. + */ +public class CronetDataSource extends UrlRequest.Callback implements HttpDataSource { + + /** + * Thrown when an error is encountered when trying to open a {@link CronetDataSource}. + */ + public static final class OpenException extends HttpDataSourceException { + + /** + * Returns the status of the connection establishment at the moment when the error occurred, as + * defined by {@link UrlRequest.Status}. + */ + public final int cronetConnectionStatus; + + public OpenException(IOException cause, DataSpec dataSpec, int cronetConnectionStatus) { + super(cause, dataSpec, TYPE_OPEN); + this.cronetConnectionStatus = cronetConnectionStatus; + } + + public OpenException(String errorMessage, DataSpec dataSpec, int cronetConnectionStatus) { + super(errorMessage, dataSpec, TYPE_OPEN); + this.cronetConnectionStatus = cronetConnectionStatus; + } + + } + + /** + * The default connection timeout, in milliseconds. + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 8 * 1000; + /** + * The default read timeout, in milliseconds. + */ + public static final int DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000; + + private static final String TAG = "CronetDataSource"; + private static final Pattern CONTENT_RANGE_HEADER_PATTERN = + Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$"); + // The size of read buffer passed to cronet UrlRequest.read(). + private static final int READ_BUFFER_SIZE_BYTES = 32 * 1024; + + /* package */ static final int IDLE_CONNECTION = 5; + /* package */ static final int OPENING_CONNECTION = 2; + /* package */ static final int CONNECTED_CONNECTION = 3; + /* package */ static final int OPEN_CONNECTION = 4; + + private final CronetEngine cronetEngine; + private final Executor executor; + private final Predicate contentTypePredicate; + private final TransferListener transferListener; + private final int connectTimeoutMs; + private final int readTimeoutMs; + private final boolean resetTimeoutOnRedirects; + private final Map requestProperties; + private final ConditionVariable operation; + private final ByteBuffer readBuffer; + private final Clock clock; + + private UrlRequest currentUrlRequest; + private DataSpec currentDataSpec; + private UrlResponseInfo responseInfo; + + /* package */ volatile int connectionState; + private volatile String currentUrl; + private volatile long currentConnectTimeoutMs; + private volatile HttpDataSourceException exception; + private volatile long contentLength; + private volatile AtomicLong expectedBytesRemainingToRead; + private volatile boolean hasData; + private volatile boolean responseFinished; + + /** + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from + * {@link #open(DataSpec)}. + * @param transferListener A listener. + */ + public CronetDataSource(CronetEngine cronetEngine, Executor executor, + Predicate contentTypePredicate, TransferListener transferListener) { + this(cronetEngine, executor, contentTypePredicate, transferListener, + DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false); + } + + /** + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from + * {@link #open(DataSpec)}. + * @param transferListener A listener. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + */ + public CronetDataSource(CronetEngine cronetEngine, Executor executor, + Predicate contentTypePredicate, TransferListener transferListener, + int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects) { + this(cronetEngine, executor, contentTypePredicate, transferListener, connectTimeoutMs, + readTimeoutMs, resetTimeoutOnRedirects, new SystemClock()); + } + + /* package */ CronetDataSource(CronetEngine cronetEngine, Executor executor, + Predicate contentTypePredicate, TransferListener transferListener, + int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects, Clock clock) { + this.cronetEngine = Assertions.checkNotNull(cronetEngine); + this.executor = Assertions.checkNotNull(executor); + this.contentTypePredicate = contentTypePredicate; + this.transferListener = transferListener; + this.connectTimeoutMs = connectTimeoutMs; + this.readTimeoutMs = readTimeoutMs; + this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; + this.clock = Assertions.checkNotNull(clock); + readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES); + requestProperties = new HashMap<>(); + operation = new ConditionVariable(); + connectionState = IDLE_CONNECTION; + } + + @Override + public void setRequestProperty(String name, String value) { + synchronized (requestProperties) { + requestProperties.put(name, value); + } + } + + @Override + public void clearRequestProperty(String name) { + synchronized (requestProperties) { + requestProperties.remove(name); + } + } + + @Override + public void clearAllRequestProperties() { + synchronized (requestProperties) { + requestProperties.clear(); + } + } + + @Override + public Map> getResponseHeaders() { + return responseInfo == null ? null : responseInfo.getAllHeaders(); + } + + @Override + public long open(DataSpec dataSpec) throws HttpDataSourceException { + TraceUtil.beginSection("CronetDataSource.open"); + try { + Assertions.checkNotNull(dataSpec); + synchronized (this) { + Assertions.checkState(connectionState == IDLE_CONNECTION, "Connection already open"); + connectionState = OPENING_CONNECTION; + } + + operation.close(); + resetConnectTimeout(); + startRequest(dataSpec); + boolean requestStarted = blockUntilConnectTimeout(); + + if (exception != null) { + // An error occurred opening the connection. + throw exception; + } else if (!requestStarted) { + // The timeout was reached before the connection was opened. + throw new OpenException(new SocketTimeoutException(), dataSpec, getCurrentRequestStatus()); + } + + // Connection was opened. + if (transferListener != null) { + transferListener.onTransferStart(this, dataSpec); + } + connectionState = OPEN_CONNECTION; + return contentLength; + } finally { + TraceUtil.endSection(); + } + } + + private void startRequest(DataSpec dataSpec) throws HttpDataSourceException { + currentUrl = dataSpec.uri.toString(); + currentDataSpec = dataSpec; + UrlRequest.Builder urlRequestBuilder = new UrlRequest.Builder(currentUrl, this, executor, + cronetEngine); + fillCurrentRequestHeader(urlRequestBuilder); + fillCurrentRequestPostBody(urlRequestBuilder, dataSpec); + currentUrlRequest = urlRequestBuilder.build(); + currentUrlRequest.start(); + } + + private void fillCurrentRequestHeader(UrlRequest.Builder urlRequestBuilder) { + synchronized (requestProperties) { + for (Entry headerEntry : requestProperties.entrySet()) { + urlRequestBuilder.addHeader(headerEntry.getKey(), headerEntry.getValue()); + } + } + if (currentDataSpec.position == 0 && currentDataSpec.length == C.LENGTH_UNSET) { + // Not required. + return; + } + StringBuilder rangeValue = new StringBuilder(); + rangeValue.append("bytes="); + rangeValue.append(currentDataSpec.position); + rangeValue.append("-"); + if (currentDataSpec.length != C.LENGTH_UNSET) { + rangeValue.append(currentDataSpec.position + currentDataSpec.length - 1); + } + urlRequestBuilder.addHeader("Range", rangeValue.toString()); + } + + private void fillCurrentRequestPostBody(UrlRequest.Builder urlRequestBuilder, DataSpec dataSpec) + throws HttpDataSourceException { + if (dataSpec.postBody != null) { + if (!requestProperties.containsKey("Content-Type")) { + throw new OpenException("POST requests must set a Content-Type header", dataSpec, + getCurrentRequestStatus()); + } + urlRequestBuilder.setUploadDataProvider( + new ByteArrayUploadDataProvider(dataSpec.postBody), executor); + } + } + + @Override + public synchronized void onFailed( + UrlRequest request, UrlResponseInfo info, UrlRequestException error) { + if (request != currentUrlRequest) { + return; + } + if (connectionState == OPENING_CONNECTION) { + IOException cause = error.getErrorCode() == UrlRequestException.ERROR_HOSTNAME_NOT_RESOLVED + ? new UnknownHostException() : error; + exception = new OpenException(cause, currentDataSpec, getCurrentRequestStatus()); + } else if (connectionState == OPEN_CONNECTION) { + readBuffer.limit(0); + exception = new HttpDataSourceException(error, currentDataSpec, + HttpDataSourceException.TYPE_READ); + } + operation.open(); + } + + @Override + public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) { + if (request != currentUrlRequest) { + return; + } + TraceUtil.beginSection("CronetDataSource.onResponseStarted"); + try { + validateResponse(info); + responseInfo = info; + // Check content length. + contentLength = getContentLength(info.getAllHeaders()); + // If a specific length is requested and a specific length is returned but the 2 don't match + // it's an error. + if (currentDataSpec.length != C.LENGTH_UNSET + && contentLength != C.LENGTH_UNSET + && currentDataSpec.length != contentLength) { + throw new OpenException("Content length did not match requested length", currentDataSpec, + getCurrentRequestStatus()); + } + + if (contentLength > 0) { + expectedBytesRemainingToRead = new AtomicLong(contentLength); + } + + // Keep track of redirects. + currentUrl = responseInfo.getUrl(); + connectionState = CONNECTED_CONNECTION; + } catch (HttpDataSourceException e) { + exception = e; + } finally { + operation.open(); + TraceUtil.endSection(); + } + } + + private void validateResponse(UrlResponseInfo info) throws HttpDataSourceException { + // Check for a valid response code. + int responseCode = info.getHttpStatusCode(); + if (responseCode < 200 || responseCode > 299) { + throw new InvalidResponseCodeException(responseCode, info.getAllHeaders(), currentDataSpec); + } + // Check for a valid content type. + try { + String contentType = info.getAllHeaders().get("Content-Type").get(0); + if (contentTypePredicate != null && !contentTypePredicate.evaluate(contentType)) { + throw new InvalidContentTypeException(contentType, currentDataSpec); + } + } catch (IndexOutOfBoundsException e) { + throw new InvalidContentTypeException(null, currentDataSpec); + } + } + + private long getContentLength(Map> headers) { + // Logic copied from {@code DefaultHttpDataSource} + long contentLength = C.LENGTH_UNSET; + List contentLengthHeader = headers.get("Content-Length"); + if (contentLengthHeader != null + && !contentLengthHeader.isEmpty() + && !TextUtils.isEmpty(contentLengthHeader.get(0))) { + try { + contentLength = Long.parseLong(contentLengthHeader.get(0)); + } catch (NumberFormatException e) { + log(Log.ERROR, "Unexpected Content-Length [" + contentLengthHeader + "]"); + } + } + List contentRangeHeader = headers.get("Content-Range"); + if (contentRangeHeader != null + && !contentRangeHeader.isEmpty() + && !TextUtils.isEmpty(contentRangeHeader.get(0))) { + Matcher matcher = CONTENT_RANGE_HEADER_PATTERN.matcher(contentRangeHeader.get(0)); + if (matcher.find()) { + try { + long contentLengthFromRange = + Long.parseLong(matcher.group(2)) - Long.parseLong(matcher.group(1)) + 1; + if (contentLength < 0) { + // Some proxy servers strip the Content-Length header. Fall back to the length + // calculated here in this case. + contentLength = contentLengthFromRange; + } else if (contentLength != contentLengthFromRange) { + // If there is a discrepancy between the Content-Length and Content-Range headers, + // assume the one with the larger value is correct. We have seen cases where carrier + // change one of them to reduce the size of a request, but it is unlikely anybody + // would increase it. + log(Log.WARN, "Inconsistent headers [" + contentLengthHeader + "] [" + + contentRangeHeader + "]"); + contentLength = Math.max(contentLength, contentLengthFromRange); + } + } catch (NumberFormatException e) { + log(Log.ERROR, "Unexpected Content-Range [" + contentRangeHeader + "]"); + } + } + } + return contentLength; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException { + TraceUtil.beginSection("CronetDataSource.read"); + try { + synchronized (this) { + if (connectionState != OPEN_CONNECTION) { + throw new IllegalStateException("Connection not ready"); + } + } + + // If being asked to read beyond the amount of bytes initially requested, return + // RESULT_END_OF_INPUT. + if (expectedBytesRemainingToRead != null && expectedBytesRemainingToRead.get() <= 0) { + return C.RESULT_END_OF_INPUT; + } + + if (!hasData) { + // Read more data from cronet. + operation.close(); + currentUrlRequest.read(readBuffer); + if (!operation.block(readTimeoutMs)) { + throw new HttpDataSourceException( + new SocketTimeoutException(), currentDataSpec, HttpDataSourceException.TYPE_READ); + } + if (exception != null) { + throw exception; + } + // The expected response length is unknown, but cronet has indicated that the request + // already finished successfully. + if (responseFinished) { + return C.RESULT_END_OF_INPUT; + } + } + + int bytesRead = Math.min(readBuffer.remaining(), readLength); + + readBuffer.get(buffer, offset, bytesRead); + + if (!readBuffer.hasRemaining()) { + readBuffer.clear(); + hasData = false; + } + + if (expectedBytesRemainingToRead != null) { + expectedBytesRemainingToRead.addAndGet(-bytesRead); + } + + if (transferListener != null && bytesRead >= 0) { + transferListener.onBytesTransferred(this, bytesRead); + } + return bytesRead; + } finally { + TraceUtil.endSection(); + } + } + + @Override + public void onRedirectReceived(UrlRequest request, UrlResponseInfo info, String newLocationUrl) { + if (request != currentUrlRequest) { + return; + } + if (currentDataSpec.postBody != null) { + int responseCode = info.getHttpStatusCode(); + // The industry standard is to disregard POST redirects when the status code is 307 or 308. + // For other redirect response codes the POST request is converted to a GET request and the + // redirect is followed. + if (responseCode == 307 || responseCode == 308) { + exception = new OpenException("POST request redirected with 307 or 308 response code", + currentDataSpec, getCurrentRequestStatus()); + operation.open(); + return; + } + } + if (resetTimeoutOnRedirects) { + resetConnectTimeout(); + } + request.followRedirect(); + } + + @Override + public synchronized void onReadCompleted(UrlRequest request, UrlResponseInfo info, + ByteBuffer buffer) { + if (request != currentUrlRequest) { + return; + } + readBuffer.flip(); + if (readBuffer.limit() > 0) { + hasData = true; + } + operation.open(); + } + + @Override + public void onSucceeded(UrlRequest request, UrlResponseInfo info) { + if (request != currentUrlRequest) { + return; + } + responseFinished = true; + operation.open(); + } + + @Override + public synchronized void close() { + TraceUtil.beginSection("CronetDataSource.close"); + try { + if (currentUrlRequest != null) { + currentUrlRequest.cancel(); + currentUrlRequest = null; + } + readBuffer.clear(); + currentDataSpec = null; + currentUrl = null; + exception = null; + contentLength = 0; + hasData = false; + responseInfo = null; + expectedBytesRemainingToRead = null; + responseFinished = false; + if (transferListener != null && connectionState == OPEN_CONNECTION) { + transferListener.onTransferEnd(this); + } + } finally { + connectionState = IDLE_CONNECTION; + TraceUtil.endSection(); + } + } + + @Override + public Uri getUri() { + return Uri.parse(currentUrl); + } + + private void log(int priority, String message) { + if (Log.isLoggable(TAG, priority)) { + Log.println(priority, TAG, message); + } + } + + private int getCurrentRequestStatus() { + if (currentUrlRequest == null) { + return UrlRequest.Status.IDLE; + } + final ConditionVariable conditionVariable = new ConditionVariable(); + final AtomicInteger result = new AtomicInteger(); + currentUrlRequest.getStatus(new UrlRequest.StatusListener() { + @Override + public void onStatus(int status) { + result.set(status); + conditionVariable.open(); + } + }); + return result.get(); + } + + private boolean blockUntilConnectTimeout() { + long now = clock.elapsedRealtime(); + boolean opened = false; + while (!opened && now < currentConnectTimeoutMs) { + opened = operation.block(currentConnectTimeoutMs - now + 5 /* fudge factor */); + now = clock.elapsedRealtime(); + } + return opened; + } + + private void resetConnectTimeout() { + currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs; + } + +} diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/DefaultCronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/DefaultCronetDataSourceFactory.java new file mode 100644 index 0000000000..9cabd3c002 --- /dev/null +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/DefaultCronetDataSourceFactory.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cronet; + +import android.content.Context; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSource.Factory; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Predicate; +import java.util.concurrent.Executor; +import org.chromium.net.CronetEngine; + +/** + * A {@link Factory} that produces {@link DefaultDataSource} instances that delegate to + * {@link CronetDataSource}s for non-file/asset/content URIs. + */ +public final class DefaultCronetDataSourceFactory implements Factory { + + /** + * The default connection timeout, in milliseconds. + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = + CronetDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; + /** + * The default read timeout, in milliseconds. + */ + public static final int DEFAULT_READ_TIMEOUT_MILLIS = + CronetDataSource.DEFAULT_READ_TIMEOUT_MILLIS; + + private final Context context; + private final CronetEngine cronetEngine; + private final Executor executor; + private final Predicate contentTypePredicate; + private final TransferListener transferListener; + private final int connectTimeoutMs; + private final int readTimeoutMs; + private final boolean resetTimeoutOnRedirects; + + public DefaultCronetDataSourceFactory(Context context, CronetEngine cronetEngine, + Executor executor, Predicate contentTypePredicate, + TransferListener super DataSource> transferListener) { + this(context, cronetEngine, executor, contentTypePredicate, transferListener, + DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false); + } + + public DefaultCronetDataSourceFactory(Context context, CronetEngine cronetEngine, + Executor executor, Predicate contentTypePredicate, + TransferListener super DataSource> transferListener, int connectTimeoutMs, + int readTimeoutMs, boolean resetTimeoutOnRedirects) { + this.context = context; + this.cronetEngine = cronetEngine; + this.executor = executor; + this.contentTypePredicate = contentTypePredicate; + this.transferListener = transferListener; + this.connectTimeoutMs = connectTimeoutMs; + this.readTimeoutMs = readTimeoutMs; + this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; + } + + @Override + public DefaultDataSource createDataSource() { + DataSource cronetDataSource = new CronetDataSource(cronetEngine, executor, contentTypePredicate, + transferListener, connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects); + return new DefaultDataSource(context, transferListener, cronetDataSource); + } + +}
This class's methods are organized in the sequence of expected calls. + */ +public class CronetDataSource extends UrlRequest.Callback implements HttpDataSource { + + /** + * Thrown when an error is encountered when trying to open a {@link CronetDataSource}. + */ + public static final class OpenException extends HttpDataSourceException { + + /** + * Returns the status of the connection establishment at the moment when the error occurred, as + * defined by {@link UrlRequest.Status}. + */ + public final int cronetConnectionStatus; + + public OpenException(IOException cause, DataSpec dataSpec, int cronetConnectionStatus) { + super(cause, dataSpec, TYPE_OPEN); + this.cronetConnectionStatus = cronetConnectionStatus; + } + + public OpenException(String errorMessage, DataSpec dataSpec, int cronetConnectionStatus) { + super(errorMessage, dataSpec, TYPE_OPEN); + this.cronetConnectionStatus = cronetConnectionStatus; + } + + } + + /** + * The default connection timeout, in milliseconds. + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 8 * 1000; + /** + * The default read timeout, in milliseconds. + */ + public static final int DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000; + + private static final String TAG = "CronetDataSource"; + private static final Pattern CONTENT_RANGE_HEADER_PATTERN = + Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$"); + // The size of read buffer passed to cronet UrlRequest.read(). + private static final int READ_BUFFER_SIZE_BYTES = 32 * 1024; + + /* package */ static final int IDLE_CONNECTION = 5; + /* package */ static final int OPENING_CONNECTION = 2; + /* package */ static final int CONNECTED_CONNECTION = 3; + /* package */ static final int OPEN_CONNECTION = 4; + + private final CronetEngine cronetEngine; + private final Executor executor; + private final Predicate contentTypePredicate; + private final TransferListener transferListener; + private final int connectTimeoutMs; + private final int readTimeoutMs; + private final boolean resetTimeoutOnRedirects; + private final Map requestProperties; + private final ConditionVariable operation; + private final ByteBuffer readBuffer; + private final Clock clock; + + private UrlRequest currentUrlRequest; + private DataSpec currentDataSpec; + private UrlResponseInfo responseInfo; + + /* package */ volatile int connectionState; + private volatile String currentUrl; + private volatile long currentConnectTimeoutMs; + private volatile HttpDataSourceException exception; + private volatile long contentLength; + private volatile AtomicLong expectedBytesRemainingToRead; + private volatile boolean hasData; + private volatile boolean responseFinished; + + /** + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from + * {@link #open(DataSpec)}. + * @param transferListener A listener. + */ + public CronetDataSource(CronetEngine cronetEngine, Executor executor, + Predicate contentTypePredicate, TransferListener transferListener) { + this(cronetEngine, executor, contentTypePredicate, transferListener, + DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false); + } + + /** + * @param cronetEngine A CronetEngine. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then an {@link InvalidContentTypeException} is thrown from + * {@link #open(DataSpec)}. + * @param transferListener A listener. + * @param connectTimeoutMs The connection timeout, in milliseconds. + * @param readTimeoutMs The read timeout, in milliseconds. + * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs. + */ + public CronetDataSource(CronetEngine cronetEngine, Executor executor, + Predicate contentTypePredicate, TransferListener transferListener, + int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects) { + this(cronetEngine, executor, contentTypePredicate, transferListener, connectTimeoutMs, + readTimeoutMs, resetTimeoutOnRedirects, new SystemClock()); + } + + /* package */ CronetDataSource(CronetEngine cronetEngine, Executor executor, + Predicate contentTypePredicate, TransferListener transferListener, + int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects, Clock clock) { + this.cronetEngine = Assertions.checkNotNull(cronetEngine); + this.executor = Assertions.checkNotNull(executor); + this.contentTypePredicate = contentTypePredicate; + this.transferListener = transferListener; + this.connectTimeoutMs = connectTimeoutMs; + this.readTimeoutMs = readTimeoutMs; + this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; + this.clock = Assertions.checkNotNull(clock); + readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES); + requestProperties = new HashMap<>(); + operation = new ConditionVariable(); + connectionState = IDLE_CONNECTION; + } + + @Override + public void setRequestProperty(String name, String value) { + synchronized (requestProperties) { + requestProperties.put(name, value); + } + } + + @Override + public void clearRequestProperty(String name) { + synchronized (requestProperties) { + requestProperties.remove(name); + } + } + + @Override + public void clearAllRequestProperties() { + synchronized (requestProperties) { + requestProperties.clear(); + } + } + + @Override + public Map> getResponseHeaders() { + return responseInfo == null ? null : responseInfo.getAllHeaders(); + } + + @Override + public long open(DataSpec dataSpec) throws HttpDataSourceException { + TraceUtil.beginSection("CronetDataSource.open"); + try { + Assertions.checkNotNull(dataSpec); + synchronized (this) { + Assertions.checkState(connectionState == IDLE_CONNECTION, "Connection already open"); + connectionState = OPENING_CONNECTION; + } + + operation.close(); + resetConnectTimeout(); + startRequest(dataSpec); + boolean requestStarted = blockUntilConnectTimeout(); + + if (exception != null) { + // An error occurred opening the connection. + throw exception; + } else if (!requestStarted) { + // The timeout was reached before the connection was opened. + throw new OpenException(new SocketTimeoutException(), dataSpec, getCurrentRequestStatus()); + } + + // Connection was opened. + if (transferListener != null) { + transferListener.onTransferStart(this, dataSpec); + } + connectionState = OPEN_CONNECTION; + return contentLength; + } finally { + TraceUtil.endSection(); + } + } + + private void startRequest(DataSpec dataSpec) throws HttpDataSourceException { + currentUrl = dataSpec.uri.toString(); + currentDataSpec = dataSpec; + UrlRequest.Builder urlRequestBuilder = new UrlRequest.Builder(currentUrl, this, executor, + cronetEngine); + fillCurrentRequestHeader(urlRequestBuilder); + fillCurrentRequestPostBody(urlRequestBuilder, dataSpec); + currentUrlRequest = urlRequestBuilder.build(); + currentUrlRequest.start(); + } + + private void fillCurrentRequestHeader(UrlRequest.Builder urlRequestBuilder) { + synchronized (requestProperties) { + for (Entry headerEntry : requestProperties.entrySet()) { + urlRequestBuilder.addHeader(headerEntry.getKey(), headerEntry.getValue()); + } + } + if (currentDataSpec.position == 0 && currentDataSpec.length == C.LENGTH_UNSET) { + // Not required. + return; + } + StringBuilder rangeValue = new StringBuilder(); + rangeValue.append("bytes="); + rangeValue.append(currentDataSpec.position); + rangeValue.append("-"); + if (currentDataSpec.length != C.LENGTH_UNSET) { + rangeValue.append(currentDataSpec.position + currentDataSpec.length - 1); + } + urlRequestBuilder.addHeader("Range", rangeValue.toString()); + } + + private void fillCurrentRequestPostBody(UrlRequest.Builder urlRequestBuilder, DataSpec dataSpec) + throws HttpDataSourceException { + if (dataSpec.postBody != null) { + if (!requestProperties.containsKey("Content-Type")) { + throw new OpenException("POST requests must set a Content-Type header", dataSpec, + getCurrentRequestStatus()); + } + urlRequestBuilder.setUploadDataProvider( + new ByteArrayUploadDataProvider(dataSpec.postBody), executor); + } + } + + @Override + public synchronized void onFailed( + UrlRequest request, UrlResponseInfo info, UrlRequestException error) { + if (request != currentUrlRequest) { + return; + } + if (connectionState == OPENING_CONNECTION) { + IOException cause = error.getErrorCode() == UrlRequestException.ERROR_HOSTNAME_NOT_RESOLVED + ? new UnknownHostException() : error; + exception = new OpenException(cause, currentDataSpec, getCurrentRequestStatus()); + } else if (connectionState == OPEN_CONNECTION) { + readBuffer.limit(0); + exception = new HttpDataSourceException(error, currentDataSpec, + HttpDataSourceException.TYPE_READ); + } + operation.open(); + } + + @Override + public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) { + if (request != currentUrlRequest) { + return; + } + TraceUtil.beginSection("CronetDataSource.onResponseStarted"); + try { + validateResponse(info); + responseInfo = info; + // Check content length. + contentLength = getContentLength(info.getAllHeaders()); + // If a specific length is requested and a specific length is returned but the 2 don't match + // it's an error. + if (currentDataSpec.length != C.LENGTH_UNSET + && contentLength != C.LENGTH_UNSET + && currentDataSpec.length != contentLength) { + throw new OpenException("Content length did not match requested length", currentDataSpec, + getCurrentRequestStatus()); + } + + if (contentLength > 0) { + expectedBytesRemainingToRead = new AtomicLong(contentLength); + } + + // Keep track of redirects. + currentUrl = responseInfo.getUrl(); + connectionState = CONNECTED_CONNECTION; + } catch (HttpDataSourceException e) { + exception = e; + } finally { + operation.open(); + TraceUtil.endSection(); + } + } + + private void validateResponse(UrlResponseInfo info) throws HttpDataSourceException { + // Check for a valid response code. + int responseCode = info.getHttpStatusCode(); + if (responseCode < 200 || responseCode > 299) { + throw new InvalidResponseCodeException(responseCode, info.getAllHeaders(), currentDataSpec); + } + // Check for a valid content type. + try { + String contentType = info.getAllHeaders().get("Content-Type").get(0); + if (contentTypePredicate != null && !contentTypePredicate.evaluate(contentType)) { + throw new InvalidContentTypeException(contentType, currentDataSpec); + } + } catch (IndexOutOfBoundsException e) { + throw new InvalidContentTypeException(null, currentDataSpec); + } + } + + private long getContentLength(Map> headers) { + // Logic copied from {@code DefaultHttpDataSource} + long contentLength = C.LENGTH_UNSET; + List contentLengthHeader = headers.get("Content-Length"); + if (contentLengthHeader != null + && !contentLengthHeader.isEmpty() + && !TextUtils.isEmpty(contentLengthHeader.get(0))) { + try { + contentLength = Long.parseLong(contentLengthHeader.get(0)); + } catch (NumberFormatException e) { + log(Log.ERROR, "Unexpected Content-Length [" + contentLengthHeader + "]"); + } + } + List contentRangeHeader = headers.get("Content-Range"); + if (contentRangeHeader != null + && !contentRangeHeader.isEmpty() + && !TextUtils.isEmpty(contentRangeHeader.get(0))) { + Matcher matcher = CONTENT_RANGE_HEADER_PATTERN.matcher(contentRangeHeader.get(0)); + if (matcher.find()) { + try { + long contentLengthFromRange = + Long.parseLong(matcher.group(2)) - Long.parseLong(matcher.group(1)) + 1; + if (contentLength < 0) { + // Some proxy servers strip the Content-Length header. Fall back to the length + // calculated here in this case. + contentLength = contentLengthFromRange; + } else if (contentLength != contentLengthFromRange) { + // If there is a discrepancy between the Content-Length and Content-Range headers, + // assume the one with the larger value is correct. We have seen cases where carrier + // change one of them to reduce the size of a request, but it is unlikely anybody + // would increase it. + log(Log.WARN, "Inconsistent headers [" + contentLengthHeader + "] [" + + contentRangeHeader + "]"); + contentLength = Math.max(contentLength, contentLengthFromRange); + } + } catch (NumberFormatException e) { + log(Log.ERROR, "Unexpected Content-Range [" + contentRangeHeader + "]"); + } + } + } + return contentLength; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException { + TraceUtil.beginSection("CronetDataSource.read"); + try { + synchronized (this) { + if (connectionState != OPEN_CONNECTION) { + throw new IllegalStateException("Connection not ready"); + } + } + + // If being asked to read beyond the amount of bytes initially requested, return + // RESULT_END_OF_INPUT. + if (expectedBytesRemainingToRead != null && expectedBytesRemainingToRead.get() <= 0) { + return C.RESULT_END_OF_INPUT; + } + + if (!hasData) { + // Read more data from cronet. + operation.close(); + currentUrlRequest.read(readBuffer); + if (!operation.block(readTimeoutMs)) { + throw new HttpDataSourceException( + new SocketTimeoutException(), currentDataSpec, HttpDataSourceException.TYPE_READ); + } + if (exception != null) { + throw exception; + } + // The expected response length is unknown, but cronet has indicated that the request + // already finished successfully. + if (responseFinished) { + return C.RESULT_END_OF_INPUT; + } + } + + int bytesRead = Math.min(readBuffer.remaining(), readLength); + + readBuffer.get(buffer, offset, bytesRead); + + if (!readBuffer.hasRemaining()) { + readBuffer.clear(); + hasData = false; + } + + if (expectedBytesRemainingToRead != null) { + expectedBytesRemainingToRead.addAndGet(-bytesRead); + } + + if (transferListener != null && bytesRead >= 0) { + transferListener.onBytesTransferred(this, bytesRead); + } + return bytesRead; + } finally { + TraceUtil.endSection(); + } + } + + @Override + public void onRedirectReceived(UrlRequest request, UrlResponseInfo info, String newLocationUrl) { + if (request != currentUrlRequest) { + return; + } + if (currentDataSpec.postBody != null) { + int responseCode = info.getHttpStatusCode(); + // The industry standard is to disregard POST redirects when the status code is 307 or 308. + // For other redirect response codes the POST request is converted to a GET request and the + // redirect is followed. + if (responseCode == 307 || responseCode == 308) { + exception = new OpenException("POST request redirected with 307 or 308 response code", + currentDataSpec, getCurrentRequestStatus()); + operation.open(); + return; + } + } + if (resetTimeoutOnRedirects) { + resetConnectTimeout(); + } + request.followRedirect(); + } + + @Override + public synchronized void onReadCompleted(UrlRequest request, UrlResponseInfo info, + ByteBuffer buffer) { + if (request != currentUrlRequest) { + return; + } + readBuffer.flip(); + if (readBuffer.limit() > 0) { + hasData = true; + } + operation.open(); + } + + @Override + public void onSucceeded(UrlRequest request, UrlResponseInfo info) { + if (request != currentUrlRequest) { + return; + } + responseFinished = true; + operation.open(); + } + + @Override + public synchronized void close() { + TraceUtil.beginSection("CronetDataSource.close"); + try { + if (currentUrlRequest != null) { + currentUrlRequest.cancel(); + currentUrlRequest = null; + } + readBuffer.clear(); + currentDataSpec = null; + currentUrl = null; + exception = null; + contentLength = 0; + hasData = false; + responseInfo = null; + expectedBytesRemainingToRead = null; + responseFinished = false; + if (transferListener != null && connectionState == OPEN_CONNECTION) { + transferListener.onTransferEnd(this); + } + } finally { + connectionState = IDLE_CONNECTION; + TraceUtil.endSection(); + } + } + + @Override + public Uri getUri() { + return Uri.parse(currentUrl); + } + + private void log(int priority, String message) { + if (Log.isLoggable(TAG, priority)) { + Log.println(priority, TAG, message); + } + } + + private int getCurrentRequestStatus() { + if (currentUrlRequest == null) { + return UrlRequest.Status.IDLE; + } + final ConditionVariable conditionVariable = new ConditionVariable(); + final AtomicInteger result = new AtomicInteger(); + currentUrlRequest.getStatus(new UrlRequest.StatusListener() { + @Override + public void onStatus(int status) { + result.set(status); + conditionVariable.open(); + } + }); + return result.get(); + } + + private boolean blockUntilConnectTimeout() { + long now = clock.elapsedRealtime(); + boolean opened = false; + while (!opened && now < currentConnectTimeoutMs) { + opened = operation.block(currentConnectTimeoutMs - now + 5 /* fudge factor */); + now = clock.elapsedRealtime(); + } + return opened; + } + + private void resetConnectTimeout() { + currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs; + } + +} diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/DefaultCronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/DefaultCronetDataSourceFactory.java new file mode 100644 index 0000000000..9cabd3c002 --- /dev/null +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/DefaultCronetDataSourceFactory.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cronet; + +import android.content.Context; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSource.Factory; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Predicate; +import java.util.concurrent.Executor; +import org.chromium.net.CronetEngine; + +/** + * A {@link Factory} that produces {@link DefaultDataSource} instances that delegate to + * {@link CronetDataSource}s for non-file/asset/content URIs. + */ +public final class DefaultCronetDataSourceFactory implements Factory { + + /** + * The default connection timeout, in milliseconds. + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = + CronetDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; + /** + * The default read timeout, in milliseconds. + */ + public static final int DEFAULT_READ_TIMEOUT_MILLIS = + CronetDataSource.DEFAULT_READ_TIMEOUT_MILLIS; + + private final Context context; + private final CronetEngine cronetEngine; + private final Executor executor; + private final Predicate contentTypePredicate; + private final TransferListener transferListener; + private final int connectTimeoutMs; + private final int readTimeoutMs; + private final boolean resetTimeoutOnRedirects; + + public DefaultCronetDataSourceFactory(Context context, CronetEngine cronetEngine, + Executor executor, Predicate contentTypePredicate, + TransferListener super DataSource> transferListener) { + this(context, cronetEngine, executor, contentTypePredicate, transferListener, + DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false); + } + + public DefaultCronetDataSourceFactory(Context context, CronetEngine cronetEngine, + Executor executor, Predicate contentTypePredicate, + TransferListener super DataSource> transferListener, int connectTimeoutMs, + int readTimeoutMs, boolean resetTimeoutOnRedirects) { + this.context = context; + this.cronetEngine = cronetEngine; + this.executor = executor; + this.contentTypePredicate = contentTypePredicate; + this.transferListener = transferListener; + this.connectTimeoutMs = connectTimeoutMs; + this.readTimeoutMs = readTimeoutMs; + this.resetTimeoutOnRedirects = resetTimeoutOnRedirects; + } + + @Override + public DefaultDataSource createDataSource() { + DataSource cronetDataSource = new CronetDataSource(cronetEngine, executor, contentTypePredicate, + transferListener, connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects); + return new DefaultDataSource(context, transferListener, cronetDataSource); + } + +}