com.murrayc.galaxyzoo.app.provider.client.ZooniverseClient.java Source code

Java tutorial

Introduction

Here is the source code for com.murrayc.galaxyzoo.app.provider.client.ZooniverseClient.java

Source

/*
 * Copyright (C) 2014 Murray Cumming
 *
 * This file is part of android-galaxyzoo
 *
 * android-galaxyzoo is free software: you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as published by the
 * Free Software Foundation, either version 3 of the License, or (at your
 * option) any later version.
 *
 * android-galaxyzoo is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
 * for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with android-galaxyzoo.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.murrayc.galaxyzoo.app.provider.client;

import android.content.Context;
import android.util.Base64;

import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.toolbox.RequestFuture;
import com.android.volley.toolbox.Volley;
import com.murrayc.galaxyzoo.app.Log;
import com.murrayc.galaxyzoo.app.LoginUtils;
import com.murrayc.galaxyzoo.app.Utils;
import com.murrayc.galaxyzoo.app.provider.HttpUtils;

import org.apache.http.NameValuePair;
import org.apache.http.message.BasicNameValuePair;

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

/**
 * Created by murrayc on 10/10/14.
 */
public class ZooniverseClient {
    private final Context mContext;
    private final String mServerBaseUri;

    //This is an attempt to reduce the amount of Network and Disk IO
    //that the system does, because even when using a Thread (with Thread.MIN_PRIORITY) instead of
    //AsyncTask, the UI is non responsive during this work.
    //For instance, buttons appear to be pressed, but their clicked listeners are not called.
    private static final int MAXIMUM_DOWNLOAD_ITEMS = 10;
    private RequestQueue mQueue = null;

    public ZooniverseClient(final Context context, final String serverBaseUri) {
        mContext = context;
        mServerBaseUri = serverBaseUri;

        //The MockContext used by ProviderTestCase2 (in our unit tests),
        //doesn't implement getPackageName(), but Volley.newRequestQueue()
        //calls it. Replacing that MockContext in ProviderTestCase2 is rather difficult,
        //so this is a quick workaround until we really need to use volley from our ContentProvider test:
        try {
            context.getPackageName();

            mQueue = Volley.newRequestQueue(context);
        } catch (final UnsupportedOperationException ex) {
            Log.info("ZooniverseClient: Not creating mQueue because context.getPackageName() would fail.");
            mQueue = null; //Just for the unit test.
        }
    }

    private static HttpURLConnection openConnection(final String strURL) throws IOException {
        final URL url = new URL(strURL);

        final HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        setConnectionUserAgent(conn);

        //Set a reasonable timeout.
        //Otherwise there is not timeout so we might never know if it fails,
        //so never have the chance to try again.
        conn.setConnectTimeout(HttpUtils.TIMEOUT_MILLIS);
        conn.setReadTimeout(HttpUtils.TIMEOUT_MILLIS);

        return conn;
    }

    private static void setConnectionUserAgent(final HttpURLConnection connection) {
        connection.setRequestProperty(HttpUtils.HTTP_REQUEST_HEADER_PARAM_USER_AGENT, HttpUtils.USER_AGENT_MURRAYC);
    }

    private String getQueryMoreItemsUri() {
        /**
         * REST uri for querying items.
         * Like, the Galaxy-Zoo website's code, this hard-codes the Group ID for the Sloan survey:
         */
        return mServerBaseUri + "groups/50251c3b516bcb6ecb000002/subjects?limit="; //Should have a number, such as 5, appended.
    }

    private String getPostUploadUri() {
        return mServerBaseUri + "workflows/50251c3b516bcb6ecb000002/classifications";
    }

    private String getLoginUri() {
        return mServerBaseUri + "login";
    }

    public LoginUtils.LoginResult loginSync(final String username, final String password) throws LoginException {
        HttpUtils.throwIfNoNetwork(getContext(), false); //Ignore the wifi-only setting because this will be when the user is explicitly requesting a login.

        final HttpURLConnection conn;
        try {
            conn = openConnection(getLoginUri());
        } catch (final IOException e) {
            Log.error("loginSync(): Could not open connection", e);

            throw new LoginException("Could not open connection.", e);
        }

        final List<NameValuePair> nameValuePairs = new ArrayList<>();
        nameValuePairs.add(new BasicNameValuePair("username", username));
        nameValuePairs.add(new BasicNameValuePair("password", password));

        try {
            conn.setRequestMethod("POST");
            conn.setDoOutput(true);
            conn.setDoInput(true);

            writeParamsToHttpPost(conn, nameValuePairs);

            conn.connect();
        } catch (final IOException e) {
            Log.error("loginSync(): exception during HTTP connection", e);

            throw new LoginException("Could not create POST.", e);
        }

        //Get the response:
        InputStream in = null;
        try {
            in = conn.getInputStream();
            if (conn.getResponseCode() != HttpURLConnection.HTTP_OK) {
                Log.error("loginSync(): response code: " + conn.getResponseCode());
                return null;
            }

            return LoginUtils.parseLoginResponseContent(in);
        } catch (final IOException e) {
            Log.error("loginSync(): exception during HTTP connection", e);

            throw new LoginException("Could not parse response.", e);
        } finally {
            if (in != null) {
                try {
                    in.close();
                } catch (final IOException e) {
                    Log.error("loginSync(): exception while closing in", e);
                }
            }
        }
    }

    private static void writeParamsToHttpPost(final HttpURLConnection conn,
            final List<NameValuePair> nameValuePairs) throws IOException {
        OutputStream out = null;
        try {
            out = conn.getOutputStream();

            BufferedWriter writer = null;
            try {
                writer = new BufferedWriter(new OutputStreamWriter(out, Utils.STRING_ENCODING));
                writer.write(getPostDataBytes(nameValuePairs));
                writer.flush();
            } finally {
                if (writer != null) {
                    writer.close();
                }
            }
        } finally {
            if (out != null) {
                try {
                    out.close();
                } catch (final IOException e) {
                    Log.error("writeParamsToHttpPost(): Exception while closing out", e);
                }
            }
        }
    }

    private static String getPostDataBytes(final List<NameValuePair> nameValuePairs) {
        final StringBuilder result = new StringBuilder();
        boolean first = true;

        for (final NameValuePair pair : nameValuePairs) {
            if (first) {
                first = false;
            } else {
                result.append("&");
            }

            try {
                result.append(URLEncoder.encode(pair.getName(), Utils.STRING_ENCODING));
                result.append("=");
                result.append(URLEncoder.encode(pair.getValue(), Utils.STRING_ENCODING));
            } catch (final UnsupportedEncodingException e) {
                //This is incredibly unlikely for the UTF-8 encoding,
                //so we just log it instead of trying to recover from it.
                Log.error("getPostDataBytes(): Exception", e);
                return null;
            }
        }

        return result.toString();
    }

    private String generateAuthorizationHeader(final String authName, final String authApiKey) {
        //See the similar code in Zooniverse's user.coffee source code:
        //https://github.com/zooniverse/Zooniverse/blob/master/src/models/user.coffee#L49
        final String str = authName + ":" + authApiKey;
        byte[] asBytes = null;
        try {
            asBytes = str.getBytes(Utils.STRING_ENCODING);
        } catch (final UnsupportedEncodingException e) {
            //This is incredibly unlikely for the UTF-8 encoding,
            //so we just log it instead of trying to recover from it.
            Log.error("generateAuthorizationHeader(): String.getBytes() failed", e);
            return null;
        }

        return "Basic " + Base64.encodeToString(asBytes, Base64.DEFAULT);
    }

    /** This will not always provide as many items as requested.
     *
     * @param count
     * @return
     */
    public List<Subject> requestMoreItemsSync(int count) throws RequestMoreItemsException {
        throwIfNoNetwork();

        //Avoid suddenly doing too much network and disk IO
        //as we download too many images.
        if (count > MAXIMUM_DOWNLOAD_ITEMS) {
            count = MAXIMUM_DOWNLOAD_ITEMS;
        }

        // Request a string response from the provided URL.
        // TODO: Use HttpUrlConnection directly instead of trying to use Volley synchronously?
        final RequestFuture<String> futureListener = RequestFuture.newFuture();
        requestMoreItemsAsync(count, futureListener, futureListener);

        String response = null;
        try {
            //Note: If we don't provider the RequestFuture as the errorListener too,
            //then this won't return until after the timeout, even if an error happen earlier.
            response = futureListener.get(HttpUtils.TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
        } catch (final InterruptedException | ExecutionException e) {
            Log.error("requestMoreItemsSync(): Exception from request.", e);
            throw new RequestMoreItemsException("Exception from request.", e);
        } catch (final TimeoutException e) {
            Log.error("requestMoreItemsSync(): Timeout Exception from request.", e);
            throw new RequestMoreItemsException("Timeout Exception from request.", e);
        }

        //Presumably this happens when onErrorResponse() is called.
        if (response == null) {
            throw new RequestMoreItemsException("response is null.");
        }

        return MoreItemsJsonParser.parseMoreItemsResponseContent(response);
    }

    public void requestMoreItemsAsync(final int count, final Response.Listener<String> listener,
            final Response.ErrorListener errorListener) {
        throwIfNoNetwork();

        Log.info("requestMoreItemsAsync(): count=" + count);

        final Request request = new ZooStringRequest(Request.Method.GET, getQueryUri(count), listener,
                errorListener);

        //Identical requests for more items should get different results each time.
        request.setShouldCache(false);

        // Add the request to the RequestQueue.
        mQueue.add(request);
    }

    private String getQueryUri(final int count) {
        return getQueryMoreItemsUri() + Integer.toString(count); //TODO: Is Integer.toString() locale-dependent?
    }

    private void throwIfNoNetwork() {
        HttpUtils.throwIfNoNetwork(getContext());
    }

    private Context getContext() {
        return mContext;
    }

    public boolean uploadClassificationSync(final String authName, final String authApiKey,
            final List<NameValuePair> nameValuePairs) throws UploadException {
        throwIfNoNetwork();

        final HttpURLConnection conn;
        try {
            conn = openConnection(getPostUploadUri());
        } catch (final IOException e) {
            Log.error("uploadClassificationSync(): Could not open connection", e);

            throw new UploadException("Could not open connection.", e);
        }

        try {
            conn.setRequestMethod("POST");
            conn.setDoOutput(true);
            conn.setDoInput(true);
        } catch (final IOException e) {
            Log.error("uploadClassificationSync: exception during HTTP connection", e);

            throw new UploadException("exception during HTTP connection", e);
        }

        //Add the authentication details to the headers;
        //Be careful: The server still returns OK_CREATED even if we provide the wrong Authorization here.
        //There doesn't seem to be any way to know if it's correct other than checking your recent
        //classifications in your profile.
        //See https://github.com/zooniverse/Galaxy-Zoo/issues/184
        if ((authName != null) && (authApiKey != null)) {
            conn.setRequestProperty("Authorization", generateAuthorizationHeader(authName, authApiKey));
        }

        try {
            writeParamsToHttpPost(conn, nameValuePairs);
        } catch (final IOException e) {
            Log.error("uploadClassificationSync: writeParamsToHttpPost() failed", e);

            throw new UploadException("writeParamsToHttpPost() failed.", e);
        }

        //TODO: Is this necessary? conn.connect();

        //Get the response:
        InputStream in = null;
        try {
            //Note: At least with okhttp.mockwebserver, getInputStream() will throw an IOException (file
            //not found) if the response code was an error, such as HTTP_UNAUTHORIZED.
            in = conn.getInputStream();
            final int responseCode = conn.getResponseCode();
            if (responseCode != HttpURLConnection.HTTP_CREATED) {
                Log.error("uploadClassificationSync: Did not receive the 201 Created status code: "
                        + conn.getResponseCode());
                return false;
            }

            return true;
        } catch (final IOException e) {
            Log.error("uploadClassificationSync: exception during HTTP connection", e);

            throw new UploadException("exception during HTTP connection", e);
        } finally {
            if (in != null) {
                try {
                    in.close();
                } catch (final IOException e) {
                    Log.error("uploadClassificationSync: exception while closing in", e);
                }
            }
        }
    }

    public RequestQueue getRequestQueue() {
        return mQueue;
    }

    /**
     * This class is meant to be immutable.
     * It only returns references to immutable Strings.
     */
    public static final class Subject {
        private final String mSubjectId;
        private final String mZooniverseId;
        private final String mLocationStandard;
        private final String mLocationThumbnail;
        private final String mLocationInverted;

        public Subject(final String subjectId, final String zooniverseId, final String locationStandard,
                final String locationThumbnail, final String locationInverted) {
            this.mSubjectId = subjectId;
            this.mZooniverseId = zooniverseId;
            this.mLocationStandard = locationStandard;
            this.mLocationThumbnail = locationThumbnail;
            this.mLocationInverted = locationInverted;
        }

        public String getSubjectId() {
            return mSubjectId;
        }

        public String getZooniverseId() {
            return mZooniverseId;
        }

        public String getLocationStandard() {
            return mLocationStandard;
        }

        public String getLocationThumbnail() {
            return mLocationThumbnail;
        }

        public String getLocationInverted() {
            return mLocationInverted;
        }

    }

    public static class LoginException extends Exception {
        LoginException(final String detail, final Exception cause) {
            super(detail, cause);
        }
    }

    public static class UploadException extends Exception {
        UploadException(final String detail, final Exception cause) {
            super(detail, cause);
        }
    }

    public static class RequestMoreItemsException extends Exception {
        RequestMoreItemsException(final String detail, final Exception cause) {
            super(detail, cause);
        }

        public RequestMoreItemsException(final String detail) {
            super(detail);
        }
    }
}