From d9509474b0b29a141d67f7ab3314babaff9d9be9 Mon Sep 17 00:00:00 2001 From: jendib Date: Thu, 21 Jan 2016 23:48:40 +0100 Subject: [PATCH] #57: Android: Migrate GET /document/list to OkHttp --- docs-android/app/app.iml | 1 + docs-android/app/build.gradle | 1 + .../docs/fragment/DocListFragment.java | 9 +- .../sismics/docs/listener/HttpCallback.java | 78 ++++++ .../sismics/docs/resource/BaseResource.java | 102 +++++++- .../docs/resource/DocumentResource.java | 30 ++- .../cookie/PersistentCookieStore.java | 229 ++++++++++++++++++ .../cookie/SerializableHttpCookie.java | 55 +++++ 8 files changed, 484 insertions(+), 21 deletions(-) create mode 100644 docs-android/app/src/main/java/com/sismics/docs/listener/HttpCallback.java create mode 100644 docs-android/app/src/main/java/com/sismics/docs/resource/cookie/PersistentCookieStore.java create mode 100644 docs-android/app/src/main/java/com/sismics/docs/resource/cookie/SerializableHttpCookie.java diff --git a/docs-android/app/app.iml b/docs-android/app/app.iml index 2fa09bbf..1a8f311d 100644 --- a/docs-android/app/app.iml +++ b/docs-android/app/app.iml @@ -111,6 +111,7 @@ + diff --git a/docs-android/app/build.gradle b/docs-android/app/build.gradle index b886eea0..bddc240f 100644 --- a/docs-android/app/build.gradle +++ b/docs-android/app/build.gradle @@ -58,5 +58,6 @@ dependencies { compile 'com.shamanland:fab:0.0.6' compile 'com.squareup.picasso:picasso:2.5.2' compile 'com.squareup.okhttp3:okhttp:3.0.1' + compile "com.squareup.okhttp3:okhttp-urlconnection:3.0.1" compile 'com.jakewharton.picasso:picasso2-okhttp3-downloader:1.0.2' } diff --git a/docs-android/app/src/main/java/com/sismics/docs/fragment/DocListFragment.java b/docs-android/app/src/main/java/com/sismics/docs/fragment/DocListFragment.java index c647c372..db810b88 100644 --- a/docs-android/app/src/main/java/com/sismics/docs/fragment/DocListFragment.java +++ b/docs-android/app/src/main/java/com/sismics/docs/fragment/DocListFragment.java @@ -21,7 +21,7 @@ import com.sismics.docs.event.DocumentAddEvent; import com.sismics.docs.event.DocumentDeleteEvent; import com.sismics.docs.event.DocumentEditEvent; import com.sismics.docs.event.SearchEvent; -import com.sismics.docs.listener.JsonHttpResponseHandler; +import com.sismics.docs.listener.HttpCallback; import com.sismics.docs.listener.RecyclerItemClickListener; import com.sismics.docs.resource.DocumentResource; import com.sismics.docs.ui.view.DividerItemDecoration; @@ -29,7 +29,6 @@ import com.sismics.docs.ui.view.EmptyRecyclerView; import org.json.JSONObject; -import cz.msebera.android.httpclient.Header; import de.greenrobot.event.EventBus; /** @@ -218,16 +217,16 @@ public class DocListFragment extends Fragment { recyclerView.setEmptyView(progressBar); - DocumentResource.list(getActivity(), reset ? 0 : adapter.getItemCount(), query, new JsonHttpResponseHandler() { + DocumentResource.list(getActivity(), reset ? 0 : adapter.getItemCount(), query, new HttpCallback() { @Override - public void onSuccess(int statusCode, Header[] headers, JSONObject response) { + public void onSuccess(JSONObject response) { adapter.addDocuments(response.optJSONArray("documents")); documentsEmptyView.setText(R.string.no_documents); recyclerView.setEmptyView(documentsEmptyView); } @Override - public void onAllFailure(int statusCode, Header[] headers, byte[] responseBytes, Throwable throwable) { + public void onFailure(JSONObject response, Exception e) { documentsEmptyView.setText(R.string.error_loading_documents); recyclerView.setEmptyView(documentsEmptyView); diff --git a/docs-android/app/src/main/java/com/sismics/docs/listener/HttpCallback.java b/docs-android/app/src/main/java/com/sismics/docs/listener/HttpCallback.java new file mode 100644 index 00000000..17802174 --- /dev/null +++ b/docs-android/app/src/main/java/com/sismics/docs/listener/HttpCallback.java @@ -0,0 +1,78 @@ +package com.sismics.docs.listener; + +import android.os.Handler; +import android.os.Looper; + +import org.json.JSONObject; + +import java.io.IOException; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.Response; + +/** + * An HTTP callback. + * + * @author bgamard. + */ +public class HttpCallback { + public void onSuccess(JSONObject json) { + // Implement me + } + + public void onFailure(JSONObject json, Exception e) { + // Implement me + } + + public void onFinish() { + // Implement me + } + + /** + * Build an OkHttp Callback from a HttpCallback. + * + * @param httpCallback HttpCallback + * @return OkHttp Callback + */ + public static Callback buildOkHttpCallback(final HttpCallback httpCallback) { + return new Callback() { + @Override + public void onResponse(final Call call, final Response response) throws IOException { + final String body = response.body().string(); + + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + if (response.isSuccessful()) { + try { + httpCallback.onSuccess(new JSONObject(body)); + } catch (Exception e) { + httpCallback.onFailure(null, e); + } + } else { + try { + httpCallback.onFailure(new JSONObject(body), null); + } catch (Exception e) { + httpCallback.onFailure(null, e); + } + } + + httpCallback.onFinish(); + } + }); + } + + @Override + public void onFailure(final Call call, final IOException e) { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + httpCallback.onFailure(null, e); + httpCallback.onFinish(); + } + }); + } + }; + } +} diff --git a/docs-android/app/src/main/java/com/sismics/docs/resource/BaseResource.java b/docs-android/app/src/main/java/com/sismics/docs/resource/BaseResource.java index ac3b3fde..985c69b3 100644 --- a/docs-android/app/src/main/java/com/sismics/docs/resource/BaseResource.java +++ b/docs-android/app/src/main/java/com/sismics/docs/resource/BaseResource.java @@ -9,7 +9,11 @@ import com.sismics.docs.util.ApplicationUtil; import com.sismics.docs.util.PreferenceUtil; import java.io.IOException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.HttpCookie; import java.net.Socket; +import java.net.URI; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; @@ -18,12 +22,18 @@ import java.security.UnrecoverableKeyException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Locale; +import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import cz.msebera.android.httpclient.conn.ssl.SSLSocketFactory; +import okhttp3.Interceptor; +import okhttp3.JavaNetCookieJar; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; /** * Base class for API access. @@ -43,10 +53,15 @@ public class BaseResource { protected static String ACCEPT_LANGUAGE = null; /** - * HTTP client. + * Async HTTP client. */ protected static AsyncHttpClient client = new AsyncHttpClient(); - + + /** + * OkHttp client. + */ + protected static OkHttpClient okHttpClient = new OkHttpClient(); + static { // 20sec default timeout client.setTimeout(60000); @@ -61,6 +76,43 @@ public class BaseResource { } catch (Exception e) { // NOP } + + // OkHttp configuration + try { + // Create a trust manager that does not validate certificate chains + final TrustManager[] trustAllCerts = new TrustManager[] { + new X509TrustManager() { + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) throws CertificateException { + } + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) throws CertificateException { + } + + @Override + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return null; + } + } + }; + + // Install the all-trusting trust manager + final SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); + final javax.net.ssl.SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + + // Configure OkHttpClient + okHttpClient = okHttpClient.newBuilder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .hostnameVerifier(MySSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER) + .sslSocketFactory(sslSocketFactory) + .build(); + } catch (Exception e) { + // NOP + } } /** @@ -82,7 +134,47 @@ public class BaseResource { client.addHeader("Accept-Language", ACCEPT_LANGUAGE); } } - + + /** + * Build an OkHttpClient. + * + * @param context Context + * @return OkHttpClient + */ + protected static OkHttpClient buildOkHttpClient(final Context context) { + // Header computation + if (USER_AGENT == null) { + USER_AGENT = "Sismics Docs Android " + ApplicationUtil.getVersionName(context) + "/Android " + Build.VERSION.RELEASE + "/" + Build.MODEL; + } + + if (ACCEPT_LANGUAGE == null) { + Locale locale = Locale.getDefault(); + ACCEPT_LANGUAGE = locale.getLanguage() + "_" + locale.getCountry(); + } + + // Cookie handling + com.sismics.docs.resource.cookie.PersistentCookieStore cookieStore = new com.sismics.docs.resource.cookie.PersistentCookieStore(context); + CookieManager cookieManager = new CookieManager(cookieStore, CookiePolicy.ACCEPT_ALL); + cookieStore.add(URI.create(PreferenceUtil.getServerUrl(context)), + new HttpCookie("auth_token", PreferenceUtil.getAuthToken(context))); // TODO Remove me when async http is ditched + + // Runtime configuration + return okHttpClient.newBuilder() + .cookieJar(new JavaNetCookieJar(cookieManager)) + .addNetworkInterceptor(new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + return chain.proceed(originalRequest.newBuilder() + .header("User-Agent", USER_AGENT) + .header("Accept-Language", ACCEPT_LANGUAGE) + // TODO necessary?? .method(originalRequest.method(), originalRequest.body()) + .build()); + } + }) + .build(); + } + /** * Socket factory to allow self-signed certificates for Async HTTP Client. * @@ -106,7 +198,7 @@ public class BaseResource { } }; - sslContext.init(null, new TrustManager[] { tm }, null); + sslContext.init(null, new TrustManager[]{tm}, null); } @Override @@ -119,7 +211,7 @@ public class BaseResource { return sslContext.getSocketFactory().createSocket(); } } - + /** * Returns cleaned API URL. * diff --git a/docs-android/app/src/main/java/com/sismics/docs/resource/DocumentResource.java b/docs-android/app/src/main/java/com/sismics/docs/resource/DocumentResource.java index dc7a89f2..65c8a0ed 100644 --- a/docs-android/app/src/main/java/com/sismics/docs/resource/DocumentResource.java +++ b/docs-android/app/src/main/java/com/sismics/docs/resource/DocumentResource.java @@ -3,10 +3,14 @@ package com.sismics.docs.resource; import android.content.Context; import com.loopj.android.http.RequestParams; +import com.sismics.docs.listener.HttpCallback; import com.sismics.docs.listener.JsonHttpResponseHandler; import java.util.Set; +import okhttp3.HttpUrl; +import okhttp3.Request; + /** * Access to /document API. * @@ -19,18 +23,22 @@ public class DocumentResource extends BaseResource { * @param context Context * @param offset Offset * @param query Search query - * @param responseHandler Callback + * @param callback Callback */ - public static void list(Context context, int offset, String query, JsonHttpResponseHandler responseHandler) { - init(context); - - RequestParams params = new RequestParams(); - params.put("limit", 20); - params.put("offset", offset); - params.put("sort_column", 3); - params.put("asc", false); - params.put("search", query); - client.get(getApiUrl(context) + "/document/list", params, responseHandler); + public static void list(Context context, int offset, String query, HttpCallback callback) { + Request request = new Request.Builder() + .url(HttpUrl.parse(getApiUrl(context) + "/document/list") + .newBuilder() + .addQueryParameter("limit", "20") + .addQueryParameter("offset", Integer.toString(offset)) + .addQueryParameter("sort_column", "3") + .addQueryParameter("asc", "false") + .addQueryParameter("search", query) + .build()) + .build(); + buildOkHttpClient(context) + .newCall(request) + .enqueue(HttpCallback.buildOkHttpCallback(callback)); } /** diff --git a/docs-android/app/src/main/java/com/sismics/docs/resource/cookie/PersistentCookieStore.java b/docs-android/app/src/main/java/com/sismics/docs/resource/cookie/PersistentCookieStore.java new file mode 100644 index 00000000..990d37ed --- /dev/null +++ b/docs-android/app/src/main/java/com/sismics/docs/resource/cookie/PersistentCookieStore.java @@ -0,0 +1,229 @@ +package com.sismics.docs.resource.cookie; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.net.CookieStore; +import java.net.HttpCookie; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A persistent cookie store which implements the Apache HttpClient CookieStore interface. + * Cookies are stored and will persist on the user's device between application sessions since they + * are serialized and stored in SharedPreferences. + */ +public class PersistentCookieStore implements CookieStore { + + private static final String LOG_TAG = "PersistentCookieStore"; + private static final String COOKIE_PREFS = "CookiePrefsFileOkHttp"; + private static final String COOKIE_NAME_PREFIX = "cookie_okhttp_"; + + private final HashMap> cookies; + private final SharedPreferences cookiePrefs; + + /** + * Construct a persistent cookie store. + * + * @param context Context to attach cookie store to + */ + public PersistentCookieStore(Context context) { + cookiePrefs = context.getSharedPreferences(COOKIE_PREFS, 0); + cookies = new HashMap<>(); + + // Load any previously stored cookies into the store + Map prefsMap = cookiePrefs.getAll(); + for (Map.Entry entry : prefsMap.entrySet()) { + if (entry.getValue() != null && !((String) entry.getValue()).startsWith(COOKIE_NAME_PREFIX)) { + String[] cookieNames = TextUtils.split((String) entry.getValue(), ","); + for (String name : cookieNames) { + String encodedCookie = cookiePrefs.getString(COOKIE_NAME_PREFIX + name, null); + if (encodedCookie != null) { + HttpCookie decodedCookie = decodeCookie(encodedCookie); + if (decodedCookie != null) { + if (!cookies.containsKey(entry.getKey())) + cookies.put(entry.getKey(), new ConcurrentHashMap()); + cookies.get(entry.getKey()).put(name, decodedCookie); + } + } + } + + } + } + } + + @Override + public void add(URI uri, HttpCookie cookie) { + String name = getCookieToken(uri, cookie); + + // Save cookie into local store, or remove if expired + if (!cookie.hasExpired()) { + if (!cookies.containsKey(uri.getHost())) + cookies.put(uri.getHost(), new ConcurrentHashMap()); + cookies.get(uri.getHost()).put(name, cookie); + } else { + if (cookies.containsKey(uri.toString())) + cookies.get(uri.getHost()).remove(name); + } + + // Save cookie into persistent store + SharedPreferences.Editor prefsWriter = cookiePrefs.edit(); + prefsWriter.putString(uri.getHost(), TextUtils.join(",", cookies.get(uri.getHost()).keySet())); + prefsWriter.putString(COOKIE_NAME_PREFIX + name, encodeCookie(new SerializableHttpCookie(cookie))); + prefsWriter.apply(); + } + + protected String getCookieToken(URI uri, HttpCookie cookie) { + return cookie.getName() + cookie.getDomain(); + } + + @Override + public List get(URI uri) { + ArrayList ret = new ArrayList<>(); + if (cookies.containsKey(uri.getHost())) + ret.addAll(cookies.get(uri.getHost()).values()); + return ret; + } + + @Override + public boolean removeAll() { + SharedPreferences.Editor prefsWriter = cookiePrefs.edit(); + prefsWriter.clear(); + prefsWriter.apply(); + cookies.clear(); + return true; + } + + + @Override + public boolean remove(URI uri, HttpCookie cookie) { + String name = getCookieToken(uri, cookie); + + if (cookies.containsKey(uri.getHost()) && cookies.get(uri.getHost()).containsKey(name)) { + cookies.get(uri.getHost()).remove(name); + + SharedPreferences.Editor prefsWriter = cookiePrefs.edit(); + if (cookiePrefs.contains(COOKIE_NAME_PREFIX + name)) { + prefsWriter.remove(COOKIE_NAME_PREFIX + name); + } + prefsWriter.putString(uri.getHost(), TextUtils.join(",", cookies.get(uri.getHost()).keySet())); + prefsWriter.apply(); + + return true; + } else { + return false; + } + } + + @Override + public List getCookies() { + ArrayList ret = new ArrayList<>(); + for (String key : cookies.keySet()) + ret.addAll(cookies.get(key).values()); + + return ret; + } + + @Override + public List getURIs() { + ArrayList ret = new ArrayList<>(); + for (String key : cookies.keySet()) + try { + ret.add(new URI(key)); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + + return ret; + } + + /** + * Serializes Cookie object into String + * + * @param cookie cookie to be encoded, can be null + * @return cookie encoded as String + */ + protected String encodeCookie(SerializableHttpCookie cookie) { + if (cookie == null) + return null; + ByteArrayOutputStream os = new ByteArrayOutputStream(); + try { + ObjectOutputStream outputStream = new ObjectOutputStream(os); + outputStream.writeObject(cookie); + } catch (IOException e) { + Log.d(LOG_TAG, "IOException in encodeCookie", e); + return null; + } + + return byteArrayToHexString(os.toByteArray()); + } + + /** + * Returns cookie decoded from cookie string + * + * @param cookieString string of cookie as returned from http request + * @return decoded cookie or null if exception occured + */ + protected HttpCookie decodeCookie(String cookieString) { + byte[] bytes = hexStringToByteArray(cookieString); + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); + HttpCookie cookie = null; + try { + ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream); + cookie = ((SerializableHttpCookie) objectInputStream.readObject()).getCookie(); + } catch (IOException e) { + Log.d(LOG_TAG, "IOException in decodeCookie", e); + } catch (ClassNotFoundException e) { + Log.d(LOG_TAG, "ClassNotFoundException in decodeCookie", e); + } + + return cookie; + } + + /** + * Using some super basic byte array <-> hex conversions so we don't have to rely on any + * large Base64 libraries. Can be overridden if you like! + * + * @param bytes byte array to be converted + * @return string containing hex values + */ + protected String byteArrayToHexString(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte element : bytes) { + int v = element & 0xff; + if (v < 16) { + sb.append('0'); + } + sb.append(Integer.toHexString(v)); + } + return sb.toString().toUpperCase(Locale.US); + } + + /** + * Converts hex values from strings to byte arra + * + * @param hexString string of hex-encoded values + * @return decoded byte array + */ + protected byte[] hexStringToByteArray(String hexString) { + int len = hexString.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(hexString.charAt(i), 16) << 4) + Character.digit(hexString.charAt(i + 1), 16)); + } + return data; + } +} \ No newline at end of file diff --git a/docs-android/app/src/main/java/com/sismics/docs/resource/cookie/SerializableHttpCookie.java b/docs-android/app/src/main/java/com/sismics/docs/resource/cookie/SerializableHttpCookie.java new file mode 100644 index 00000000..5991096e --- /dev/null +++ b/docs-android/app/src/main/java/com/sismics/docs/resource/cookie/SerializableHttpCookie.java @@ -0,0 +1,55 @@ +package com.sismics.docs.resource.cookie; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.net.HttpCookie; + +public class SerializableHttpCookie implements Serializable { + private static final long serialVersionUID = 6374381323722046732L; + + private transient final HttpCookie cookie; + private transient HttpCookie clientCookie; + + public SerializableHttpCookie(HttpCookie cookie) { + this.cookie = cookie; + } + + public HttpCookie getCookie() { + HttpCookie bestCookie = cookie; + if (clientCookie != null) { + bestCookie = clientCookie; + } + return bestCookie; + } + + private void writeObject(ObjectOutputStream out) throws IOException { + out.writeObject(cookie.getName()); + out.writeObject(cookie.getValue()); + out.writeObject(cookie.getComment()); + out.writeObject(cookie.getCommentURL()); + out.writeObject(cookie.getDomain()); + out.writeLong(cookie.getMaxAge()); + out.writeObject(cookie.getPath()); + out.writeObject(cookie.getPortlist()); + out.writeInt(cookie.getVersion()); + out.writeBoolean(cookie.getSecure()); + out.writeBoolean(cookie.getDiscard()); + } + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + String name = (String) in.readObject(); + String value = (String) in.readObject(); + clientCookie = new HttpCookie(name, value); + clientCookie.setComment((String) in.readObject()); + clientCookie.setCommentURL((String) in.readObject()); + clientCookie.setDomain((String) in.readObject()); + clientCookie.setMaxAge(in.readLong()); + clientCookie.setPath((String) in.readObject()); + clientCookie.setPortlist((String) in.readObject()); + clientCookie.setVersion(in.readInt()); + clientCookie.setSecure(in.readBoolean()); + clientCookie.setDiscard(in.readBoolean()); + } +} \ No newline at end of file