diff options
Diffstat (limited to 'src/test/java')
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); + } +} |