/*
* Copyright (C) 2014 SCVNGR, Inc. d/b/a LevelUp
*
* 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.scvngr.levelup.core.net;
import android.content.Context;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import com.scvngr.levelup.core.annotation.LevelUpApi;
import com.scvngr.levelup.core.annotation.LevelUpApi.Contract;
import com.scvngr.levelup.core.annotation.VisibleForTesting;
import com.scvngr.levelup.core.annotation.VisibleForTesting.Visibility;
import com.scvngr.levelup.core.net.AbstractRequest.BadRequestException;
import com.scvngr.levelup.core.util.EnvironmentUtil;
import com.scvngr.levelup.core.util.LogManager;
import com.scvngr.levelup.core.util.NullUtils;
import net.jcip.annotations.ThreadSafe;
import java.io.EOFException;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.util.Map;
/**
* Utility class for performing network operations.
* <p>
* Via the {@link #send(Context, AbstractRequest)} method, this class takes in
* {@link AbstractRequest} objects, sends them over the network, and returns
* {@link StreamingResponse} objects.
*/
@ThreadSafe
@LevelUpApi(contract = Contract.INTERNAL)
public final class NetworkConnection {
/**
* The maximum number of simultaneous connections on each server.
* <p>
* The connection pool's implementation relies on reading the {@code http.maxConnections} system
* property during static initialization. The best we can be do is assume that the property's
* current value was also observed by the connection pool. When the property is not set, a
* default value of 5 is used on all supported devices up to and including
* {@link android.os.Build.VERSION_CODES#KITKAT}. The default value is an internal
* implementation detail that may change when newer versions of Android become available.
* </p>
*/
@VisibleForTesting(visibility = Visibility.PRIVATE)
/* package */static final int MAX_POOLED_CONNECTIONS =
Integer.valueOf(System.getProperty("http.maxConnections", "5"));
/*
* The static fields stored by this class are not necessarily thread-safe. They operate on a
* best-effort basis to improve testing.
*/
@Nullable
private static volatile StreamingResponse sNextResponse = null;
/**
* Send the {@link AbstractRequest} using HTTP and create a {@link AbstractResponse}.
*
* @param context Application Context.
* @param request the request to send.
* @return the {@link AbstractResponse} received.
*/
@NonNull
public static StreamingResponse send(@NonNull final Context context,
@NonNull final AbstractRequest request) {
StreamingResponse response;
try {
response = doSendWithRetry(context, request);
} catch (final IOException e) {
LogManager.v("Error during send", e);
response = new StreamingResponse(e);
} catch (final BadRequestException e) {
LogManager.v("Error during send", e);
response = new StreamingResponse(e);
}
return response;
}
/**
* Performs the send to the server.
* <p>
* The implementation of this method includes a workaround for {@link EOFException}s caused by
* the reuse of stale connections. Send may be attempted multiple times in order to close these
* connections. See <a href="http://stackoverflow.com/a/23795099/204480">Stack Overflow</a> for
* more details.
* </p>
*
* @param context Application Context.
* @param request the request to send.
* @return {@link StreamingResponse} containing information regarding the outcome of the send.
* @throws IOException if network operations fail.
* @throws BadRequestException if the request is invalid.
*/
@NonNull
private static StreamingResponse doSendWithRetry(@NonNull final Context context,
@NonNull final AbstractRequest request) throws IOException, BadRequestException {
LogManager.v("HTTP request headers: %s", request.getRequestHeaders(context));
final boolean isPooled = MAX_POOLED_CONNECTIONS > 0;
for (int i = 0; i < MAX_POOLED_CONNECTIONS + 1; i++) {
// Close the connection if this is a retry and the connections are pooled.
final boolean shouldCloseConnection = i > 0 && isPooled;
try {
return doSend(context, request, shouldCloseConnection);
} catch (final EOFException e) {
LogManager.e(NullUtils.format("Unable to send request: failures(%d)", i), e);
}
}
return doSend(context, request, false);
}
/**
* Performs the send to the server.
*
* @param context Application Context.
* @param request the request to send.
* @param shouldCloseConnection determines whether the connection should be closed after the
* request has been made.
* @return {@link StreamingResponse} containing information regarding the outcome of the send.
* @throws IOException if network operations fail.
* @throws BadRequestException if the request is invalid.
*/
@NonNull
private static StreamingResponse doSend(@NonNull final Context context,
@NonNull final AbstractRequest request, final boolean shouldCloseConnection)
throws IOException, BadRequestException {
HttpURLConnection connection = null;
StreamingResponse response = null;
try {
// Configure the connection based on the request passed
connection = configureConnection(context, request);
if (shouldCloseConnection) {
connection.setRequestProperty("Connection", "close");
}
// Write the post body if necessary
doOutput(context, connection, request);
// Get the response from the server
response = getResponse(connection);
} finally {
if (response == null) {
if (connection != null) {
connection.disconnect();
}
}
}
return response;
}
/**
* Configures the {@link HttpURLConnection} to use for this request.
*
* @param context Application Context.
* @param request the request to use to configure the connection
* @return the configured connection
* @throws IOException if configuration fails
* @throws BadRequestException if the request is invalid.
*/
@VisibleForTesting(visibility = Visibility.PRIVATE)
@NonNull
/* package */static HttpURLConnection configureConnection(@NonNull final Context context,
@NonNull final AbstractRequest request) throws IOException, BadRequestException {
final HttpURLConnection connection =
(HttpURLConnection) request.getUrl(context).openConnection();
// Set the HTTP method (GET, POST, PUT, etc..)
connection.setRequestMethod(request.getMethod().name());
// Append the HTTP headers to the request
final Map<String, String> headers = request.getRequestHeaders(context);
for (final String headerKey : headers.keySet()) {
connection.setRequestProperty(headerKey, headers.get(headerKey));
}
final int bodyLength = request.getBodyLength(context);
if (0 != bodyLength) {
/*
* Due to an issue with internal buffering on Android 2.2, streaming can cause future
* connections to hang if an IOException is thrown while writing to a connection's
* output stream. Both fixed-length and chunked streaming modes are affected. This is
* fixed in Android 2.3+ with the changes made for
* https://code.google.com/p/android/issues/detail?id=3164.
*/
if (EnvironmentUtil.isSdk9OrGreater()) {
connection.setFixedLengthStreamingMode(bodyLength);
}
connection.setDoOutput(true);
}
return connection;
}
/**
* Write to the post output stream if necessary.
*
* @param context Application Context.
* @param connection the connection to write to
* @param request the request containing the post body
* @throws IOException if writing of the post body fails.
*/
@VisibleForTesting(visibility = Visibility.PRIVATE)
/* package */static void doOutput(@NonNull final Context context,
@NonNull final HttpURLConnection connection, @NonNull final AbstractRequest request)
throws IOException {
if (connection.getDoOutput()) {
OutputStream stream = null;
try {
stream = NullUtils.nonNullContract(connection.getOutputStream());
try {
request.writeBodyToStream(context, stream);
} catch (final IOException e) {
// Close the stream quietly.
try {
stream.close();
} catch (final IOException f) {
// OutputStream expected more output.
}
stream = null;
throw e;
}
} finally {
if (null != stream) {
stream.close();
}
}
}
}
/**
* Gets the response from the server.
*
* @param connection the connection to use to make the request to the server
* @return {@link StreamingResponse} containing information regarding the outcome of the send
* @throws IOException if network operations fail
*/
@VisibleForTesting(visibility = Visibility.PRIVATE)
@NonNull
/* package */static StreamingResponse getResponse(@NonNull final HttpURLConnection connection)
throws IOException {
final StreamingResponse nextResponse = sNextResponse;
final StreamingResponse response;
if (null == nextResponse) {
// Create the response object to pass back to the caller
response = new StreamingResponse(connection);
} else {
// If the sNextResponse field was set return it instead of doing the network request
response = nextResponse;
sNextResponse = null;
}
return response;
}
/**
* Method to use for testing to set the next response to return regardless of the request.
*
* @param nextResponse the response to return for the next network request.
*/
@VisibleForTesting(visibility = Visibility.PRIVATE)
/* package */static void setNextResponse(@Nullable final StreamingResponse nextResponse) {
sNextResponse = nextResponse;
}
/**
* Private constructor prevents instantiation.
*
* @throws UnsupportedOperationException because this class cannot be instantiated.
*/
private NetworkConnection() {
throw new UnsupportedOperationException("This class is non-instantiable");
}
}