package cgeo.geocaching.network;
import cgeo.geocaching.CgeoApplication;
import cgeo.geocaching.settings.Settings;
import cgeo.geocaching.utils.FileUtils;
import cgeo.geocaching.utils.JsonUtils;
import cgeo.geocaching.utils.Log;
import cgeo.geocaching.utils.RxOkHttpUtils;
import cgeo.geocaching.utils.TextUtils;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.reactivex.Completable;
import io.reactivex.Single;
import io.reactivex.functions.Function;
import okhttp3.FormBody;
import okhttp3.HttpUrl;
import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Request.Builder;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.apache.commons.compress.utils.IOUtils;
import org.apache.commons.lang3.CharEncoding;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
public final class Network {
/** User agent id */
private static final String PC_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:9.0.1) Gecko/20100101 Firefox/9.0.1";
/** Native user agent, taken from a Android 2.2 Nexus **/
private static final String NATIVE_USER_AGENT = "Mozilla/5.0 (Linux; U; Android 2.2; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1";
private static final Pattern PATTERN_PASSWORD = Pattern.compile("(?<=[\\?&])[Pp]ass(w(or)?d)?=[^$]+");
private static final OkHttpClient OK_HTTP_CLIENT = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.cookieJar(Cookies.cookieJar)
.addInterceptor(new HeadersInterceptor())
.addInterceptor(new LoggingInterceptor())
.build();
private static final MediaType MEDIA_TYPE_APPLICATION_JSON = MediaType.parse("application/json; charset=utf-8");
public static final Function<String, Single<? extends ObjectNode>> stringToJson = new Function<String, Single<? extends ObjectNode>>() {
@Override
public Single<? extends ObjectNode> apply(final String s) {
try {
return Single.just((ObjectNode) JsonUtils.reader.readTree(s));
} catch (final Throwable t) {
return Single.error(t);
}
}
};
private static ConnectivityManager connectivityManager = null;
private Network() {
// Utility class
}
/**
* POST HTTP request
*
* @param uri the URI to request
* @param params the parameters to add to the POST request
* @return a Single with the HTTP response, or an IOException
*/
@NonNull
public static Single<Response> postRequest(final String uri, final Parameters params) {
return request("POST", uri, params, null, null);
}
/**
* POST HTTP request
*
* @param uri the URI to request
* @param params the parameters to add to the POST request
* @param headers the headers to add to the request
* @return a single with the HTTP response, or an IOException
*/
@NonNull
public static Single<Response> postRequest(final String uri, final Parameters params, final Parameters headers) {
return request("POST", uri, params, headers, null);
}
/**
* POST HTTP request with Json POST DATA
*
* @param uri the URI to request
* @param json the json object to add to the POST request
* @return a single with the HTTP response, or an IOException
*/
@NonNull
public static Single<Response> postJsonRequest(final String uri, final ObjectNode json) {
final Request request = new Request.Builder().url(uri).post(RequestBody.create(MEDIA_TYPE_APPLICATION_JSON,
json.toString())).build();
return RxOkHttpUtils.request(OK_HTTP_CLIENT, request);
}
/**
* Multipart POST HTTP request
*
* @param uri the URI to request
* @param params the parameters to add to the POST request
* @param fileFieldName the name of the file field name
* @param fileContentType the content-type of the file
* @param file the file to include in the request
* @return a single with the HTTP response, or an IOException
*/
@NonNull
public static Single<Response> postRequest(final String uri, final Parameters params,
final String fileFieldName, final String fileContentType, final File file) {
final MultipartBody.Builder entity = new MultipartBody.Builder().setType(MultipartBody.FORM);
for (final ImmutablePair<String, String> param : params) {
entity.addFormDataPart(param.left, param.right);
}
entity.addFormDataPart(fileFieldName, file.getName(),
RequestBody.create(MediaType.parse(fileContentType), file));
final Builder request = new Request.Builder().url(uri).post(entity.build());
addHeaders(request, null, null);
return RxOkHttpUtils.request(OK_HTTP_CLIENT, request.build());
}
/**
* Make an HTTP request
*
* @param method
* the HTTP method to use ("GET" or "POST")
* @param uri
* the URI to request
* @param params
* the parameters to add to the URI
* @param headers
* the headers to add to the request
* @param cacheFile
* the cache file used to cache this query
* @return a single with the HTTP response, or an IOException
*/
@NonNull
private static Single<Response> request(final String method, final String uri,
@Nullable final Parameters params, @Nullable final Parameters headers,
@Nullable final File cacheFile) {
final Builder builder = new Builder();
if ("GET".equals(method)) {
final HttpUrl.Builder urlBuilder = HttpUrl.parse(uri).newBuilder();
if (params != null) {
urlBuilder.encodedQuery(params.toString());
}
builder.url(urlBuilder.build());
} else {
builder.url(uri);
final FormBody.Builder body = new FormBody.Builder();
if (params != null) {
for (final ImmutablePair<String, String> param : params) {
body.add(param.left, param.right);
}
}
builder.post(body.build());
}
addHeaders(builder, headers, cacheFile);
return RxOkHttpUtils.request(OK_HTTP_CLIENT, builder.build());
}
/**
* Add headers to HTTP request.
* @param request
* the request builder to add headers to
* @param headers
* the headers to add (in addition to the standard headers), can be null
* @param cacheFile
* if non-null, the file to take ETag and If-Modified-Since information from
*/
private static void addHeaders(final Builder request, @Nullable final Parameters headers, @Nullable final File cacheFile) {
for (final ImmutablePair<String, String> header : Parameters.extend(Parameters.merge(headers, cacheHeaders(cacheFile)))) {
request.header(header.left, header.right);
}
}
private static class HeadersInterceptor implements Interceptor {
@Override
public Response intercept(final Interceptor.Chain chain) throws IOException {
final Request request = chain.request().newBuilder()
.header("Accept-Charset", "utf-8,iso-8859-1;q=0.8,utf-16;q=0.8,*;q=0.7")
.header("Accept-Language", "en-US,*;q=0.9")
.header("X-Requested-With", "XMLHttpRequest")
.header("User-Agent", Settings.getUseNativeUa() ? NATIVE_USER_AGENT : PC_USER_AGENT)
.build();
return chain.proceed(request);
}
}
private static class LoggingInterceptor implements Interceptor {
@Override
public Response intercept(final Interceptor.Chain chain) throws IOException {
final Request request = chain.request();
final String reqLogStr = request.method() + " " + hidePassword(request.url().toString());
Log.d(reqLogStr);
final long before = System.currentTimeMillis();
try {
final Response response = chain.proceed(request);
final String protocol = " (" + response.protocol() + ')';
final String redirect = request.url().equals(response.request().url()) ? "" : " (=> " + response.request().url() + ")";
if (response.isSuccessful()) {
Log.d(response.code() + formatTimeSpan(before) + reqLogStr + protocol + redirect);
} else {
Log.d(response.code() + " [" + response.message() + "]" + formatTimeSpan(before) + reqLogStr + protocol);
}
return response;
} catch (final IOException e) {
Log.w("Failure" + formatTimeSpan(before) + reqLogStr + " (" + e + ")");
throw e;
}
}
private static String hidePassword(final String message) {
return PATTERN_PASSWORD.matcher(message).replaceAll("password=***");
}
private static String formatTimeSpan(final long before) {
// don't use String.format in a pure logging routine, it has very bad performance
return " (" + (System.currentTimeMillis() - before) + " ms) ";
}
}
@Nullable
private static Parameters cacheHeaders(@Nullable final File cacheFile) {
if (cacheFile == null || !cacheFile.exists()) {
return null;
}
final String etag = FileUtils.getSavedHeader(cacheFile, FileUtils.HEADER_ETAG);
if (etag != null) {
// The ETag is a more robust check than a timestamp. If we have an ETag, it is enough
// to identify the right version of the resource.
return new Parameters("If-None-Match", etag);
}
final String lastModified = FileUtils.getSavedHeader(cacheFile, FileUtils.HEADER_LAST_MODIFIED);
if (lastModified != null) {
return new Parameters("If-Modified-Since", lastModified);
}
return null;
}
/**
* GET HTTP request
*
* @param uri
* the URI to request
* @param params
* the parameters to add to the GET request
* @param cacheFile
* the name of the file storing the cached resource, or null not to use one
* @return a single with the HTTP response, or an IOException
*/
@NonNull
public static Single<Response> getRequest(final String uri, @Nullable final Parameters params, @Nullable final File cacheFile) {
return request("GET", uri, params, null, cacheFile);
}
/**
* GET HTTP request
*
* @param uri
* the URI to request
* @param params
* the parameters to add to the GET request
* @return a single with the HTTP response, or an IOException
*/
public static Single<Response> getRequest(final String uri, @Nullable final Parameters params) {
return request("GET", uri, params, null, null);
}
/**
* GET HTTP request
*
* @param uri
* the URI to request
* @param params
* the parameters to add to the GET request
* @param headers
* the headers to add to the GET request
* @return a single with the HTTP response, or an IOException
*/
@NonNull
public static Single<Response> getRequest(final String uri, @Nullable final Parameters params, @Nullable final Parameters headers) {
return request("GET", uri, params, headers, null);
}
/**
* GET HTTP request
*
* @param uri
* the URI to request
* @return a single with the HTTP response, or an IOException
*/
public static Single<Response> getRequest(final String uri) {
return request("GET", uri, null, null, null);
}
/**
* Get the result of a GET HTTP request returning a JSON body.
*
* @param uri the base URI of the GET HTTP request
* @param params the query parameters, or {@code null} if there are none
* @return a Single with a JSON object if the request was successful and the body could be decoded, an error otherwise
*/
@NonNull
public static Single<ObjectNode> requestJSON(final String uri, @Nullable final Parameters params) {
return request("GET", uri, params, new Parameters("Accept", "application/json, text/javascript, */*; q=0.01"), null)
.flatMap(getResponseData)
.flatMap(stringToJson);
}
/**
* Get the response stream. The stream must be closed after use.
*
* @param response the response
* @return the body stream
*/
@Nullable
public static InputStream getResponseStream(final Single<Response> response) {
try {
return response.flatMap(withSuccess).blockingGet().body().byteStream();
} catch (final Exception ignored) {
return null;
}
}
@Nullable
private static String getResponseDataNoError(final Response response, final boolean replaceWhitespace) {
try {
final String data = response.body().string();
return replaceWhitespace ? TextUtils.replaceWhitespace(data) : data;
} catch (final Exception e) {
Log.e("getResponseData", e);
return null;
} finally {
response.close();
}
}
/**
* Get the body of a HTTP response.
*
* {@link TextUtils#replaceWhitespace(String)} will be called on the result
*
* @param response a HTTP response
* @return the body if the response comes from a successful HTTP request, {@code null} otherwise
*/
@Nullable
public static String getResponseData(final Response response) {
return getResponseData(response, true);
}
/**
* Get the body of a HTTP response.
*
* @param response a HTTP response
* @param replaceWhitespace {@code true} if {@link TextUtils#replaceWhitespace(String)}
* should be called on the body
* @return the body if the response comes from a successful HTTP request, {@code null} otherwise
*/
@Nullable
public static String getResponseData(final Response response, final boolean replaceWhitespace) {
return response.isSuccessful() ? getResponseDataNoError(response, replaceWhitespace) : null;
}
/**
* Get the body of a HTTP response.
*
* @param response a HTTP response
* @return the body with whitespace replaced if the response comes from a successful HTTP request, {@code null} otherwise
*/
@Nullable
public static String getResponseData(final Single<Response> response) {
try {
return response.flatMap(getResponseDataReplaceWhitespace).blockingGet();
} catch (final Exception ignored) {
return null;
}
}
/**
* Get the HTML document corresponding to the body of a HTTP response.
*
* @param response a HTTP response
* @return a Single containing a document corresponding to the body if the response comes from a
* successful HTTP request with Content-Type "text/html", or containing an IOException otherwise.
*/
public static Single<Document> getResponseDocument(final Single<Response> response) {
return response.flatMap(new Function<Response, Single<Document>>() {
@Override
public Single<Document> apply(final Response resp) {
try {
final String uri = resp.request().url().toString();
if (resp.isSuccessful()) {
final MediaType mediaType = MediaType.parse(resp.header("content-type", ""));
if (mediaType == null || !StringUtils.equals(mediaType.type(), "text") || !StringUtils.equals(mediaType.subtype(), "html")) {
throw new IOException("unable to parse non HTML page with media type " + mediaType + " for " + uri);
}
final InputStream inputStream = resp.body().byteStream();
try {
return Single.just(Jsoup.parse(inputStream, null, uri));
} finally {
IOUtils.closeQuietly(inputStream);
resp.close();
}
}
throw new IOException("unsuccessful request " + uri);
} catch (final Throwable t) {
return Single.error(t);
}
}
});
}
/**
* Get the body of a HTTP response.
*
* @param response a HTTP response
* @param replaceWhitespace {@code true} if {@link TextUtils#replaceWhitespace(String)}
* should be called on the body
* @return the body if the response comes from a successful HTTP request, {@code null} otherwise
*/
@Nullable
public static String getResponseData(final Single<Response> response, final boolean replaceWhitespace) {
try {
return response.flatMap(replaceWhitespace ? getResponseDataReplaceWhitespace : getResponseData).blockingGet();
} catch (final Exception ignored) {
return null;
}
}
public static final Function<Response, Single<String>> getResponseData = new Function<Response, Single<String>>() {
@Override
public Single<String> apply(final Response response) {
if (response.isSuccessful()) {
try {
return Single.just(response.body().string());
} catch (final IOException e) {
return Single.error(e);
} finally {
response.close();
}
}
return Single.error(new IOException("request was not successful"));
}
};
/**
* Filter only successful responses for use with flatMap.
*/
public static final Function<Response, Single<Response>> withSuccess = new Function<Response, Single<Response>>() {
@Override
public Single<Response> apply(final Response response) {
return response.isSuccessful() ? Single.just(response) : Single.<Response>error(new IOException("unsuccessful response: " + response));
}
};
/**
* Wait until a request has completed and check its response status. An exception will be thrown if the
* request does not complete successfully.success status.
*
* @param response the response to check
*/
public static void completeWithSuccess(final Single<Response> response) {
Completable.fromSingle(response.flatMap(withSuccess)).blockingAwait();
}
public static final Function<Response, Single<String>> getResponseDataReplaceWhitespace = new Function<Response, Single<String>>() {
@Override
public Single<String> apply(final Response response) throws Exception {
return getResponseData.apply(response).map(new Function<String, String>() {
@Override
public String apply(final String s) {
return TextUtils.replaceWhitespace(s);
}
});
}
};
@Nullable
public static String rfc3986URLEncode(final String text) {
final String encoded = encode(text);
return encoded != null ? StringUtils.replace(encoded.replace("+", "%20"), "%7E", "~") : null;
}
@Nullable
public static String decode(final String text) {
try {
return URLDecoder.decode(text, CharEncoding.UTF_8);
} catch (final UnsupportedEncodingException e) {
Log.e("Network.decode", e);
}
return null;
}
@Nullable
public static String encode(final String text) {
try {
return URLEncoder.encode(text, CharEncoding.UTF_8);
} catch (final UnsupportedEncodingException e) {
Log.e("Network.encode", e);
}
return null;
}
/**
* Checks if the device has network connection.
*
* @return {@code true} if the device is connected to the network.
*/
public static boolean isConnected() {
if (connectivityManager == null) {
// Concurrent assignment would not hurt as this request is idempotent
connectivityManager = (ConnectivityManager) CgeoApplication.getInstance().getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE);
}
final NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
return activeNetworkInfo != null && activeNetworkInfo.isConnected();
}
}