summaryrefslogtreecommitdiff
path: root/src/test/java
diff options
context:
space:
mode:
Diffstat (limited to 'src/test/java')
-rw-r--r--src/test/java/com/google/android/downloader/AndroidConnectivityHandlerTest.java331
-rw-r--r--src/test/java/com/google/android/downloader/CronetUrlEngineTest.java129
-rw-r--r--src/test/java/com/google/android/downloader/CronetUrlEngineTestActivity.java22
-rw-r--r--src/test/java/com/google/android/downloader/DataUrlEngineTest.java105
-rw-r--r--src/test/java/com/google/android/downloader/DataUrlTest.java74
-rw-r--r--src/test/java/com/google/android/downloader/DownloaderTest.java1053
-rw-r--r--src/test/java/com/google/android/downloader/IOUtilTest.java73
-rw-r--r--src/test/java/com/google/android/downloader/MockWebServerUrlEngineTestHelper.java338
-rw-r--r--src/test/java/com/google/android/downloader/OkHttp2UrlEngineTest.java106
-rw-r--r--src/test/java/com/google/android/downloader/OkHttp3UrlEngineTest.java106
-rw-r--r--src/test/java/com/google/android/downloader/PlatformUrlEngineTest.java133
-rw-r--r--src/test/java/com/google/android/downloader/ProtoFileDownloadDestinationTest.java186
-rw-r--r--src/test/java/com/google/android/downloader/SimpleFileDownloadDestinationTest.java186
-rw-r--r--src/test/java/com/google/android/downloader/TestExecutorRule.java114
-rw-r--r--src/test/java/com/google/android/downloader/TestingExecutorService.java86
15 files changed, 3042 insertions, 0 deletions
diff --git a/src/test/java/com/google/android/downloader/AndroidConnectivityHandlerTest.java b/src/test/java/com/google/android/downloader/AndroidConnectivityHandlerTest.java
new file mode 100644
index 0000000..247080b
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/AndroidConnectivityHandlerTest.java
@@ -0,0 +1,331 @@
+// Copyright 2021 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.downloader;
+
+import static android.net.ConnectivityManager.CONNECTIVITY_ACTION;
+import static android.os.Looper.getMainLooper;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.Manifest.permission;
+import android.app.Application;
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkInfo.DetailedState;
+import android.net.NetworkInfo.State;
+import android.os.Build.VERSION_CODES;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.downloader.DownloadConstraints.NetworkType;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.time.Duration;
+import java.util.EnumSet;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeoutException;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.ShadowConnectivityManager;
+import org.robolectric.shadows.ShadowNetwork;
+import org.robolectric.shadows.ShadowNetworkCapabilities;
+import org.robolectric.shadows.ShadowNetworkInfo;
+
+/** Unit tests for AndroidConnectivityHandler. */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = VERSION_CODES.KITKAT)
+public final class AndroidConnectivityHandlerTest {
+ private static final long DEFAULT_TIMEOUT_MILLIS = 1000;
+
+ @Rule
+ public TestExecutorRule testExecutorRule =
+ new TestExecutorRule(Duration.ofMillis(2 * DEFAULT_TIMEOUT_MILLIS));
+
+ private Application application;
+ private ConnectivityManager connectivityManager;
+ private ScheduledExecutorService scheduledExecutorService;
+
+ @Before
+ public void setUp() {
+ application = ApplicationProvider.getApplicationContext();
+ scheduledExecutorService = testExecutorRule.newSingleThreadScheduledExecutor();
+ connectivityManager =
+ (ConnectivityManager) application.getSystemService(Context.CONNECTIVITY_SERVICE);
+ }
+
+ @Test
+ public void checkConnectivity_permissionNotGranted() {
+ assertThrows(IllegalStateException.class, this::createConnectivityHandler);
+ }
+
+ @Test
+ public void checkConnectivity_noConnectivityRequired() throws Exception {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+ ListenableFuture<Void> result = connectivityHandler.checkConnectivity(DownloadConstraints.NONE);
+
+ assertThat(result.isDone()).isTrue();
+ assertThat(result.get()).isNull();
+ }
+
+ @Test
+ public void checkConnectivity_noNetwork() {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ shadowOf(connectivityManager).setActiveNetworkInfo(null);
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(DownloadConstraints.NETWORK_CONNECTED);
+
+ assertThat(result.isDone()).isFalse();
+ }
+
+ @Test
+ public void checkConnectivity_networkNotConnected() {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ shadowOf(connectivityManager)
+ .setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(
+ DetailedState.DISCONNECTED,
+ ConnectivityManager.TYPE_WIFI,
+ 0,
+ false,
+ State.DISCONNECTED));
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(DownloadConstraints.NETWORK_CONNECTED);
+
+ assertThat(result.isDone()).isFalse();
+ }
+
+ @Test
+ public void checkConnectivity_wrongNetworkType() {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ shadowOf(connectivityManager)
+ .setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(
+ DetailedState.CONNECTED,
+ ConnectivityManager.TYPE_MOBILE,
+ 0,
+ true,
+ State.CONNECTED));
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(DownloadConstraints.NETWORK_UNMETERED);
+
+ assertThat(result.isDone()).isFalse();
+ }
+
+ @Test
+ public void checkConnectivity_anyNetworkType() {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ shadowOf(connectivityManager)
+ .setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(
+ DetailedState.CONNECTED,
+ ConnectivityManager.TYPE_MOBILE,
+ 0,
+ true,
+ State.CONNECTED));
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(
+ DownloadConstraints.builder()
+ .setRequireUnmeteredNetwork(false)
+ .setRequiredNetworkTypes(ImmutableSet.of(NetworkType.ANY))
+ .build());
+ assertThat(result.isDone()).isTrue();
+ }
+
+ @Test
+ public void checkConnectivity_unknownNetworkType() {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ shadowOf(connectivityManager)
+ .setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(
+ DetailedState.CONNECTED,
+ 100, // Invalid network type
+ 0,
+ true,
+ State.CONNECTED));
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(
+ DownloadConstraints.builder()
+ .setRequiredNetworkTypes(EnumSet.of(NetworkType.WIFI))
+ .setRequireUnmeteredNetwork(false)
+ .build());
+
+ assertThat(result.isDone()).isFalse();
+ }
+
+ @Test
+ public void checkConnectivity_requiredNetworkConnected_wifiOnly() {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ shadowOf(connectivityManager)
+ .setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(
+ DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, State.CONNECTED));
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(DownloadConstraints.NETWORK_UNMETERED);
+
+ assertThat(result.isDone()).isTrue();
+ }
+
+ @Test
+ public void checkConnectivity_requiredNetworkConnected_wifiOrCellular() {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ shadowOf(connectivityManager)
+ .setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(
+ DetailedState.CONNECTED,
+ ConnectivityManager.TYPE_MOBILE,
+ 0,
+ true,
+ State.CONNECTED));
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(DownloadConstraints.NETWORK_CONNECTED);
+
+ assertThat(result.isDone()).isTrue();
+ }
+
+ @Config(sdk = VERSION_CODES.M)
+ @Test
+ public void checkConnectivity_requiredNetworkConnected_sdk23() {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ ShadowConnectivityManager shadowConnectivityManager = shadowOf(connectivityManager);
+ Network network = ShadowNetwork.newInstance(0);
+ shadowConnectivityManager.addNetwork(
+ network,
+ ShadowNetworkInfo.newInstance(
+ DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, State.CONNECTED));
+ NetworkCapabilities networkCapabilities = ShadowNetworkCapabilities.newInstance();
+ shadowOf(networkCapabilities).addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
+ shadowConnectivityManager.setNetworkCapabilities(network, networkCapabilities);
+ shadowConnectivityManager.setDefaultNetworkActive(true);
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(DownloadConstraints.NETWORK_CONNECTED);
+
+ assertThat(result.isDone()).isTrue();
+ }
+
+ @Test
+ public void checkConnectivity_networkChange() throws Exception {
+ ShadowApplication shadowApplication = shadowOf(application);
+ shadowApplication.grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ shadowOf(connectivityManager)
+ .setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(
+ DetailedState.DISCONNECTED,
+ ConnectivityManager.TYPE_WIFI,
+ 0,
+ false,
+ State.DISCONNECTED));
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(DownloadConstraints.NETWORK_CONNECTED);
+ assertThat(result.isDone()).isFalse();
+ assertThat(shadowApplication.getRegisteredReceivers()).hasSize(1);
+ assertThat(shadowApplication.getRegisteredReceivers().get(0).getBroadcastReceiver())
+ .isInstanceOf(AndroidConnectivityHandler.NetworkBroadcastReceiver.class);
+ assertThat(
+ shadowApplication
+ .getRegisteredReceivers()
+ .get(0)
+ .getIntentFilter()
+ .hasAction(CONNECTIVITY_ACTION))
+ .isTrue();
+
+ // Change state to be available.
+ shadowOf(connectivityManager)
+ .setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(
+ DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, State.CONNECTED));
+
+ application.sendBroadcast(new Intent(CONNECTIVITY_ACTION));
+
+ shadowOf(getMainLooper()).idle();
+
+ assertThat(result.isDone()).isTrue();
+ assertThat(result.get()).isNull();
+ assertThat(shadowApplication.getRegisteredReceivers()).isEmpty();
+ }
+
+ @Test
+ public void checkConnectivity_timeout() {
+ shadowOf(application).grantPermissions(permission.ACCESS_NETWORK_STATE);
+
+ AndroidConnectivityHandler connectivityHandler = createConnectivityHandler();
+
+ shadowOf(connectivityManager)
+ .setActiveNetworkInfo(
+ ShadowNetworkInfo.newInstance(
+ DetailedState.DISCONNECTED,
+ ConnectivityManager.TYPE_WIFI,
+ 0,
+ false,
+ State.DISCONNECTED));
+
+ ListenableFuture<Void> result =
+ connectivityHandler.checkConnectivity(DownloadConstraints.NETWORK_CONNECTED);
+ assertThat(result.isDone()).isFalse();
+
+ ExecutionException exception = assertThrows(ExecutionException.class, result::get);
+ assertThat(exception).hasCauseThat().isInstanceOf(TimeoutException.class);
+ }
+
+ private AndroidConnectivityHandler createConnectivityHandler() {
+ return new AndroidConnectivityHandler(
+ application, scheduledExecutorService, DEFAULT_TIMEOUT_MILLIS);
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/CronetUrlEngineTest.java b/src/test/java/com/google/android/downloader/CronetUrlEngineTest.java
new file mode 100644
index 0000000..1a2a257
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/CronetUrlEngineTest.java
@@ -0,0 +1,129 @@
+// Copyright 2021 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.downloader;
+
+import static androidx.test.InstrumentationRegistry.getInstrumentation;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.base.Charsets;
+import com.google.common.io.Files;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.File;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.chromium.net.CronetEngine;
+import org.chromium.net.NetworkException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+/** Unit tests for CronetUrlEngine. */
+@RunWith(AndroidJUnit4.class)
+public class CronetUrlEngineTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private MockWebServerUrlEngineTestHelper testHelper;
+ private CronetUrlEngine engine;
+
+ @Before
+ public void setUp() {
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ TestingExecutorService testingExecutorService = new TestingExecutorService(executorService);
+ testHelper = new MockWebServerUrlEngineTestHelper(temporaryFolder, testingExecutorService);
+ CronetEngine cronetEngine =
+ new CronetEngine.Builder(getInstrumentation().getTargetContext()).build();
+ engine = new CronetUrlEngine(cronetEngine, executorService);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ testHelper.tearDown();
+ }
+
+ @Test
+ public void executeRequest_normalResponse_succeeds() throws Exception {
+ testHelper.executeRequest_normalResponse_succeeds(engine);
+ }
+
+ @Test
+ public void executeRequest_responseThrottled_succeeds() throws Exception {
+ testHelper.executeRequest_responseThrottled_succeeds(engine);
+ }
+
+ @Test
+ public void executeRequest_largeResponse_succeeds() throws Exception {
+ testHelper.executeRequest_largeResponse_succeeds(engine, CronetUrlEngine.BUFFER_SIZE_BYTES * 3);
+ }
+
+ @Test
+ public void executeRequest_closeBeforeWrite_failsAborted() throws Exception {
+ testHelper.executeRequest_closeBeforeWrite_failsAborted(engine);
+ }
+
+ @Test
+ public void executeRequest_serverError_failsInternalError() throws Exception {
+ testHelper.executeRequest_serverError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_networkError_failsInternalError() throws Exception {
+ testHelper.executeRequest_networkError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_writeError_failsInternalError() throws Exception {
+ testHelper.executeRequest_writeError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_requestCanceled_requestNeverSent() throws Exception {
+ testHelper.executeRequest_requestCanceled_requestNeverSent(engine);
+ }
+
+ @Test
+ public void executeRequest_invalidUrl_failsInvalidArgument() throws Exception {
+ testHelper.executeRequest_invalidUrl_failsInvalidArgument(engine);
+ }
+
+ @Test
+ public void executeRequest_fileUrl_requestFails() throws Exception {
+ // Note: File url testing doesn't exist in MockWebServerUrlEngineTestHelper because
+ // it doesn't involve the MockWebServer and doesn't apply to all UrlEngine implementations.
+ String message = "foobar";
+ File sourceFile = temporaryFolder.newFile();
+ Files.asCharSink(sourceFile, Charsets.UTF_8).write(message);
+
+ String url = sourceFile.toURI().toURL().toString();
+ UrlRequest request = engine.createRequest(url).build();
+ ListenableFuture<? extends UrlResponse> responseFuture = request.send();
+
+ // Cronet doesn't support file URLs. Verify that the request fails with an underlying
+ // error that communicates this problem.
+ ExecutionException exception = assertThrows(ExecutionException.class, responseFuture::get);
+ assertThat(exception).hasCauseThat().isInstanceOf(RequestException.class);
+ assertThat(exception).hasCauseThat().hasCauseThat().isInstanceOf(NetworkException.class);
+ assertThat(exception)
+ .hasCauseThat()
+ .hasCauseThat()
+ .hasMessageThat()
+ .contains("UNKNOWN_URL_SCHEME");
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/CronetUrlEngineTestActivity.java b/src/test/java/com/google/android/downloader/CronetUrlEngineTestActivity.java
new file mode 100644
index 0000000..15decd5
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/CronetUrlEngineTestActivity.java
@@ -0,0 +1,22 @@
+// Copyright 2021 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.downloader;
+
+import android.app.Activity;
+
+/**
+ * Dummy activity for the Cronet instrumentation test setup to ensure we don't have an empty APK.
+ */
+public class CronetUrlEngineTestActivity extends Activity {}
diff --git a/src/test/java/com/google/android/downloader/DataUrlEngineTest.java b/src/test/java/com/google/android/downloader/DataUrlEngineTest.java
new file mode 100644
index 0000000..37a1ab0
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/DataUrlEngineTest.java
@@ -0,0 +1,105 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.Files;
+import com.google.common.net.HttpHeaders;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.testing.mockito.Mocks;
+import java.io.File;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.WritableByteChannel;
+import java.nio.file.StandardOpenOption;
+import java.util.concurrent.ExecutionException;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+
+/** Unit tests for DataUrlEngine. */
+@RunWith(JUnit4.class)
+public class DataUrlEngineTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+ @Rule public Mocks mocks = new Mocks(this);
+
+ @Mock WritableByteChannel mockByteChannel;
+
+ private DataUrlEngine engine;
+
+ @Before
+ public void setUp() {
+ engine = new DataUrlEngine(MoreExecutors.newDirectExecutorService());
+ }
+
+ @Test
+ public void executeRequest() throws Exception {
+ File file = temporaryFolder.newFile();
+ FileChannel channel = FileChannel.open(file.toPath(), StandardOpenOption.WRITE);
+
+ UrlRequest request = engine.createRequest("data:text/plain;base64,Zm9vYmFy").build();
+ UrlResponse response = request.send().get();
+ long bytesWritten = response.readResponseBody(channel).get();
+ response.close();
+ channel.close();
+
+ assertThat(bytesWritten).isGreaterThan(0L);
+ assertThat(Files.asCharSource(file, UTF_8).read()).isEqualTo("foobar");
+ assertThat(response.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_OK);
+ assertThat(response.getResponseHeaders())
+ .containsExactly(HttpHeaders.CONTENT_TYPE, ImmutableList.of("text/plain"));
+ }
+
+ @Test
+ public void executeRequest_invalidDataUrl() throws Exception {
+ UrlRequest request = engine.createRequest("data:text/plain;base64,foobar*").build();
+ ListenableFuture<? extends UrlResponse> responseFuture = request.send();
+
+ ExecutionException exception = assertThrows(ExecutionException.class, responseFuture::get);
+ assertThat(exception).hasCauseThat().isInstanceOf(RequestException.class);
+ }
+
+ @Test
+ public void executeRequest_writeError() throws Exception {
+ when(mockByteChannel.isOpen()).thenReturn(true);
+ when(mockByteChannel.write(any(ByteBuffer.class))).thenThrow(new IOException());
+
+ UrlRequest request = engine.createRequest("data:text/plain;base64,Zm9vYmFy").build();
+ UrlResponse response = request.send().get();
+ ListenableFuture<Long> writeFuture = response.readResponseBody(mockByteChannel);
+
+ ExecutionException exception = assertThrows(ExecutionException.class, writeFuture::get);
+ assertThat(exception).hasCauseThat().isInstanceOf(RequestException.class);
+
+ response.close();
+
+ assertThat(response.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_OK);
+ assertThat(response.getResponseHeaders())
+ .containsExactly(HttpHeaders.CONTENT_TYPE, ImmutableList.of("text/plain"));
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/DataUrlTest.java b/src/test/java/com/google/android/downloader/DataUrlTest.java
new file mode 100644
index 0000000..e0673a8
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/DataUrlTest.java
@@ -0,0 +1,74 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import com.google.android.downloader.DataUrl.DataUrlException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for DataUrl parsing. */
+@RunWith(JUnit4.class)
+public class DataUrlTest {
+ @Test
+ public void invalidScheme() {
+ assertThrows(DataUrlException.class, () -> DataUrl.parseFromString("http://example.com"));
+ }
+
+ @Test
+ public void invalidSyntax() {
+ assertThrows(DataUrlException.class, () -> DataUrl.parseFromString("data:foobar"));
+ }
+
+ @Test
+ public void missingEncoding() {
+ assertThrows(DataUrlException.class, () -> DataUrl.parseFromString("data:text/plain,foobar"));
+ }
+
+ @Test
+ public void invalidEncoding() {
+ assertThrows(
+ DataUrlException.class, () -> DataUrl.parseFromString("data:text/plain;base32,foobar"));
+ }
+
+ @Test
+ public void invalidBase64Data() {
+ // Note that '*' is not a valid character in base64.
+ assertThrows(
+ DataUrlException.class, () -> DataUrl.parseFromString("data:text/plain;base64,foobar*"));
+ }
+
+ @Test
+ public void validData() {
+ // Note: 'Zm9vYmFy' is the base64-encoding of 'foobar'.
+ DataUrl dataUrl = DataUrl.parseFromString("data:text/plain;base64,Zm9vYmFy");
+ assertThat(dataUrl.data()).isEqualTo("foobar".getBytes(UTF_8));
+ assertThat(dataUrl.mimeType()).isEqualTo("text/plain");
+ }
+
+ @Test
+ public void validData_notUrl() {
+ // Note: 'Zm9vLiw/YmFy' is the base64-encoding of 'foo.,?bar'. Because there's a slash in the
+ // base64 payload, this isn't a url-safe base64 encoding, so this test will verify we fall
+ // back to regular base64 encoding instead of just url-safe encoding.
+ DataUrl dataUrl = DataUrl.parseFromString("data:text/plain;base64,Zm9vLiw/YmFy");
+ assertThat(dataUrl.data()).isEqualTo("foo.,?bar".getBytes(UTF_8));
+ assertThat(dataUrl.mimeType()).isEqualTo("text/plain");
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/DownloaderTest.java b/src/test/java/com/google/android/downloader/DownloaderTest.java
new file mode 100644
index 0000000..1bbcaea
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/DownloaderTest.java
@@ -0,0 +1,1053 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.truth.StreamSubject.streams;
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.AdditionalMatchers.or;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.google.android.downloader.Downloader.State;
+import com.google.common.base.Utf8;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.io.Files;
+import com.google.common.net.HttpHeaders;
+import com.google.common.util.concurrent.ClosingFuture;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
+import com.google.testing.mockito.Mocks;
+import java.io.File;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Supplier;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.QueueDispatcher;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+
+/** Unit tests for Downloader. */
+@RunWith(JUnit4.class)
+public final class DownloaderTest {
+ private static final int CONNECT_TIMEOUT_MS = 500;
+ private static final int READ_TIMEOUT_MS = 300;
+ private static final int MAX_CONCURRENT_DOWNLOADS = 1;
+
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+ @Rule public Mocks mocks = new Mocks(this);
+ @Rule public TestExecutorRule testExecutorRule = new TestExecutorRule(Duration.ofSeconds(2));
+
+ @Mock Downloader.StateChangeCallback mockCallback;
+
+ private ListeningExecutorService urlEngineExecutor;
+ private ExecutorService ioExecutor;
+ private MockWebServer mockWebServer;
+ private DispatcherImpl dispatcher;
+
+ @Before
+ public void setUp() {
+ mockWebServer = new MockWebServer();
+ dispatcher = new DispatcherImpl();
+ mockWebServer.setDispatcher(dispatcher);
+ urlEngineExecutor =
+ MoreExecutors.listeningDecorator(testExecutorRule.newSingleThreadExecutor());
+ ioExecutor = testExecutorRule.newSingleThreadExecutor();
+ }
+
+ private Downloader.Builder buildDownloader() {
+ return new Downloader.Builder()
+ .addUrlEngine(
+ ImmutableSet.of("http", "https", "file"),
+ new PlatformUrlEngine(urlEngineExecutor, CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS))
+ .withConnectivityHandler(new AlwaysConnected())
+ .withIOExecutor(ioExecutor)
+ .withLogger(new FloggerDownloaderLogger())
+ .withMaxConcurrentDownloads(MAX_CONCURRENT_DOWNLOADS);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ mockWebServer.shutdown();
+ }
+
+ @Test
+ public void downloadOneFile() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+
+ String contentTag = "content_tag_abc";
+ long lastModifiedTimeSeconds = 123456789;
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBody("hello world")
+ .setHeader(HttpHeaders.ETAG, contentTag)
+ .setHeader(
+ HttpHeaders.LAST_MODIFIED,
+ RFC_1123_DATE_TIME
+ .withLocale(Locale.US)
+ .withZone(ZoneId.of("UTC"))
+ .format(Instant.ofEpochSecond(lastModifiedTimeSeconds))));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(11); // == "hello world".length
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo("hello world");
+
+ assertThat(destination.numExistingBytes()).isEqualTo(11);
+ DownloadMetadata metadata = destination.readMetadata();
+ assertThat(metadata).isEqualTo(DownloadMetadata.create(contentTag, lastModifiedTimeSeconds));
+ }
+
+ @Test
+ public void downloadOneFile_existingContent_partialContent() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+ Files.asCharSink(destinationFile, UTF_8).write("hello world");
+
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_PARTIAL)
+ .setBody("goodbye world")
+ // 24 == ("hello world" + "goodbye world").length
+ .setHeader(HttpHeaders.CONTENT_RANGE, "bytes 11-24/24"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(13); // == "goodbye world".length
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read())
+ .isEqualTo("hello worldgoodbye world");
+ assertThat(mockWebServer.takeRequest().getHeader(HttpHeaders.RANGE))
+ .isEqualTo("bytes=11-"); // == "hello world".length
+ }
+
+ @Test
+ public void downloadOneFile_existingContent_noMetadata() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+ Files.asCharSink(destinationFile, UTF_8).write("hello world");
+
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody("goodbye world"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ downloader.execute(request).get();
+
+ RecordedRequest recordedRequest = mockWebServer.takeRequest();
+ assertThat(recordedRequest.getHeader(HttpHeaders.RANGE))
+ .isEqualTo("bytes=11-"); // == "hello world".length
+ assertThat(recordedRequest.getHeader(HttpHeaders.IF_RANGE)).isNull();
+ }
+
+ @Test
+ public void downloadOneFile_existingContent_withEtag() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+ String contentTag = "content_tag_abc";
+ destination.openByteChannel(0L, DownloadMetadata.create(contentTag, 0L)).close();
+ Files.asCharSink(destinationFile, UTF_8).write("hello world");
+
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody("goodbye world"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ downloader.execute(request).get();
+
+ RecordedRequest recordedRequest = mockWebServer.takeRequest();
+ assertThat(recordedRequest.getHeader(HttpHeaders.RANGE))
+ .isEqualTo("bytes=11-"); // == "hello world".length
+ assertThat(recordedRequest.getHeader(HttpHeaders.IF_RANGE)).isEqualTo(contentTag);
+ }
+
+ @Test
+ public void downloadOneFile_existingContent_withLastModified() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+ long lastModifiedTimestampSeconds = 123456789;
+ destination
+ .openByteChannel(0L, DownloadMetadata.create("", lastModifiedTimestampSeconds))
+ .close();
+ Files.asCharSink(destinationFile, UTF_8).write("hello world");
+
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody("goodbye world"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ downloader.execute(request).get();
+
+ RecordedRequest recordedRequest = mockWebServer.takeRequest();
+ assertThat(recordedRequest.getHeader(HttpHeaders.RANGE))
+ .isEqualTo("bytes=11-"); // == "hello world".length
+ assertThat(recordedRequest.getHeader(HttpHeaders.IF_RANGE))
+ .isEqualTo(
+ RFC_1123_DATE_TIME
+ .withLocale(Locale.US)
+ .withZone(ZoneId.of("UTC"))
+ .format(Instant.ofEpochSecond(lastModifiedTimestampSeconds)));
+ }
+
+ @Test
+ public void downloadOneFile_existingContent_partialContent_overwritesExistingContent()
+ throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+ Files.asCharSink(destinationFile, UTF_8).write("hello world");
+
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_PARTIAL)
+ .setBody("goodbye world")
+ // 6 == ("hello world" - "world").length
+ // 19 == ("hello world" - "world" + "goodbye world").length
+ .setHeader(HttpHeaders.CONTENT_RANGE, "bytes 6-19/19"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(13); // == "goodbye world".length
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo("hello goodbye world");
+ assertThat(mockWebServer.takeRequest().getHeader(HttpHeaders.RANGE))
+ .isEqualTo("bytes=11-"); // == "hello world".length
+ }
+
+ @Test
+ public void downloadOneFile_existingContent_noServerSupport() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+ Files.asCharSink(destinationFile, UTF_8).write("hello world");
+
+ // Let the server ignore our Range header and reply with the entire resource.
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBody("hello worldgoodbye world"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ // 24 == ("hello world" + "goodbye world").length
+ assertThat(result.bytesWritten()).isEqualTo(24);
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read())
+ .isEqualTo("hello worldgoodbye world");
+ assertThat(mockWebServer.takeRequest().getHeader(HttpHeaders.RANGE))
+ .isEqualTo("bytes=11-"); // == "hello world".length
+ }
+
+ @Test
+ public void downloadOneFile_rangeNotSatisfiable() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+
+ dispatcher.responseForPath(
+ "/foo", () -> new MockResponse().setResponseCode(Downloader.HTTP_RANGE_NOT_SATISFIABLE));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(0);
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEmpty();
+ }
+
+ @Test
+ public void downloadOneFile_oAuthTokenProvider() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+
+ dispatcher.responseForPath(
+ "/foo",
+ () -> new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody("hello world"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader
+ .newRequestBuilder(mockWebServer.url("/foo").uri(), destination)
+ .setOAuthTokenProvider(uri -> Futures.immediateFuture("test_token"))
+ .build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(Utf8.encodedLength("hello world"));
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo("hello world");
+ assertThat(mockWebServer.takeRequest().getHeader(HttpHeaders.AUTHORIZATION))
+ .isEqualTo("Bearer test_token");
+ }
+
+ @Test
+ public void downloadOneFile_oAuthTokenProvider_nullToken() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+
+ dispatcher.responseForPath(
+ "/foo",
+ () -> new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody("hello world"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader
+ .newRequestBuilder(mockWebServer.url("/foo").uri(), destination)
+ .setOAuthTokenProvider(uri -> Futures.immediateFuture(null))
+ .build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(Utf8.encodedLength("hello world"));
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo("hello world");
+ assertThat(mockWebServer.takeRequest().getHeaders().names())
+ .doesNotContain(HttpHeaders.AUTHORIZATION);
+ }
+
+ @Test
+ public void downloadOneFile_notFound() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ downloader.registerStateChangeCallback(mockCallback, MoreExecutors.directExecutor());
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(1, 0, 0));
+
+ Exception exception = assertThrows(Exception.class, resultFuture::get);
+ RequestException requestException = Downloader.getRequestException(exception);
+ assertThat(requestException).isNotNull();
+
+ // Flush through the I/O executor to make sure internal state has settled.
+ ioExecutor.submit(() -> {}).get();
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 0));
+ }
+
+ @Test
+ public void downloadOneFile_customHeader() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+
+ dispatcher.responseForPath(
+ "/foo",
+ () -> new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody("hello world"));
+ mockWebServer.start();
+
+ String headerKey = "fooHeader";
+ String headerValue = "fooHeaderValue";
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader
+ .newRequestBuilder(mockWebServer.url("/foo").uri(), destination)
+ .addHeader(headerKey, headerValue)
+ .build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(Utf8.encodedLength("hello world"));
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo("hello world");
+ assertThat(mockWebServer.takeRequest().getHeader(headerKey)).isEqualTo(headerValue);
+ }
+
+ @Test
+ public void downloadOneFile_fileSystem() throws Exception {
+ File sourceFile = temporaryFolder.newFile();
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+
+ Files.asCharSink(sourceFile, UTF_8).write("hello world");
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request = downloader.newRequestBuilder(sourceFile.toURI(), destination).build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(Utf8.encodedLength("hello world"));
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo("hello world");
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(0);
+ }
+
+ @Test
+ public void downloadOneFile_fileSystem_existingContent() throws Exception {
+ File sourceFile = temporaryFolder.newFile();
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+
+ Files.asCharSink(sourceFile, UTF_8).write("hello world");
+ Files.asCharSink(destinationFile, UTF_8).write("hello");
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request = downloader.newRequestBuilder(sourceFile.toURI(), destination).build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(Utf8.encodedLength("hello world"));
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo("hello world");
+ }
+
+ @Test
+ public void downloadOneFile_retryAfterFailure() throws Exception {
+ mockWebServer.setDispatcher(new QueueDispatcher());
+
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+
+ mockWebServer.enqueue(
+ new MockResponse().setResponseCode(HttpURLConnection.HTTP_GATEWAY_TIMEOUT));
+ mockWebServer.enqueue(
+ new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody("hello world"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(Utf8.encodedLength("hello world"));
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo("hello world");
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(2);
+ }
+
+ @Test
+ public void downloadOneFile_waitForConnectivity() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+ String text = "hello world";
+
+ dispatcher.responseForPath(
+ "/foo", () -> new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody(text));
+ mockWebServer.start();
+
+ ManualConnectivity connectivityHandler = new ManualConnectivity();
+
+ Downloader downloader = buildDownloader().withConnectivityHandler(connectivityHandler).build();
+ downloader.registerStateChangeCallback(mockCallback, MoreExecutors.directExecutor());
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+ assertThrows(TimeoutException.class, () -> resultFuture.get(1, SECONDS));
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(0);
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 1));
+
+ connectivityHandler.setConnectivitySatisfied();
+ connectivityHandler.succeed();
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(Utf8.encodedLength(text));
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo(text);
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+
+ // Flush through the I/O executor to make sure internal state has settled.
+ ioExecutor.submit(() -> {}).get();
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 0));
+ }
+
+ @Test
+ public void downloadOneFile_waitForConnectivity_canceled() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+ String text = "hello world";
+
+ dispatcher.responseForPath(
+ "/foo", () -> new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody(text));
+ mockWebServer.start();
+
+ ManualConnectivity connectivityHandler = new ManualConnectivity();
+
+ Downloader downloader = buildDownloader().withConnectivityHandler(connectivityHandler).build();
+ downloader.registerStateChangeCallback(mockCallback, MoreExecutors.directExecutor());
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(0);
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 1));
+
+ resultFuture.cancel(true);
+
+ connectivityHandler.setConnectivitySatisfied();
+ connectivityHandler.succeed();
+
+ assertThrows(CancellationException.class, resultFuture::get);
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(0);
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 0));
+ }
+
+ @Test
+ public void downloadOneFile_timeoutConnectivity() throws Exception {
+ File destinationFile = temporaryFolder.newFile();
+ File metadataFile = new File(destinationFile.getParent(), destinationFile.getName() + ".meta");
+ SimpleFileDownloadDestination destination =
+ new SimpleFileDownloadDestination(destinationFile, metadataFile);
+ String text = "hello world";
+
+ dispatcher.responseForPath(
+ "/foo", () -> new MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody(text));
+ mockWebServer.start();
+
+ ManualConnectivity connectivityHandler = new ManualConnectivity();
+
+ Downloader downloader = buildDownloader().withConnectivityHandler(connectivityHandler).build();
+ downloader.registerStateChangeCallback(mockCallback, MoreExecutors.directExecutor());
+ DownloadRequest request =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination).build();
+
+ FluentFuture<DownloadResult> resultFuture = downloader.execute(request);
+ assertThat(resultFuture.isDone()).isFalse();
+ assertThrows(TimeoutException.class, () -> resultFuture.get(1, SECONDS));
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(0);
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 1));
+
+ connectivityHandler.setConnectivitySatisfied();
+ connectivityHandler.fail(new TimeoutException());
+
+ DownloadResult result = resultFuture.get();
+ assertThat(result.bytesWritten()).isEqualTo(Utf8.encodedLength(text));
+ assertThat(Files.asCharSource(destinationFile, UTF_8).read()).isEqualTo(text);
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+
+ // Flush through the I/O executor to make sure internal state has settled.
+ ioExecutor.submit(() -> {}).get();
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 0));
+ }
+
+ @Test
+ public void downloadTwoFiles_sequentially() throws Exception {
+ File destinationFile1 = temporaryFolder.newFile();
+ File destinationFile2 = temporaryFolder.newFile();
+ File metadataFile1 =
+ new File(destinationFile1.getParent(), destinationFile1.getName() + ".meta");
+ File metadataFile2 =
+ new File(destinationFile2.getParent(), destinationFile2.getName() + ".meta");
+ SimpleFileDownloadDestination destination1 =
+ new SimpleFileDownloadDestination(destinationFile1, metadataFile1);
+ SimpleFileDownloadDestination destination2 =
+ new SimpleFileDownloadDestination(destinationFile2, metadataFile2);
+
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world one"));
+ dispatcher.responseForPath(
+ "/bar",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world two"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ downloader.registerStateChangeCallback(mockCallback, MoreExecutors.directExecutor());
+ DownloadRequest request1 =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination1).build();
+ DownloadRequest request2 =
+ downloader.newRequestBuilder(mockWebServer.url("/bar").uri(), destination2).build();
+ FluentFuture<DownloadResult> resultFuture1 = downloader.execute(request1);
+ FluentFuture<DownloadResult> resultFuture2 = downloader.execute(request2);
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(1, 1, 0));
+
+ DownloadResult result1 = resultFuture1.get();
+ DownloadResult result2 = resultFuture2.get();
+ assertThat(result1.bytesWritten()).isEqualTo(15); // == "hello world one".length
+ assertThat(result2.bytesWritten()).isEqualTo(15); // == "hello world two".length
+ assertThat(Files.asCharSource(destinationFile1, UTF_8).read()).isEqualTo("hello world one");
+ assertThat(Files.asCharSource(destinationFile2, UTF_8).read()).isEqualTo("hello world two");
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(2);
+
+ // Flush through the I/O executor to make sure internal state has settled.
+ ioExecutor.submit(() -> {}).get();
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 0));
+ }
+
+ @Test
+ public void downloadTwoFiles_sequentially_unregisterCallback() throws Exception {
+ File destinationFile1 = temporaryFolder.newFile();
+ File destinationFile2 = temporaryFolder.newFile();
+ File metadataFile1 =
+ new File(destinationFile1.getParent(), destinationFile1.getName() + ".meta");
+ File metadataFile2 =
+ new File(destinationFile2.getParent(), destinationFile2.getName() + ".meta");
+ SimpleFileDownloadDestination destination1 =
+ new SimpleFileDownloadDestination(destinationFile1, metadataFile1);
+ SimpleFileDownloadDestination destination2 =
+ new SimpleFileDownloadDestination(destinationFile2, metadataFile2);
+
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world one"));
+ dispatcher.responseForPath(
+ "/bar",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world two"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ downloader.registerStateChangeCallback(mockCallback, MoreExecutors.directExecutor());
+
+ DownloadRequest request1 =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination1).build();
+ FluentFuture<DownloadResult> resultFuture1 = downloader.execute(request1);
+
+ downloader.unregisterStateChangeCallback(mockCallback);
+
+ DownloadRequest request2 =
+ downloader.newRequestBuilder(mockWebServer.url("/bar").uri(), destination2).build();
+ FluentFuture<DownloadResult> resultFuture2 = downloader.execute(request2);
+
+ DownloadResult result1 = resultFuture1.get();
+ DownloadResult result2 = resultFuture2.get();
+ assertThat(result1.bytesWritten()).isEqualTo(15); // == "hello world one".length
+ assertThat(result2.bytesWritten()).isEqualTo(15); // == "hello world two".length
+ assertThat(Files.asCharSource(destinationFile1, UTF_8).read()).isEqualTo("hello world one");
+ assertThat(Files.asCharSource(destinationFile2, UTF_8).read()).isEqualTo("hello world two");
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(2);
+
+ // Only run two times, for the first request:
+ // - Once for enqueueing the request
+ // - Once for starting the request
+ // but no more progress registered otherwise.
+ verify(mockCallback, times(2)).onStateChange(any());
+ }
+
+ @Test
+ public void downloadTwoFiles_concurrently() throws Exception {
+ File destinationFile1 = temporaryFolder.newFile();
+ File destinationFile2 = temporaryFolder.newFile();
+ File metadataFile1 =
+ new File(destinationFile1.getParent(), destinationFile1.getName() + ".meta");
+ File metadataFile2 =
+ new File(destinationFile2.getParent(), destinationFile2.getName() + ".meta");
+ SimpleFileDownloadDestination destination1 =
+ new SimpleFileDownloadDestination(destinationFile1, metadataFile1);
+ SimpleFileDownloadDestination destination2 =
+ new SimpleFileDownloadDestination(destinationFile2, metadataFile2);
+
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world one"));
+ dispatcher.responseForPath(
+ "/bar",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world two"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().withMaxConcurrentDownloads(2).build();
+ downloader.registerStateChangeCallback(mockCallback, MoreExecutors.directExecutor());
+ DownloadRequest request1 =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination1).build();
+ DownloadRequest request2 =
+ downloader.newRequestBuilder(mockWebServer.url("/bar").uri(), destination2).build();
+ FluentFuture<DownloadResult> resultFuture1 = downloader.execute(request1);
+ FluentFuture<DownloadResult> resultFuture2 = downloader.execute(request2);
+ assertThat(resultFuture1.isDone()).isFalse();
+ assertThat(resultFuture2.isDone()).isFalse();
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(2, 0, 0));
+
+ DownloadResult result1 = resultFuture1.get();
+ DownloadResult result2 = resultFuture2.get();
+ assertThat(result1.bytesWritten()).isEqualTo(15); // == "hello world one".length
+ assertThat(result2.bytesWritten()).isEqualTo(15); // == "hello world two".length
+ assertThat(Files.asCharSource(destinationFile1, UTF_8).read()).isEqualTo("hello world one");
+ assertThat(Files.asCharSource(destinationFile2, UTF_8).read()).isEqualTo("hello world two");
+ assertThat(mockWebServer.getRequestCount()).isEqualTo(2);
+
+ // Flush through the I/O executor to make sure internal state has settled.
+ ioExecutor.submit(() -> {}).get();
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 0));
+ }
+
+ @Test
+ public void downloadThreeFiles_sequentiallyWithCancellation() throws Exception {
+ File destinationFile1 = temporaryFolder.newFile();
+ File destinationFile2 = temporaryFolder.newFile();
+ File destinationFile3 = temporaryFolder.newFile();
+ File metadataFile1 =
+ new File(destinationFile1.getParent(), destinationFile1.getName() + ".meta");
+ File metadataFile2 =
+ new File(destinationFile2.getParent(), destinationFile2.getName() + ".meta");
+ File metadataFile3 =
+ new File(destinationFile3.getParent(), destinationFile3.getName() + ".meta");
+ SimpleFileDownloadDestination destination1 =
+ new SimpleFileDownloadDestination(destinationFile1, metadataFile1);
+ SimpleFileDownloadDestination destination2 =
+ new SimpleFileDownloadDestination(destinationFile2, metadataFile2);
+ SimpleFileDownloadDestination destination3 =
+ new SimpleFileDownloadDestination(destinationFile3, metadataFile3);
+
+ dispatcher.responseForPath(
+ "/foo",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world one"));
+ dispatcher.responseForPath(
+ "/bar",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world two"));
+ dispatcher.responseForPath(
+ "/baz",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world three"));
+ mockWebServer.start();
+
+ Downloader downloader = buildDownloader().build();
+ downloader.registerStateChangeCallback(mockCallback, MoreExecutors.directExecutor());
+ DownloadRequest request1 =
+ downloader.newRequestBuilder(mockWebServer.url("/foo").uri(), destination1).build();
+ DownloadRequest request2 =
+ downloader.newRequestBuilder(mockWebServer.url("/bar").uri(), destination2).build();
+ DownloadRequest request3 =
+ downloader.newRequestBuilder(mockWebServer.url("/baz").uri(), destination3).build();
+ FluentFuture<DownloadResult> resultFuture1 = downloader.execute(request1);
+ FluentFuture<DownloadResult> resultFuture2 = downloader.execute(request2);
+ FluentFuture<DownloadResult> resultFuture3 = downloader.execute(request3);
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(1, 2, 0));
+
+ // Cancel the first request.
+ assertThat(resultFuture1.cancel(true)).isTrue();
+ assertThrows(CancellationException.class, resultFuture1::get);
+
+ // Flush through the I/O executor to make sure internal state has settled.
+ ioExecutor.submit(() -> {}).get();
+
+ // Cancellation is racy. After it happens, there is either one request in flight and one still
+ // queued, or both still queued. The third request did not ignore the limit, see b/148559122.
+ verify(mockCallback, atLeastOnce())
+ .onStateChange(or(eq(State.create(1, 1, 0)), eq(State.create(0, 2, 0))));
+
+ DownloadResult result2 = resultFuture2.get(10, SECONDS);
+ DownloadResult result3 = resultFuture3.get(10, SECONDS);
+ assertThat(result2.bytesWritten()).isEqualTo(15L); // == "hello world two".length
+ assertThat(result3.bytesWritten()).isEqualTo(17L); // == "hello world three".length
+ assertThat(Files.asCharSource(destinationFile2, UTF_8).read()).isEqualTo("hello world two");
+ assertThat(Files.asCharSource(destinationFile3, UTF_8).read()).isEqualTo("hello world three");
+ assertThat(mockWebServer.getRequestCount()).isAnyOf(2, 3);
+
+ // Flush through the I/O executor to make sure internal state has settled.
+ ioExecutor.submit(() -> {}).get();
+
+ verify(mockCallback, atLeastOnce()).onStateChange(State.create(0, 0, 0));
+ }
+
+ @Test
+ public void closingFutureDoesntLeak() throws Exception {
+ TestLogHandler logHandler = new TestLogHandler();
+ Logger.getLogger(ClosingFuture.class.getName()).addHandler(logHandler);
+
+ File destinationFile1 = temporaryFolder.newFile();
+ File destinationFile2 = temporaryFolder.newFile();
+ File metadataFile1 =
+ new File(destinationFile1.getParent(), destinationFile1.getName() + ".meta");
+ File metadataFile2 =
+ new File(destinationFile2.getParent(), destinationFile2.getName() + ".meta");
+ SimpleFileDownloadDestination destination1 =
+ new SimpleFileDownloadDestination(destinationFile1, metadataFile1);
+ SimpleFileDownloadDestination destination2 =
+ new SimpleFileDownloadDestination(destinationFile2, metadataFile2);
+
+ dispatcher.responseForPath(
+ "/foo1",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world one"));
+ dispatcher.responseForPath(
+ "/foo2",
+ () ->
+ new MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBodyDelay(50, TimeUnit.MILLISECONDS)
+ .setBody("hello world two"));
+ mockWebServer.start();
+
+ ManualConnectivity connectivityHandler = new ManualConnectivity();
+
+ Downloader downloader = buildDownloader().withConnectivityHandler(connectivityHandler).build();
+
+ DownloadRequest request1 =
+ downloader.newRequestBuilder(mockWebServer.url("/foo1").uri(), destination1).build();
+ URI request2Uri = mockWebServer.url("/foo2").uri();
+ DownloadRequest request2 = downloader.newRequestBuilder(request2Uri, destination2).build();
+
+ FluentFuture<DownloadResult> resultFuture1 = downloader.execute(request1);
+ connectivityHandler.setConnectivitySatisfied();
+ connectivityHandler.succeed();
+
+ FluentFuture<DownloadResult> resultFuture2 = downloader.execute(request2);
+
+ resultFuture1.get();
+ resultFuture2.get();
+ System.gc();
+
+ assertAbout(streams())
+ .that(logHandler.logRecords.stream().map(LogRecord::getLevel))
+ .doesNotContain(Level.SEVERE);
+ }
+
+ private static class AlwaysConnected implements ConnectivityHandler {
+ @Override
+ public ListenableFuture<Void> checkConnectivity(DownloadConstraints constraints) {
+ return Futures.immediateFuture(null);
+ }
+ }
+
+ private static class ManualConnectivity implements ConnectivityHandler {
+ private final List<SettableFuture<Void>> futures = new ArrayList<>();
+ private boolean connectivitySatisfied = false;
+
+ public void succeed() {
+ for (SettableFuture<Void> future : futures) {
+ future.set(null);
+ }
+ futures.clear();
+ }
+
+ public void fail(Throwable throwable) {
+ for (SettableFuture<Void> future : futures) {
+ future.setException(throwable);
+ }
+ futures.clear();
+ }
+
+ void setConnectivitySatisfied() {
+ this.connectivitySatisfied = true;
+ }
+
+ @Override
+ public ListenableFuture<Void> checkConnectivity(DownloadConstraints constraints) {
+ if (connectivitySatisfied) {
+ return Futures.immediateFuture(null);
+ }
+ SettableFuture<Void> future = SettableFuture.create();
+ futures.add(future);
+ return future;
+ }
+ }
+
+ private static class TestLogHandler extends Handler {
+ List<LogRecord> logRecords = new ArrayList<>();
+
+ @Override
+ public void close() {}
+
+ @Override
+ public void flush() {}
+
+ @Override
+ public void publish(LogRecord record) {
+ System.err.println("Handling logrecord: " + record.getMessage());
+ logRecords.add(record);
+ }
+ }
+
+ private static class DispatcherImpl extends Dispatcher {
+ private Map<String, Supplier<MockResponse>> responseMap = new HashMap<>();
+
+ public void responseForPath(String path, Supplier<MockResponse> responseSupplier) {
+ responseMap.put(path, responseSupplier);
+ }
+
+ @Override
+ public MockResponse dispatch(RecordedRequest recordedRequest) throws InterruptedException {
+ String path = recordedRequest.getPath();
+ if (path == null) {
+ return defaultResponse();
+ }
+ Supplier<MockResponse> responseSupplier = responseMap.get(recordedRequest.getPath());
+ if (responseSupplier == null) {
+ return defaultResponse();
+ }
+ return responseSupplier.get();
+ }
+
+ @Override
+ public void shutdown() {
+ responseMap.clear();
+ super.shutdown();
+ }
+
+ private MockResponse defaultResponse() {
+ return new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND);
+ }
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/IOUtilTest.java b/src/test/java/com/google/android/downloader/IOUtilTest.java
new file mode 100644
index 0000000..b0a03bf
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/IOUtilTest.java
@@ -0,0 +1,73 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.io.Files;
+import java.io.File;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.SocketChannel;
+import java.nio.file.StandardOpenOption;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for IOUtil. */
+@RunWith(JUnit4.class)
+public class IOUtilTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void validateChannel_nonBlocking() throws Exception {
+ SocketChannel channel = SocketChannel.open();
+ channel.configureBlocking(false);
+ assertThrows(IllegalStateException.class, () -> IOUtil.validateChannel(channel));
+ }
+
+ @Test
+ public void validateChannel_notOpen() throws Exception {
+ File testFile = temporaryFolder.newFile();
+ FileChannel channel = FileChannel.open(testFile.toPath());
+ channel.close();
+
+ assertThrows(IllegalStateException.class, () -> IOUtil.validateChannel(channel));
+ }
+
+ @Test
+ public void validateChannel_valid() throws Exception {
+ File testFile = temporaryFolder.newFile();
+ FileChannel channel = FileChannel.open(testFile.toPath());
+ IOUtil.validateChannel(channel);
+ }
+
+ @Test
+ public void blockingWrite() throws Exception {
+ String message = "hello world";
+ File testFile = temporaryFolder.newFile();
+
+ FileChannel channel = FileChannel.open(testFile.toPath(), StandardOpenOption.WRITE);
+ ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(UTF_8));
+ IOUtil.blockingWrite(buffer, channel);
+ channel.close();
+
+ assertThat(Files.asCharSource(testFile, UTF_8).read()).isEqualTo(message);
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/MockWebServerUrlEngineTestHelper.java b/src/test/java/com/google/android/downloader/MockWebServerUrlEngineTestHelper.java
new file mode 100644
index 0000000..fa80c4e
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/MockWebServerUrlEngineTestHelper.java
@@ -0,0 +1,338 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.Files;
+import com.google.common.net.HttpHeaders;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.net.HttpURLConnection;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.WritableByteChannel;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import okhttp3.HttpUrl;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import okhttp3.mockwebserver.SocketPolicy;
+import okio.Buffer;
+import org.junit.rules.TemporaryFolder;
+
+/**
+ * Helper class for executing common test behaviors for {@link UrlEngine} instances that interact
+ * with the {@link MockWebServer}.
+ */
+class MockWebServerUrlEngineTestHelper {
+ private final TemporaryFolder temporaryFolder;
+ private final TestingExecutorService executorService;
+ private final MockWebServer server;
+
+ MockWebServerUrlEngineTestHelper(
+ TemporaryFolder temporaryFolder, TestingExecutorService executorService) {
+ this.temporaryFolder = temporaryFolder;
+ this.executorService = executorService;
+ server = new MockWebServer();
+ }
+
+ void tearDown() throws Exception {
+ server.shutdown();
+ }
+
+ void executeRequest_normalResponse_succeeds(UrlEngine engine) throws Exception {
+ String message = "foobar";
+ String encoding = "text/plain";
+ String path = "/path";
+
+ server.enqueue(
+ new MockResponse()
+ .setBody(message)
+ .setHeader(HttpHeaders.CONTENT_TYPE, encoding)
+ .setResponseCode(HttpURLConnection.HTTP_OK));
+ server.start();
+
+ HttpUrl url = server.url("/path");
+
+ File file = temporaryFolder.newFile();
+ RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
+ FileChannel channel = randomAccessFile.getChannel();
+
+ UrlRequest request =
+ engine
+ .createRequest(url.toString())
+ .addHeader(HttpHeaders.CACHE_CONTROL, "no-cache")
+ .build();
+ ListenableFuture<UrlResponse> responseFuture = request.send();
+ UrlResponse response = responseFuture.get();
+ ListenableFuture<Long> writeFuture = response.readResponseBody(channel);
+ long bytesWritten = writeFuture.get();
+ response.close();
+ channel.close();
+
+ assertThat(bytesWritten).isEqualTo(message.getBytes(UTF_8).length);
+ assertThat(Files.asCharSource(file, UTF_8).read()).isEqualTo(message);
+ assertThat(response.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_OK);
+ assertThat(response.getResponseHeaders())
+ .containsEntry(HttpHeaders.CONTENT_TYPE, ImmutableList.of(encoding));
+ assertThat(server.getRequestCount()).isEqualTo(1);
+ RecordedRequest recordedRequest = server.takeRequest();
+ assertThat(recordedRequest.getHeaders().toMultimap())
+ .containsEntry(HttpHeaders.CACHE_CONTROL, ImmutableList.of("no-cache"));
+ assertThat(recordedRequest.getMethod()).isEqualTo("GET");
+ assertThat(recordedRequest.getPath()).isEqualTo(path);
+ }
+
+ void executeRequest_responseThrottled_succeeds(UrlEngine engine) throws Exception {
+ String message = "foobar";
+ String encoding = "text/plain";
+ String path = "/path";
+
+ server.enqueue(
+ new MockResponse()
+ .setBody(message)
+ .throttleBody(2, 500, TimeUnit.MILLISECONDS)
+ .setHeader(HttpHeaders.CONTENT_TYPE, encoding)
+ .setResponseCode(HttpURLConnection.HTTP_OK));
+ server.start();
+
+ HttpUrl url = server.url("/path");
+
+ File file = temporaryFolder.newFile();
+ RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
+ FileChannel channel = randomAccessFile.getChannel();
+
+ UrlRequest request =
+ engine
+ .createRequest(url.toString())
+ .addHeader(HttpHeaders.CACHE_CONTROL, "no-cache")
+ .build();
+ ListenableFuture<UrlResponse> responseFuture = request.send();
+ UrlResponse response = responseFuture.get();
+ ListenableFuture<Long> writeFuture = response.readResponseBody(channel);
+ long bytesWritten = writeFuture.get();
+ response.close();
+ channel.close();
+
+ assertThat(bytesWritten).isEqualTo(message.getBytes(UTF_8).length);
+ assertThat(Files.asCharSource(file, UTF_8).read()).isEqualTo(message);
+ assertThat(response.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_OK);
+ assertThat(response.getResponseHeaders())
+ .containsEntry(HttpHeaders.CONTENT_TYPE, ImmutableList.of(encoding));
+ assertThat(server.getRequestCount()).isEqualTo(1);
+ RecordedRequest recordedRequest = server.takeRequest();
+ assertThat(recordedRequest.getHeaders().toMultimap())
+ .containsEntry(HttpHeaders.CACHE_CONTROL, ImmutableList.of("no-cache"));
+ assertThat(recordedRequest.getMethod()).isEqualTo("GET");
+ assertThat(recordedRequest.getPath()).isEqualTo(path);
+ }
+
+ void executeRequest_largeResponse_succeeds(UrlEngine engine, long bufferSizeBytes)
+ throws Exception {
+ Buffer bodyBuffer = new Buffer();
+ for (long i = 0; i < bufferSizeBytes / 8 /* = Long.BYTES */; i++) {
+ bodyBuffer.writeLong(i);
+ }
+
+ String encoding = "text/plain";
+ String path = "/path";
+
+ server.enqueue(
+ new MockResponse()
+ .setBody(bodyBuffer.clone())
+ .setHeader(HttpHeaders.CONTENT_TYPE, encoding)
+ .setResponseCode(HttpURLConnection.HTTP_OK));
+ server.start();
+
+ HttpUrl url = server.url("/path");
+
+ File file = temporaryFolder.newFile();
+ RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
+ FileChannel channel = randomAccessFile.getChannel();
+
+ UrlRequest request =
+ engine
+ .createRequest(url.toString())
+ .addHeader(HttpHeaders.CACHE_CONTROL, "no-cache")
+ .build();
+ ListenableFuture<UrlResponse> responseFuture = request.send();
+ UrlResponse response = responseFuture.get();
+ ListenableFuture<Long> writeFuture = response.readResponseBody(channel);
+ long bytesWritten = writeFuture.get();
+ response.close();
+ channel.close();
+
+ assertThat(bytesWritten).isEqualTo(bodyBuffer.size());
+ assertThat(Files.asByteSource(file).read()).isEqualTo(bodyBuffer.readByteArray());
+ assertThat(response.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_OK);
+ assertThat(response.getResponseHeaders())
+ .containsEntry(HttpHeaders.CONTENT_TYPE, ImmutableList.of(encoding));
+ assertThat(server.getRequestCount()).isEqualTo(1);
+ RecordedRequest recordedRequest = server.takeRequest();
+ assertThat(recordedRequest.getHeaders().toMultimap())
+ .containsEntry(HttpHeaders.CACHE_CONTROL, ImmutableList.of("no-cache"));
+ assertThat(recordedRequest.getMethod()).isEqualTo("GET");
+ assertThat(recordedRequest.getPath()).isEqualTo(path);
+ }
+
+ void executeRequest_closeBeforeWrite_failsAborted(UrlEngine engine) throws Exception {
+ String message = "foobar";
+ String encoding = "text/plain";
+
+ server.enqueue(
+ new MockResponse()
+ .setBody(message)
+ .setHeader(HttpHeaders.CONTENT_TYPE, encoding)
+ .setResponseCode(HttpURLConnection.HTTP_OK));
+ server.start();
+
+ HttpUrl url = server.url("/path");
+
+ File file = temporaryFolder.newFile();
+ RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
+ FileChannel channel = randomAccessFile.getChannel();
+
+ UrlRequest request = engine.createRequest(url.toString()).build();
+ ListenableFuture<UrlResponse> responseFuture = request.send();
+ UrlResponse response = responseFuture.get();
+ response.close();
+
+ ListenableFuture<Long> writeFuture = response.readResponseBody(channel);
+ ExecutionException exception = assertThrows(ExecutionException.class, writeFuture::get);
+ channel.close();
+
+ assertThat(exception).hasCauseThat().isInstanceOf(RequestException.class);
+ }
+
+ void executeRequest_serverError_failsInternalError(UrlEngine engine) throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR));
+ server.start();
+
+ HttpUrl url = server.url("/path");
+
+ UrlRequest request = engine.createRequest(url.toString()).build();
+ ListenableFuture<UrlResponse> responseFuture = request.send();
+
+ ExecutionException exception = assertThrows(ExecutionException.class, responseFuture::get);
+ assertThat(exception).hasCauseThat().isInstanceOf(RequestException.class);
+
+ RequestException requestException = (RequestException) exception.getCause();
+ assertThat(requestException.getErrorDetails().getHttpStatusCode())
+ .isEqualTo(HttpURLConnection.HTTP_INTERNAL_ERROR);
+ }
+
+ void executeRequest_networkError_failsInternalError(UrlEngine engine) throws Exception {
+ server.enqueue(
+ new MockResponse()
+ .setBody("foobar")
+ .setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)
+ .setResponseCode(HttpURLConnection.HTTP_OK));
+ server.start();
+
+ HttpUrl url = server.url("/path");
+
+ UrlRequest request = engine.createRequest(url.toString()).build();
+ ListenableFuture<UrlResponse> responseFuture = request.send();
+
+ ExecutionException exception = assertThrows(ExecutionException.class, responseFuture::get);
+ assertThat(exception).hasCauseThat().isInstanceOf(RequestException.class);
+ }
+
+ void executeRequest_writeError_failsInternalError(UrlEngine engine) throws Exception {
+ String message = "foobar";
+ String errorMessage = "error message";
+
+ server.enqueue(new MockResponse().setBody(message).setResponseCode(HttpURLConnection.HTTP_OK));
+ server.start();
+
+ HttpUrl url = server.url("/path");
+
+ UrlRequest request = engine.createRequest(url.toString()).build();
+ ListenableFuture<UrlResponse> responseFuture = request.send();
+ UrlResponse response = responseFuture.get();
+ ListenableFuture<Long> writeFuture =
+ response.readResponseBody(new ThrowingWritableByteChannel(errorMessage));
+ ExecutionException exception = assertThrows(ExecutionException.class, writeFuture::get);
+ response.close();
+
+ assertThat(response.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_OK);
+ assertThat(server.getRequestCount()).isEqualTo(1);
+
+ assertThat(exception).hasCauseThat().isInstanceOf(RequestException.class);
+ RequestException requestException = (RequestException) exception.getCause();
+ assertThat(requestException).hasCauseThat().isInstanceOf(IOException.class);
+ assertThat(requestException).hasCauseThat().hasMessageThat().isEqualTo(errorMessage);
+ }
+
+ void executeRequest_requestCanceled_requestNeverSent(UrlEngine engine) throws Exception {
+ server.enqueue(
+ new MockResponse()
+ .setSocketPolicy(SocketPolicy.NO_RESPONSE)
+ .setResponseCode(HttpURLConnection.HTTP_OK));
+ server.start();
+ executorService.pause();
+
+ HttpUrl url = server.url("/path");
+
+ UrlRequest request = engine.createRequest(url.toString()).build();
+ ListenableFuture<UrlResponse> responseFuture = request.send();
+
+ responseFuture.cancel(true);
+
+ executorService.resume();
+
+ assertThrows(CancellationException.class, responseFuture::get);
+
+ // No requests were sent to the server!
+ assertThat(server.getRequestCount()).isEqualTo(0);
+ }
+
+ void executeRequest_invalidUrl_failsInvalidArgument(UrlEngine engine) {
+ UrlRequest request = engine.createRequest("foo:bar").build();
+ ListenableFuture<UrlResponse> responseFuture = request.send();
+
+ ExecutionException exception = assertThrows(ExecutionException.class, responseFuture::get);
+ assertThat(exception).hasCauseThat().isInstanceOf(RequestException.class);
+ }
+
+ private static class ThrowingWritableByteChannel implements WritableByteChannel {
+ private final String message;
+
+ ThrowingWritableByteChannel(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public int write(ByteBuffer src) throws IOException {
+ throw new IOException(message);
+ }
+
+ @Override
+ public boolean isOpen() {
+ return true;
+ }
+
+ @Override
+ public void close() {}
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/OkHttp2UrlEngineTest.java b/src/test/java/com/google/android/downloader/OkHttp2UrlEngineTest.java
new file mode 100644
index 0000000..a19c7b0
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/OkHttp2UrlEngineTest.java
@@ -0,0 +1,106 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.util.concurrent.MoreExecutors.listeningDecorator;
+import static java.util.concurrent.Executors.newSingleThreadExecutor;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.squareup.okhttp.Dispatcher;
+import com.squareup.okhttp.OkHttpClient;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for OkHttp2UrlEngine. */
+@RunWith(JUnit4.class)
+public class OkHttp2UrlEngineTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private MockWebServerUrlEngineTestHelper testHelper;
+ private OkHttp2UrlEngine engine;
+ private TestingExecutorService dispatchExecutorService;
+ private ListeningExecutorService transferExecutorService;
+
+ @Before
+ public void setUp() {
+ dispatchExecutorService = new TestingExecutorService(Executors.newSingleThreadExecutor());
+ testHelper = new MockWebServerUrlEngineTestHelper(temporaryFolder, dispatchExecutorService);
+ transferExecutorService = listeningDecorator(newSingleThreadExecutor());
+ // Note: The OkHttpClient dispatcher uses the TestingExecutorService (which waits to execute
+ // tasks) in order to ensure that OkHttp requests are executed in an asynchronous manner, so
+ // that we can properly test request cancellation.
+ OkHttpClient client = new OkHttpClient();
+ client.setDispatcher(new Dispatcher(dispatchExecutorService));
+ engine = new OkHttp2UrlEngine(client, transferExecutorService);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ testHelper.tearDown();
+ transferExecutorService.shutdown();
+ dispatchExecutorService.shutdown();
+ }
+
+ @Test
+ public void executeRequest_normalResponse_succeeds() throws Exception {
+ testHelper.executeRequest_normalResponse_succeeds(engine);
+ }
+
+ @Test
+ public void executeRequest_responseThrottled_succeeds() throws Exception {
+ testHelper.executeRequest_responseThrottled_succeeds(engine);
+ }
+
+ @Test
+ public void executeRequest_largeResponse_succeeds() throws Exception {
+ testHelper.executeRequest_largeResponse_succeeds(engine, 1024 * 1024);
+ }
+
+ @Test
+ public void executeRequest_closeBeforeWrite_failsAborted() throws Exception {
+ testHelper.executeRequest_closeBeforeWrite_failsAborted(engine);
+ }
+
+ @Test
+ public void executeRequest_serverError_failsInternalError() throws Exception {
+ testHelper.executeRequest_serverError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_networkError_failsInternalError() throws Exception {
+ testHelper.executeRequest_networkError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_writeError_failsInternalError() throws Exception {
+ testHelper.executeRequest_writeError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_requestCanceled_requestNeverSent() throws Exception {
+ testHelper.executeRequest_requestCanceled_requestNeverSent(engine);
+ }
+
+ @Test
+ public void executeRequest_invalidUrl_failsInvalidArgument() {
+ testHelper.executeRequest_invalidUrl_failsInvalidArgument(engine);
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/OkHttp3UrlEngineTest.java b/src/test/java/com/google/android/downloader/OkHttp3UrlEngineTest.java
new file mode 100644
index 0000000..88236b0
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/OkHttp3UrlEngineTest.java
@@ -0,0 +1,106 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.util.concurrent.MoreExecutors.listeningDecorator;
+import static java.util.concurrent.Executors.newSingleThreadExecutor;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
+import java.util.concurrent.Executors;
+import okhttp3.Dispatcher;
+import okhttp3.OkHttpClient;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for OkHttp3UrlEngine. */
+@RunWith(JUnit4.class)
+public class OkHttp3UrlEngineTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private MockWebServerUrlEngineTestHelper testHelper;
+ private OkHttp3UrlEngine engine;
+ private TestingExecutorService dispatchExecutorService;
+ private ListeningExecutorService transferExecutorService;
+
+ @Before
+ public void setUp() {
+ dispatchExecutorService = new TestingExecutorService(Executors.newSingleThreadExecutor());
+ testHelper = new MockWebServerUrlEngineTestHelper(temporaryFolder, dispatchExecutorService);
+ transferExecutorService = listeningDecorator(newSingleThreadExecutor());
+ // Note: The OkHttpClient dispatcher uses the TestingExecutorService (which waits to execute
+ // tasks) in order to ensure that OkHttp requests are executed in an asynchronous manner, so
+ // that we can properly test request cancellation.
+ OkHttpClient client =
+ new OkHttpClient.Builder().dispatcher(new Dispatcher(dispatchExecutorService)).build();
+ engine = new OkHttp3UrlEngine(client, transferExecutorService);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ testHelper.tearDown();
+ transferExecutorService.shutdown();
+ dispatchExecutorService.shutdown();
+ }
+
+ @Test
+ public void executeRequest_normalResponse_succeeds() throws Exception {
+ testHelper.executeRequest_normalResponse_succeeds(engine);
+ }
+
+ @Test
+ public void executeRequest_responseThrottled_succeeds() throws Exception {
+ testHelper.executeRequest_responseThrottled_succeeds(engine);
+ }
+
+ @Test
+ public void executeRequest_largeResponse_succeeds() throws Exception {
+ testHelper.executeRequest_largeResponse_succeeds(engine, 1024 * 1024);
+ }
+
+ @Test
+ public void executeRequest_closeBeforeWrite_failsAborted() throws Exception {
+ testHelper.executeRequest_closeBeforeWrite_failsAborted(engine);
+ }
+
+ @Test
+ public void executeRequest_serverError_failsInternalError() throws Exception {
+ testHelper.executeRequest_serverError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_networkError_failsInternalError() throws Exception {
+ testHelper.executeRequest_networkError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_writeError_failsInternalError() throws Exception {
+ testHelper.executeRequest_writeError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_requestCanceled_requestNeverSent() throws Exception {
+ testHelper.executeRequest_requestCanceled_requestNeverSent(engine);
+ }
+
+ @Test
+ public void executeRequest_invalidUrl_failsInvalidArgument() {
+ testHelper.executeRequest_invalidUrl_failsInvalidArgument(engine);
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/PlatformUrlEngineTest.java b/src/test/java/com/google/android/downloader/PlatformUrlEngineTest.java
new file mode 100644
index 0000000..10ec850
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/PlatformUrlEngineTest.java
@@ -0,0 +1,133 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.listeningDecorator;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.Executors.newSingleThreadExecutor;
+
+import com.google.common.io.Files;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import java.io.File;
+import java.net.HttpURLConnection;
+import java.nio.channels.FileChannel;
+import java.nio.file.StandardOpenOption;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for PlatformUrlEngine. */
+@RunWith(JUnit4.class)
+public class PlatformUrlEngineTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private MockWebServerUrlEngineTestHelper testHelper;
+ private PlatformUrlEngine engine;
+ private ListeningExecutorService executorService;
+
+ @Before
+ public void setUp() {
+ executorService = listeningDecorator(newSingleThreadExecutor());
+ testHelper =
+ new MockWebServerUrlEngineTestHelper(
+ temporaryFolder, new TestingExecutorService(executorService));
+ engine =
+ new PlatformUrlEngine(
+ executorService, /* connectTimeoutMs= */ 1000, /* readTimeoutMs= */ 1000);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ testHelper.tearDown();
+ executorService.shutdown();
+ }
+
+ @Test
+ public void executeRequest_normalResponse_succeeds() throws Exception {
+ testHelper.executeRequest_normalResponse_succeeds(engine);
+ }
+
+ @Test
+ public void executeRequest_responseThrottled_succeeds() throws Exception {
+ testHelper.executeRequest_responseThrottled_succeeds(engine);
+ }
+
+ @Test
+ public void executeRequest_largeResponse_succeeds() throws Exception {
+ testHelper.executeRequest_largeResponse_succeeds(
+ engine, PlatformUrlEngine.BUFFER_SIZE_BYTES * 3);
+ }
+
+ @Test
+ public void executeRequest_closeBeforeWrite_failsAborted() throws Exception {
+ testHelper.executeRequest_closeBeforeWrite_failsAborted(engine);
+ }
+
+ @Test
+ public void executeRequest_serverError_failsInternalError() throws Exception {
+ testHelper.executeRequest_serverError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_networkError_failsInternalError() throws Exception {
+ testHelper.executeRequest_networkError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_writeError_failsInternalError() throws Exception {
+ testHelper.executeRequest_writeError_failsInternalError(engine);
+ }
+
+ @Test
+ public void executeRequest_requestCanceled_requestNeverSent() throws Exception {
+ testHelper.executeRequest_requestCanceled_requestNeverSent(engine);
+ }
+
+ @Test
+ public void executeRequest_invalidUrl_failsInvalidArgument() {
+ testHelper.executeRequest_invalidUrl_failsInvalidArgument(engine);
+ }
+
+ @Test
+ public void executeRequest_fileUrl() throws Exception {
+ // Note: File url testing doesn't exist in MockWebServerUrlEngineTestHelper because
+ // it doesn't involve the MockWebServer and doesn't apply to all UrlEngine implementations.
+ String message = "foobar";
+ File sourceFile = temporaryFolder.newFile();
+ Files.asCharSink(sourceFile, UTF_8).write(message);
+
+ String url = sourceFile.toURI().toURL().toString();
+ UrlRequest request = engine.createRequest(url).build();
+ ListenableFuture<? extends UrlResponse> responseFuture = request.send();
+ UrlResponse response = responseFuture.get();
+
+ File targetFile = temporaryFolder.newFile();
+ FileChannel channel = FileChannel.open(targetFile.toPath(), StandardOpenOption.WRITE);
+ ListenableFuture<Long> writeFuture = response.readResponseBody(channel);
+ long bytesWritten = writeFuture.get();
+ response.close();
+ channel.close();
+
+ assertThat(bytesWritten).isGreaterThan(0L);
+ assertThat(Files.asCharSource(targetFile, UTF_8).read()).isEqualTo(message);
+ assertThat(response.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_OK);
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/ProtoFileDownloadDestinationTest.java b/src/test/java/com/google/android/downloader/ProtoFileDownloadDestinationTest.java
new file mode 100644
index 0000000..cc63cc7
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/ProtoFileDownloadDestinationTest.java
@@ -0,0 +1,186 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.io.CharSource;
+import com.google.common.io.Files;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.nio.channels.Channels;
+import java.nio.channels.WritableByteChannel;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for ProtoFileDownloadDestination. */
+@RunWith(JUnit4.class)
+public final class ProtoFileDownloadDestinationTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void numExistingBytes_fileDoesNotExist() throws Exception {
+ File targetFile = new File("/does/not/exist");
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+
+ // Perhaps a bit surprising, but java.io.File.length returns 0 for a file that does not exist
+ assertThat(downloadDestination.numExistingBytes()).isEqualTo(0);
+ }
+
+ @Test
+ public void numExistingBytes_fileEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+ assertThat(downloadDestination.numExistingBytes()).isEqualTo(0);
+ }
+
+ @Test
+ public void numExistingBytes_fileNonEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+ Files.asCharSink(targetFile, UTF_8).write("Hello world");
+ assertThat(downloadDestination.numExistingBytes()).isEqualTo(targetFile.length());
+ }
+
+ @Test
+ public void metadata_fileDoesNotExist() throws Exception {
+ File targetFile = new File("/does/not/exist");
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+
+ DownloadMetadata metadata = downloadDestination.readMetadata();
+ assertThat(metadata).isEqualTo(DownloadMetadata.create());
+ }
+
+ @Test
+ public void metadata_fileEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+
+ DownloadMetadata metadata = downloadDestination.readMetadata();
+ assertThat(metadata).isEqualTo(DownloadMetadata.create());
+ }
+
+ @Test
+ public void metadata_fileNonEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+ Files.asCharSink(targetFile, UTF_8).write("Hello world");
+
+ DownloadMetadata metadata = downloadDestination.readMetadata();
+ assertThat(metadata).isEqualTo(DownloadMetadata.create());
+ }
+
+ @Test
+ public void openByteChannel_fileDoesNotExist() {
+ File targetFile = new File("/does/not/exist");
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+ assertThrows(
+ FileNotFoundException.class,
+ () -> downloadDestination.openByteChannel(0L, DownloadMetadata.create()));
+ }
+
+ @Test
+ public void openByteChannel_fileEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+ String text = "Hello world";
+
+ WritableByteChannel channel =
+ downloadDestination.openByteChannel(0L, DownloadMetadata.create());
+ assertThat(channel.isOpen()).isTrue();
+ CharSource.wrap(text).asByteSource(UTF_8).copyTo(Channels.newOutputStream(channel));
+ channel.close();
+
+ assertThat(Files.asCharSource(targetFile, UTF_8).read()).isEqualTo(text);
+ }
+
+ @Test
+ public void openByteChannel_fileNonEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+ String text1 = "Hello world";
+ Files.asCharSink(targetFile, UTF_8).write(text1);
+ String text2 = "Bye world";
+
+ WritableByteChannel channel =
+ downloadDestination.openByteChannel(targetFile.length(), DownloadMetadata.create());
+ assertThat(channel.isOpen()).isTrue();
+ CharSource.wrap(text2).asByteSource(UTF_8).copyTo(Channels.newOutputStream(channel));
+ channel.close();
+
+ assertThat(Files.asCharSource(targetFile, UTF_8).read()).isEqualTo(text1 + text2);
+ }
+
+ @Test
+ public void openByteChannel_metadata() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+
+ String contentTag = "content_tag_abc";
+ long lastModifiedTimeSeconds = 12345;
+ downloadDestination
+ .openByteChannel(0L, DownloadMetadata.create(contentTag, lastModifiedTimeSeconds))
+ .close();
+
+ DownloadMetadata metadata = downloadDestination.readMetadata();
+ assertThat(metadata).isEqualTo(DownloadMetadata.create(contentTag, lastModifiedTimeSeconds));
+ }
+
+ @Test
+ public void clear_fileNonEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ ProtoFileDownloadDestination downloadDestination =
+ new ProtoFileDownloadDestination(targetFile, metadataFile);
+ Files.asCharSink(targetFile, UTF_8).write("existing");
+
+ WritableByteChannel channel1 =
+ downloadDestination.openByteChannel(0L, DownloadMetadata.create());
+ downloadDestination.clear();
+ WritableByteChannel channel2 =
+ downloadDestination.openByteChannel(0L, DownloadMetadata.create());
+
+ CharSource.wrap("swallowed").asByteSource(UTF_8).copyTo(Channels.newOutputStream(channel1));
+ CharSource.wrap("replacement").asByteSource(UTF_8).copyTo(Channels.newOutputStream(channel2));
+ channel2.close();
+
+ assertThat(Files.asCharSource(targetFile, UTF_8).read()).isEqualTo("replacement");
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/SimpleFileDownloadDestinationTest.java b/src/test/java/com/google/android/downloader/SimpleFileDownloadDestinationTest.java
new file mode 100644
index 0000000..1eb0aaf
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/SimpleFileDownloadDestinationTest.java
@@ -0,0 +1,186 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.io.CharSource;
+import com.google.common.io.Files;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.nio.channels.Channels;
+import java.nio.channels.WritableByteChannel;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for SimpleFileDownloadDestination. */
+@RunWith(JUnit4.class)
+public final class SimpleFileDownloadDestinationTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void numExistingBytes_fileDoesNotExist() throws Exception {
+ File targetFile = new File("/does/not/exist");
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+
+ // Perhaps a bit surprising, but java.io.File.length returns 0 for a file that does not exist
+ assertThat(downloadDestination.numExistingBytes()).isEqualTo(0);
+ }
+
+ @Test
+ public void numExistingBytes_fileEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+ assertThat(downloadDestination.numExistingBytes()).isEqualTo(0);
+ }
+
+ @Test
+ public void numExistingBytes_fileNonEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+ Files.asCharSink(targetFile, UTF_8).write("Hello world");
+ assertThat(downloadDestination.numExistingBytes()).isEqualTo(targetFile.length());
+ }
+
+ @Test
+ public void metadata_fileDoesNotExist() throws Exception {
+ File targetFile = new File("/does/not/exist");
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+
+ DownloadMetadata metadata = downloadDestination.readMetadata();
+ assertThat(metadata).isEqualTo(DownloadMetadata.create());
+ }
+
+ @Test
+ public void metadata_fileEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+
+ DownloadMetadata metadata = downloadDestination.readMetadata();
+ assertThat(metadata).isEqualTo(DownloadMetadata.create());
+ }
+
+ @Test
+ public void metadata_fileNonEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+ Files.asCharSink(targetFile, UTF_8).write("Hello world");
+
+ DownloadMetadata metadata = downloadDestination.readMetadata();
+ assertThat(metadata).isEqualTo(DownloadMetadata.create());
+ }
+
+ @Test
+ public void openByteChannel_fileDoesNotExist() {
+ File targetFile = new File("/does/not/exist");
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+ assertThrows(
+ FileNotFoundException.class,
+ () -> downloadDestination.openByteChannel(0L, DownloadMetadata.create()));
+ }
+
+ @Test
+ public void openByteChannel_fileEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+ String text = "Hello world";
+
+ WritableByteChannel channel =
+ downloadDestination.openByteChannel(0L, DownloadMetadata.create());
+ assertThat(channel.isOpen()).isTrue();
+ CharSource.wrap(text).asByteSource(UTF_8).copyTo(Channels.newOutputStream(channel));
+ channel.close();
+
+ assertThat(Files.asCharSource(targetFile, UTF_8).read()).isEqualTo(text);
+ }
+
+ @Test
+ public void openByteChannel_fileNonEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+ String text1 = "Hello world";
+ Files.asCharSink(targetFile, UTF_8).write(text1);
+ String text2 = "Bye world";
+
+ WritableByteChannel channel =
+ downloadDestination.openByteChannel(targetFile.length(), DownloadMetadata.create());
+ assertThat(channel.isOpen()).isTrue();
+ CharSource.wrap(text2).asByteSource(UTF_8).copyTo(Channels.newOutputStream(channel));
+ channel.close();
+
+ assertThat(Files.asCharSource(targetFile, UTF_8).read()).isEqualTo(text1 + text2);
+ }
+
+ @Test
+ public void openByteChannel_metadata() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+
+ String contentTag = "content_tag_abc";
+ long lastModifiedTimeSeconds = 12345;
+ downloadDestination
+ .openByteChannel(0L, DownloadMetadata.create(contentTag, lastModifiedTimeSeconds))
+ .close();
+
+ DownloadMetadata metadata = downloadDestination.readMetadata();
+ assertThat(metadata).isEqualTo(DownloadMetadata.create(contentTag, lastModifiedTimeSeconds));
+ }
+
+ @Test
+ public void clear_fileNonEmpty() throws Exception {
+ File targetFile = temporaryFolder.newFile();
+ File metadataFile = new File(targetFile.getParent(), targetFile.getName() + ".meta");
+ SimpleFileDownloadDestination downloadDestination =
+ new SimpleFileDownloadDestination(targetFile, metadataFile);
+ Files.asCharSink(targetFile, UTF_8).write("existing");
+
+ WritableByteChannel channel1 =
+ downloadDestination.openByteChannel(0L, DownloadMetadata.create());
+ downloadDestination.clear();
+ WritableByteChannel channel2 =
+ downloadDestination.openByteChannel(0L, DownloadMetadata.create());
+
+ CharSource.wrap("swallowed").asByteSource(UTF_8).copyTo(Channels.newOutputStream(channel1));
+ CharSource.wrap("replacement").asByteSource(UTF_8).copyTo(Channels.newOutputStream(channel2));
+ channel2.close();
+
+ assertThat(Files.asCharSource(targetFile, UTF_8).read()).isEqualTo("replacement");
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/TestExecutorRule.java b/src/test/java/com/google/android/downloader/TestExecutorRule.java
new file mode 100644
index 0000000..250abe7
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/TestExecutorRule.java
@@ -0,0 +1,114 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.stream.Collectors.joining;
+import static org.junit.Assert.fail;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadFactory;
+import org.junit.rules.ExternalResource;
+
+/**
+ * A {@link org.junit.rules.TestRule} that manages and provides instances of {@link
+ * java.util.concurrent.Executor} and its various more specific interfaces. Takes care of shutting
+ * down any started threads and executors during execution, and also collects uncaught exceptions,
+ * failing the test and reporting the uncaught exception if any are found during execution.
+ */
+public class TestExecutorRule extends ExternalResource {
+ private final Duration timeout;
+ private final List<Throwable> uncaughtExceptions = new ArrayList<>();
+ private final List<ExecutorService> executorServices = new ArrayList<>();
+ private final ThreadFactory threadFactory =
+ runnable -> {
+ Thread thread = Executors.defaultThreadFactory().newThread(runnable);
+ // Insert an uncaught exception handler so that that errors happening on a background
+ // thread can be collected and cause test failures.
+ thread.setUncaughtExceptionHandler((t, e) -> uncaughtExceptions.add(e));
+ return thread;
+ };
+
+ /**
+ * Constructs a new instance of this rule with the provided {@code timeout}. The timeout will be
+ * used when calling {@link ExecutorService#awaitTermination} on any {@link ExecutorService}
+ * instances created by this rule.
+ */
+ public TestExecutorRule(Duration timeout) {
+ this.timeout = timeout;
+ }
+
+ /**
+ * Creates a new single-threaded {@link ExecutorService} for use in tests. The executor will
+ * collect any uncaught exceptions encountered during test execution, and will fail the test with
+ * a detailed report of exceptions, if any are encountered. The executor will also be shut down
+ * and will await termination. Failure to shutdown in time (e.g. due to a blocked thread) will
+ * result in a test failure as well.
+ */
+ public ExecutorService newSingleThreadExecutor() {
+ ExecutorService executorService = Executors.newSingleThreadExecutor(threadFactory);
+ executorServices.add(executorService);
+ return executorService;
+ }
+
+ /**
+ * Creates a new single-threaded {@link ScheduledExecutorService} for use in tests. The executor
+ * will collect any uncaught exceptions encountered during test execution, and will fail the test
+ * with a detailed report of exceptions, if any are encountered. The executor will also be shut
+ * down and will await termination. Failure to shutdown in time (e.g. due to a blocked thread)
+ * will result in a test failure as well.
+ */
+ public ScheduledExecutorService newSingleThreadScheduledExecutor() {
+ ScheduledExecutorService executorService =
+ Executors.newSingleThreadScheduledExecutor(threadFactory);
+ executorServices.add(executorService);
+ return executorService;
+ }
+
+ @Override
+ protected void after() {
+ try {
+ for (ExecutorService executorService : executorServices) {
+ try {
+ executorService.shutdown();
+ assertThat(executorService.awaitTermination(timeout.toMillis(), MILLISECONDS)).isTrue();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ fail("Error shutting down executor service:" + e);
+ } catch (Exception e) {
+ fail("Error shutting down executor service:" + e);
+ }
+ }
+
+ if (!uncaughtExceptions.isEmpty()) {
+ String message =
+ uncaughtExceptions.stream()
+ .map(e -> "\n\t" + getStackTraceAsString(e).replace("\t", "\t\t"))
+ .collect(joining("\n"));
+ fail("Uncaught exceptions found: " + message);
+ }
+ } finally {
+ uncaughtExceptions.clear();
+ executorServices.clear();
+ }
+ }
+}
diff --git a/src/test/java/com/google/android/downloader/TestingExecutorService.java b/src/test/java/com/google/android/downloader/TestingExecutorService.java
new file mode 100644
index 0000000..2fa6b40
--- /dev/null
+++ b/src/test/java/com/google/android/downloader/TestingExecutorService.java
@@ -0,0 +1,86 @@
+// Copyright 2021 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.downloader;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.util.concurrent.AbstractListeningExecutorService;
+import java.util.ArrayDeque;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Implementation of {@link com.google.common.util.concurrent.ListeningExecutorService} for testing
+ * purposes. It artificially delays runnables enqueued via calls to {@link #execute} to allow tests
+ * to exercise async behavior.
+ */
+final class TestingExecutorService extends AbstractListeningExecutorService {
+ private final ExecutorService delegate;
+ private final Queue<Runnable> taskQueue = new ArrayDeque<>();
+
+ private boolean paused = false;
+
+ TestingExecutorService(ExecutorService delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void shutdown() {
+ delegate.shutdown();
+ }
+
+ @Override
+ public List<Runnable> shutdownNow() {
+ return delegate.shutdownNow();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return delegate.isShutdown();
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return delegate.isTerminated();
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+ return delegate.awaitTermination(timeout, unit);
+ }
+
+ public synchronized void pause() {
+ paused = true;
+ }
+
+ public synchronized void resume() {
+ paused = false;
+ while (!taskQueue.isEmpty()) {
+ delegate.execute(checkNotNull(taskQueue.poll()));
+ }
+ }
+
+ @Override
+ public void execute(Runnable command) {
+ if (paused) {
+ taskQueue.add(command);
+ return;
+ }
+
+ delegate.execute(command);
+ }
+}