com.morphoss.acal.service.connector.AcalRequestor.java Source code

Java tutorial

Introduction

Here is the source code for com.morphoss.acal.service.connector.AcalRequestor.java

Source

/*
 * Copyright (C) 2011 Morphoss Ltd
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 */

package com.morphoss.acal.service.connector;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.net.InetAddress;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLProtocolException;

import org.apache.http.Header;
import org.apache.http.HeaderElement;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.conn.ConnectTimeoutException;
import org.apache.http.conn.ConnectionPoolTimeoutException;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.message.BasicHeader;
import org.apache.http.params.HttpParams;
import org.json.JSONObject;

import android.content.ContentValues;
import android.net.Uri;
import android.util.Log;

import com.morphoss.acal.AcalDebug;
import com.morphoss.acal.Constants;
import com.morphoss.acal.StaticHelpers;
import com.morphoss.acal.activity.serverconfig.AuthenticationFailure;
import com.morphoss.acal.providers.Servers;
import com.morphoss.acal.xml.DavNode;
import com.morphoss.acal.xml.DavParserFactory;

public class AcalRequestor {

    final private static String TAG = "AcalRequestor";

    private static final int LONG_LINE_WRAP_FOR_DEBUG = 500;

    private boolean initialised = false;

    // Basic URI components
    private String hostName = null;
    private String path = null;
    private String protocol = null;
    private int port = 0;
    private String method = "PROPFIND";

    // Authentication crap.
    private boolean authRequired = false;
    private int authType = Servers.AUTH_NONE;
    //   private Header wwwAuthenticate = null;
    private String authRealm = null;
    private String nonce = null;
    private String opaque = null;
    private String cnonce = null;
    private String qop = null;
    private int authNC = 0;
    private String algorithm = null;

    private String username = null;
    private String password = null;

    private HttpParams httpParams;
    private HttpClient httpClient;
    private ThreadSafeClientConnManager connManager;
    private Header responseHeaders[];
    private int statusCode = -1;
    private int connectionTimeOut = 30000;
    private int socketTimeOut = 60000;
    private final int redirectLimit = 5;
    private int redirectCount = 0;

    private DavRequest request = null;
    private HttpResponse response = null;

    private boolean debugThisRequest = false;

    public final static String PROTOCOL_HTTP = "http";
    public final static String PROTOCOL_HTTPS = "https";

    /**
     * Construct an uninitialised AcalRequestor.  After calling this you will need to
     * initialise things by either calling setFromServer() or interpretUriString() before
     * you will be able to make a request.
     */
    public AcalRequestor() {
        debugThisRequest = Constants.debugDavCommunication;
    }

    public void enableDebugging() {
        debugThisRequest = true;
    }

    /**
     * Construct a new contentvalues from these path components.
     * @param hostIn
     * @param proto
     * @param portIn
     * @param pathIn
     * @param user
     * @param pass
     */
    public AcalRequestor(String hostIn, Integer proto, Integer portIn, String pathIn, String user, String pass) {
        setHostName(hostIn);
        setPortProtocol(portIn, proto);
        setPath(pathIn);
        username = user;
        password = pass;

        initialise();
    }

    /**
     * Construct a new AcalRequestor from the values in a ContentValues which has been read
     * from a Server row in the database.  In this case the hostname / path will be set from
     * the 'simple' configuration values, and as such should only be used by configuration code.
     * @param cvServerData
     * @return
     */
    public static AcalRequestor fromSimpleValues(ContentValues cvServerData) {
        AcalRequestor result = new AcalRequestor();
        result.applyFromServer(cvServerData, true);
        return result;
    }

    /**
     * Construct a new AcalRequestor from the values in a ContentValues which has been read
     * from a Server row in the database.  The path will be set to the principal-path value
     * so you may need to specify a different path on the actual request(s).
     * @param cvServerData
     * @return
     */
    public static AcalRequestor fromServerValues(ContentValues cvServerData) {
        AcalRequestor result = new AcalRequestor();
        result.applyFromServer(cvServerData, false);
        return result;
    }

    /**
     * Adjust the current URI values to align with those in a ContentValues which has been read
     * from a Server row in the database.  The path will be set to the principal-path value
     * so you may need to specify a different path on the actual request(s).
     *
     * @param cvServerData
     */
    public void applyFromServer(ContentValues cvServerData) {
        applyFromServer(cvServerData, false);
    }

    /**
     * Adjust the current URI values to align with those in a ContentValues which has been read
     * from a Server row in the database.  The path will be set to the principal-path value
     * so you may need to specify a different path on the actual request(s).
     *
      * Unless you're configuring the server you should probably use AcalRequestor.fromServerValues(serverData) or applyFromServer(serverData).
        
     * @param cvServerData
     * @param simpleSetup true/false whether to use only the 'simple' values to initialise from
     */
    public void applyFromServer(ContentValues cvServerData, boolean simpleSetup) {
        if (simpleSetup) {
            protocol = null;
            hostName = null;
            port = 0;
            path = null;
            authType = Servers.AUTH_NONE;
            interpretUriString(cvServerData.getAsString(Servers.SUPPLIED_USER_URL));
        } else {
            setHostName(cvServerData.getAsString(Servers.HOSTNAME));
            setPath(cvServerData.getAsString(Servers.PRINCIPAL_PATH));

            String portString = cvServerData.getAsString(Servers.PORT);
            int tmpPort = 0;
            if (portString != null && portString.length() > 0)
                tmpPort = Integer.parseInt(portString);
            setPortProtocol(tmpPort, cvServerData.getAsInteger(Servers.USE_SSL));

            setAuthType(cvServerData.getAsInteger(Servers.AUTH_TYPE));

        }
        if (hostName == null)
            hostName = "invalid";
        if (path == null)
            path = "/";

        authRequired = (authType != Servers.AUTH_NONE);
        username = cvServerData.getAsString(Servers.USERNAME);
        password = cvServerData.getAsString(Servers.PASSWORD);

        if (!initialised)
            initialise();
    }

    private void initialise() {
        httpParams = AcalConnectionPool.defaultHttpParams(socketTimeOut, connectionTimeOut);
        connManager = AcalConnectionPool.getHttpConnectionPool();
        httpClient = new DefaultHttpClient(connManager, httpParams);

        initialised = true;
    }

    /**
     * Takes the current AcalRequestor values and applies them to the Server ContentValues
     * to be saved back in the database.  Used during the server discovery process.
     * @param cvServerData
     */
    public void applyToServerSettings(ContentValues cvServerData) {
        cvServerData.put(Servers.HOSTNAME, hostName);
        cvServerData.put(Servers.USE_SSL, (protocol.equals(PROTOCOL_HTTPS) ? 1 : 0));
        cvServerData.put(Servers.PORT, port);
        cvServerData.put(Servers.PRINCIPAL_PATH, path);
        cvServerData.put(Servers.AUTH_TYPE, authType);
    }

    /**
     * Retrieve the HTTP headers received with the most recent response.
     * @return
     */
    public Header[] getResponseHeaders() {
        return this.responseHeaders;
    }

    /**
     * Retrieve the HTTP status code of the most recent response.
     * @return
     */
    public int getStatusCode() {
        return this.statusCode;
    }

    /**
     * Interpret the URI in the string to set protocol, host, port & path for the next request.
     * If the URI only matches a path part then protocol/host/port will be unchanged. This call
     * will only allow for path parts that are anchored to the web root.  This is used internally
     * for following Location: redirects.
     *
     * This is also used to interpret the 'path' parameter to the request calls generally.
     *
     * @param uriString
     */
    public void interpretUriString(String uriString) {

        if (uriString == null)
            return;

        // Match a URL, including an ipv6 address like http://[DEAD:BEEF:CAFE:F00D::]:8008/
        final Pattern uriMatcher = Pattern.compile("^(?:(https?)://)?" + // Protocol
                "(" + // host spec
                "(?:(?:[a-z0-9-]+[.]){1,7}(?:[a-z0-9-]+))" + // Hostname or IPv4 address
                "|(?:\\[(?:[0-9a-f]{0,4}:)+(?:[0-9a-f]{0,4})?\\])" + // IPv6 address
                ")" + "(?:[:]([0-9]{2,5}))?" + // Port number
                "(/.*)?$" // Path bit.
                , Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

        final Pattern pathMatcher = Pattern.compile("^(/.*)$");

        if (Constants.LOG_VERBOSE)
            Log.println(Constants.LOGV, TAG, "Interpreting '" + uriString + "'");
        Matcher m = uriMatcher.matcher(uriString);
        if (m.matches()) {
            if (m.group(1) != null && !m.group(1).equals("")) {
                if (Constants.LOG_VERBOSE)
                    Log.println(Constants.LOGV, TAG, "Found protocol '" + m.group(1) + "'");
                protocol = m.group(1);
                if (m.group(3) == null || m.group(3).equals("")) {
                    port = (protocol.equals(PROTOCOL_HTTP) ? 80 : 443);
                }
            }
            if (m.group(2) != null) {
                if (Constants.LOG_VERBOSE)
                    Log.println(Constants.LOGV, TAG, "Found hostname '" + m.group(2) + "'");
                setHostName(m.group(2));
            }
            if (m.group(3) != null && !m.group(3).equals("")) {
                if (Constants.LOG_VERBOSE)
                    Log.println(Constants.LOGV, TAG, "Found port '" + m.group(3) + "'");
                port = Integer.parseInt(m.group(3));
                if (m.group(1) != null && (port == 0 || port == 80 || port == 443)) {
                    port = (protocol.equals(PROTOCOL_HTTP) ? 80 : 443);
                }
            }
            if (m.group(4) != null && !m.group(4).equals("")) {
                if (Constants.LOG_VERBOSE)
                    Log.println(Constants.LOGV, TAG, "Found path '" + m.group(4) + "'");
                setPath(m.group(4));
            }
            if (!initialised)
                initialise();
        } else {
            m = pathMatcher.matcher(uriString);
            if (m.find()) {
                if (Constants.LOG_VERBOSE)
                    Log.println(Constants.LOGV, TAG, "Found relative path '" + m.group(1) + "'");
                setPath(m.group(1));
            } else {
                if (Constants.LOG_DEBUG)
                    Log.println(Constants.LOGD, TAG, "Using Uri class to process redirect...");
                Uri newLocation = Uri.parse(uriString);
                if (newLocation.getHost() != null)
                    setHostName(newLocation.getHost());
                setPortProtocol(newLocation.getPort(), newLocation.getScheme());
                setPath(newLocation.getPath());
                if (Constants.LOG_VERBOSE)
                    Log.println(Constants.LOGV, TAG, "Found new location at '" + fullUrl() + "'");

            }
        }
    }

    /**
     * When a request fails with a 401 Unauthorized you can call this with the content
     * of the WWW-Authenticate header in the response and it will modify the URI so that
     * if you repeat the request the correct authentication should be used.
     *
     * If you then get a 401, and this gets called again on that same Uri, it will throw
     * an AuthenticationFailure exception rather than continue futilely.
     *
     * @param authRequestHeader
     * @throws AuthenticationFailure
     */
    public void interpretRequestedAuth(Header authRequestHeader) throws AuthenticationFailure {
        // Adjust our authentication setup so the next request will be able
        // to send the correct authentication headers...

        // WWW-Authenticate: Digest realm="DAViCal CalDAV Server", qop="auth", nonce="55a1a0c53c0f337e4675befabeff6a122b5b78de", opaque="52295deb26cc99c2dcc6614e70ed471f7a163e7a", algorithm="MD5"
        // WWW-Authenticate: Digest realm="SabreDAV",qop="auth",nonce="4f08e719a85d0",opaque="df58bdff8cf60599c939187d0b5c54de"
        // WWW-Authenticate:digest nonce="130183646896936966342199963268042751958404602087869166446", realm="Test Realm", algorithm="md5"

        if (debugThisRequest)
            Log.println(Constants.LOGV, TAG, "Interpreting '" + authRequestHeader + "'");

        String name;
        for (HeaderElement he : authRequestHeader.getElements()) {
            if (debugThisRequest)
                Log.println(Constants.LOGV, TAG, "Interpreting Element: '" + he.toString() + "' (" + he.getName()
                        + ":" + he.getValue() + ")");
            name = he.getName();

            if (name.length() > 7 && name.substring(0, 7).equalsIgnoreCase("Digest ")) {
                authType = Servers.AUTH_DIGEST;
                qop = null;
                algorithm = "md5";
                name = name.substring(7);
                if (debugThisRequest)
                    Log.println(Constants.LOGV, TAG,
                            "Found '" + getAuthTypeName(authType) + "' auth, realm: " + authRealm);
            } else if (name.length() > 6 && name.substring(0, 6).equalsIgnoreCase("Basic ")) {
                authType = Servers.AUTH_BASIC;
                name = name.substring(6);
            }

            if (name.equalsIgnoreCase("realm")) {
                authRealm = he.getValue();
            } else if (name.equalsIgnoreCase("nonce")) {
                nonce = he.getValue();
            } else if (name.equalsIgnoreCase("opaque")) {
                opaque = he.getValue();
            } else if (name.equalsIgnoreCase("qop")) {
                qop = "auth";
                if (!he.getValue().equalsIgnoreCase("auth")) {
                    // Really we should split it out to see whether 'auth' is one of their options.
                    Log.w(TAG, "Digest Auth requested qop of '" + he.getValue() + "' but we only support 'auth'");
                }
            } else if (name.equalsIgnoreCase("algorithm")) {
                if (!he.getValue().equalsIgnoreCase(algorithm)) {
                    Log.w(TAG, "Digest Auth requested algorithm of '" + he.getValue() + "' but we only support '"
                            + algorithm + "'");
                }
            } else {
                Log.w(TAG, "Digest parameter of '" + name + "=\"" + he.getValue() + "\"' is being ignored.");
            }
        }

        authRequired = true;
    }

    private String md5(String in) {
        // Create MD5 Hash
        MessageDigest digest;
        try {
            digest = java.security.MessageDigest.getInstance("MD5");
            digest.update(in.getBytes());
            return StaticHelpers.toHexString(digest.digest());
        } catch (NoSuchAlgorithmException e) {
            Log.e(TAG, e.getMessage());
            Log.println(Constants.LOGV, TAG, Log.getStackTraceString(e));
        }
        return "";
    }

    private Header basicAuthHeader() {
        String authValue = String.format("Basic %s", Base64Coder.encodeString(username + ":" + password));
        if (Constants.LOG_VERBOSE)
            Log.println(Constants.LOGV, TAG, "BasicAuthDebugging: '" + authValue + "'");
        return new BasicHeader("Authorization", authValue);
    }

    private Header digestAuthHeader() {
        String authValue;
        String A1 = md5(username + ":" + authRealm + ":" + password);
        String A2 = md5(method + ":" + path);
        cnonce = md5(AcalConnectionPool.getUserAgent());
        String printNC = String.format("%08x", ++authNC);
        String responseString = A1 + ":" + nonce + ":" + printNC + ":" + cnonce + ":auth:" + A2;
        if (debugThisRequest)
            Log.println(Constants.LOGV, TAG, "DigestDebugging: '" + responseString + "'");
        String response = md5(responseString);
        authValue = String.format(
                "Digest realm=\"%s\", username=\"%s\", nonce=\"%s\", uri=\"%s\""
                        + ", response=\"%s\", algorithm=\"%s\", cnonce=\"%s\", opaque=\"%s\", nc=\"%s\""
                        + (qop == null ? "" : ", qop=\"auth\""),
                authRealm, username, nonce, path, response, algorithm, cnonce, opaque, printNC);
        return new BasicHeader("Authorization", authValue);
    }

    private Header buildAuthHeader() throws AuthenticationFailure {
        switch (authType) {
        case Servers.AUTH_BASIC:
            return basicAuthHeader();
        case Servers.AUTH_DIGEST:
            return digestAuthHeader();
        default:
            throw new AuthenticationFailure("Unknown authentication type");
        }
    }

    /**
     * Get the current path used for the last request, or recently set.
     * @return
     */
    public String getPath() {
        return path;
    }

    /**
     * Get the current authentication type used for the last request, or recently set.
     * @return
     */
    public int getAuthType() {
        return authType;
    }

    /**
     * Set the port and protocol to the supplied values, with sanity checking.
     * @param newPort As an integer.  Numbers < 1 or > 65535 are ignored.
     * @param newProtocol As an integer where 1 is https and anything else is http
     */
    public void setPortProtocol(Integer newPort, Integer newProtocol) {
        protocol = (newProtocol == null || newProtocol == 1 ? PROTOCOL_HTTPS : PROTOCOL_HTTP);
        if (newPort == null || newPort < 1 || newPort > 65535 || newPort == 80 || newPort == 443)
            port = (protocol.equals(PROTOCOL_HTTP) ? 80 : 443);
        else
            port = newPort;
    }

    /**
     * Set the port and protocol to the supplied values, with sanity checking.  If the supplied
     * newProtocol is null then we initially fall back to the current protocol, or http if that
     * is null.
     * @param newPort As an integer.  Numbers < 1 or > 65535 are ignored.
     * @param newProtocol As a string like 'http' or 'https'
     */
    public void setPortProtocol(Integer newPort, String newProtocol) {
        protocol = (newProtocol == null ? protocol
                : (newProtocol.equals(PROTOCOL_HTTPS) ? PROTOCOL_HTTPS : PROTOCOL_HTTP));
        if (newPort == null || newPort < 1 || newPort > 65535 || newPort == 80 || newPort == 443)
            port = (protocol.equals(PROTOCOL_HTTP) ? 80 : 443);
        else
            port = newPort;
    }

    /**
     * Set the timeouts to use for subsequent requests, in milliseconds. The connectionTimeOut
     * says how long to wait for the connection to be established, and the socketTimeOut says
     * how long to wait for data after the connection is established.
     * @param newConnectionTimeOut
     * @param newSocketTimeOut
     */
    public void setTimeOuts(int newConnectionTimeOut, int newSocketTimeOut) {
        if (socketTimeOut == newSocketTimeOut && connectionTimeOut == newConnectionTimeOut)
            return;
        socketTimeOut = newSocketTimeOut;
        connectionTimeOut = newConnectionTimeOut;
        if (!initialised)
            return;
        AcalConnectionPool.setTimeOuts(socketTimeOut, connectionTimeOut);
        httpClient = new DefaultHttpClient(connManager, httpParams);
    }

    /**
     * Set the path for the next request, with some sanity checking to force the path
     * to start with a '/'.
     * @param newPath
     */
    public void setPath(String newPath) {
        if (newPath == null || newPath.equals("")) {
            path = "/";
            return;
        }
        if (!newPath.substring(0, 1).equals("/")) {
            path = "/" + newPath;
        } else
            path = newPath;
    }

    /**
     * Set the authentication type to be used for the next request.
     * @param newAuthType
     */
    public void setAuthType(Integer newAuthType) {
        if (newAuthType == Servers.AUTH_BASIC || newAuthType == Servers.AUTH_DIGEST) {
            authType = newAuthType;
            return;
        }
        authType = Servers.AUTH_NONE;
    }

    /**
     * Force the next request to use authentication pre-emptively.
     */
    public void setAuthRequired() {
        authRequired = true;
    }

    /**
     * Return the current protocol://host:port as the start of a URL.
     * @return
     */
    public String protocolHostPort() {
        return protocol + "://" + hostName + ((protocol.equals(PROTOCOL_HTTP) && port == 80)
                || (protocol.equals(PROTOCOL_HTTPS) && port == 443) ? "" : ":" + Integer.toString(port));
    }

    /**
     * Return the current protocol://host.example.com:port/path/to/resource as a URL.
     * @return
     */
    public String fullUrl() {
        return protocolHostPort() + path;
    }

    /**
     * Retrieve the unlocalised name of the authentication scheme currently in effect.
     * @return
     */
    public static String getAuthTypeName(int authCode) {
        switch (authCode) {
        // Only used in debug logging so don't need l10n
        case Servers.AUTH_BASIC:
            return "Basic";
        case Servers.AUTH_DIGEST:
            return "Digest";
        default:
            return "NoAuth";
        }
    }

    private String getLocationHeader() {
        for (Header h : responseHeaders) {
            if (debugThisRequest)
                Log.println(Constants.LOGV, TAG,
                        "Looking for redirect in Header: " + h.getName() + ":" + h.getValue());
            if (h.getName().equalsIgnoreCase("Location"))
                return h.getValue();
        }
        return "";
    }

    private Header getAuthHeader() {
        Header selectedAuthHeader = null;
        for (Header h : responseHeaders) {
            if (debugThisRequest)
                Log.println(Constants.LOGV, TAG, "Looking for auth in Header: " + h.getName() + ":" + h.getValue());
            if (h.getName().equalsIgnoreCase("WWW-Authenticate")) {
                // If this is a digest Auth header we will return with it
                for (HeaderElement he : h.getElements()) {

                    if (he.getName().substring(0, 7).equalsIgnoreCase("Digest ")) {
                        return h;
                    } else if (he.getName().substring(0, 6).equalsIgnoreCase("Basic ")) {
                        if (selectedAuthHeader == null)
                            selectedAuthHeader = h;
                    }
                }
            }
        }
        return selectedAuthHeader;
    }

    private String entityToString(HttpEntity entity) {
        InputStream in;
        StringBuilder total = new StringBuilder();
        try {
            in = entity.getContent();
            BufferedReader r = new BufferedReader(new InputStreamReader(in),
                    AcalConnectionPool.DEFAULT_BUFFER_SIZE);
            String line;
            while ((line = r.readLine()) != null) {
                total.append(line).append("\n");
            }
            in.close();
        } catch (IllegalStateException e) {
            Log.w(TAG, "Auto-generated catch block", e);
        } catch (IOException e) {
            Log.w(TAG, "Auto-generated catch block", e);
        }
        return total.toString();
    }

    private void logEntityLines(int logLevel, String prefix, String entityString) {
        for (String line : entityString.toString().split("\n")) {
            if (line.length() == entityString.toString().length()) {
                int end;
                int length = line.length();
                for (int pos = 0; pos < length; pos += LONG_LINE_WRAP_FOR_DEBUG) {
                    end = pos + LONG_LINE_WRAP_FOR_DEBUG;
                    if (end > length)
                        end = length;
                    Log.println(logLevel, TAG, prefix + line.substring(pos, end));
                }
            } else {
                Log.println(logLevel, TAG, prefix + line.replaceAll("\r$", ""));
            }
        }
    }

    /**
     * Log the full details of the request.
     * @param logLevel
     */
    public void logRequest(int logLevel) {
        Log.println(logLevel, TAG, method + " " + this.fullUrl());
        if (request == null) {
            Log.w(TAG, "Attempting to log request entity but request is null!");
            return;
        }

        for (Header h : request.getAllHeaders()) {
            Log.println(logLevel, TAG, "H>  " + h.getName() + ":" + h.getValue());
        }
        if (request.getEntity() == null)
            return;

        String entityString = entityToString(request.getEntity());
        if (entityString != null) {
            Log.println(logLevel, TAG, "----------------------- vvv Request Body vvv -----------------------");
            logEntityLines(logLevel, "R>  ", entityString);
            Log.println(logLevel, TAG, "----------------------- ^^^ Request Body ^^^ -----------------------");
        }
    }

    public InputStream logResponse(int logLevel) {
        if (response == null) {
            Log.w(TAG, "Attempting to log response entity but response is null!");
            return null;
        }
        Log.println(logLevel, TAG, "RESPONSE: " + response.getStatusLine().getProtocolVersion() + " "
                + response.getStatusLine().getStatusCode() + " " + response.getStatusLine().getReasonPhrase());

        for (Header h : responseHeaders) {
            Log.println(logLevel, TAG, "H<  " + h.getName() + ": " + h.getValue());
        }

        if (response.getEntity() == null) {
            Log.println(logLevel, TAG, "Attempting to log response entity but response.getEntity() is null :-(");
            return null;
        }

        String entityString = entityToString(response.getEntity());
        if (entityString != null) {
            Log.println(logLevel, TAG, "----------------------- vvv Response Body vvv -----------------------");
            logEntityLines(logLevel, "R<  ", entityString);
            Log.println(logLevel, TAG, "----------------------- ^^^ Response Body ^^^ -----------------------");
        }
        return new ByteArrayInputStream(entityString.getBytes());
    }

    /**
     * Marshall and send the request.
     * @param headers
     * @param entityString
     * @return
     * @throws SendRequestFailedException
     * @throws SSLException
     * @throws AuthenticationFailure
     * @throws ConnectionFailedException
     * @throws ConnectionPoolTimeoutException
     */
    private synchronized InputStream sendRequest(Header[] headers, String entityString)
            throws SendRequestFailedException, SSLException, AuthenticationFailure, ConnectionFailedException,
            ConnectionPoolTimeoutException {
        long down = 0;
        long up = 0;
        long start = System.currentTimeMillis();

        if (!initialised)
            throw new IllegalStateException("AcalRequestor has not been initialised!");
        statusCode = -1;
        try {
            // Create request and add headers and entity
            request = new DavRequest(method, this.fullUrl());
            //         request.addHeader(new BasicHeader("User-Agent", AcalConnectionPool.getUserAgent()));
            if (headers != null)
                for (Header h : headers)
                    request.addHeader(h);

            if (authRequired && authType != Servers.AUTH_NONE)
                request.addHeader(buildAuthHeader());
            else if (authRequired) {
                // Assume basicAuth
                request.addHeader(basicAuthHeader());
            }

            if (entityString != null) {
                request.setEntity(new StringEntity(entityString.toString(), "UTF-8"));
                up = request.getEntity().getContentLength();
            }

            // This trick greatly reduces the occurrence of host not found errors.
            try {
                InetAddress.getByName(this.hostName);
            } catch (UnknownHostException e1) {
                Thread.sleep(100);
                try {
                    InetAddress.getByName(this.hostName);
                } catch (UnknownHostException e2) {
                    Thread.sleep(100);
                }
            }

            int requestPort = -1;
            if (this.protocol == null)
                this.protocol = PROTOCOL_HTTP;
            String requestProtocol = this.protocol;
            if ((this.protocol.equals(PROTOCOL_HTTP) && this.port != 80)
                    || (this.protocol.equals(PROTOCOL_HTTPS) && this.port != 443)) {
                requestPort = this.port;
            }

            if (Constants.LOG_DEBUG || debugThisRequest) {
                Log.println(Constants.LOGD, TAG,
                        String.format("Method: %s, Protocol: %s, Hostname: %s, Port: %d, Path: %s", method,
                                requestProtocol, hostName, requestPort, path));
            }
            HttpHost host = new HttpHost(this.hostName, requestPort, requestProtocol);

            if (debugThisRequest)
                logRequest(Constants.LOGV);

            // Send request and get response
            response = null;

            if (Constants.debugHeap)
                AcalDebug.heapDebug(TAG, "Making HTTP request");
            try {
                response = httpClient.execute(host, request);
            } catch (ConnectionPoolTimeoutException e) {
                Log.println(Constants.LOGI, TAG,
                        e.getClass().getSimpleName() + ": " + e.getMessage() + " to " + fullUrl());
                Log.println(Constants.LOGI, TAG, "Retrying...");
                response = httpClient.execute(host, request);
            }
            if (Constants.debugHeap)
                AcalDebug.heapDebug(TAG, "Finished HTTP request");

            this.responseHeaders = response.getAllHeaders();
            this.statusCode = response.getStatusLine().getStatusCode();

            HttpEntity entity = response.getEntity();
            down = (entity == null ? 0 : entity.getContentLength());

            long finish = System.currentTimeMillis();
            double timeTaken = (finish - start) / 1000.0;

            if (Constants.LOG_DEBUG || debugThisRequest)
                Log.println(Constants.LOGD, TAG, "Response: " + statusCode + ", Sent: " + up + ", Received: " + down
                        + ", Took: " + timeTaken + " seconds");

            if (debugThisRequest) {
                return logResponse(Constants.LOGV);
            } else if (entity != null) {
                if (entity.getContentLength() > 0)
                    return entity.getContent();

                // Kind of admitting defeat here, but I can't track down why we seem
                // to end up in never-never land if we just return entity.getContent()
                // directly when entity.getContentLength() is -1 ('unknown', apparently).
                // Horribly inefficient too.
                //
                // @todo: Check whether this problem was caused by failing to close the InputStream
                // and this hack can be removed...  Need to find a server which does not send Content-Length headers.
                //
                String tmpEntity = entityToString(entity);
                return new ByteArrayInputStream(tmpEntity.getBytes());
            }

        } catch (SSLProtocolException e) {
            Log.i(TAG, e.getClass().getSimpleName() + ": " + e.getMessage() + " to " + fullUrl());
            return null;
        } catch (SSLHandshakeException e) {
            Log.i(TAG, e.getClass().getSimpleName() + ": " + e.getMessage() + " to " + fullUrl());
            throw e;
        } catch (SSLException e) {
            if (debugThisRequest)
                Log.println(Constants.LOGD, TAG, Log.getStackTraceString(e));
            throw e;
        } catch (AuthenticationFailure e) {
            if (debugThisRequest)
                Log.println(Constants.LOGD, TAG, Log.getStackTraceString(e));
            throw e;
        } catch (ConnectionPoolTimeoutException e) {
            Log.i(TAG, e.getClass().getSimpleName() + ": " + e.getMessage() + " to " + fullUrl());
            throw e;
        } catch (SocketTimeoutException e) {
            Log.i(TAG, e.getClass().getSimpleName() + ": " + e.getMessage() + " to " + fullUrl());
            return null;
        } catch (ConnectTimeoutException e) {
            Log.i(TAG, e.getClass().getSimpleName() + ": " + e.getMessage() + " to " + fullUrl());
            return null;
        } catch (UnknownHostException e) {
            Log.i(TAG, e.getClass().getSimpleName() + ": " + e.getMessage() + " to " + fullUrl());
            return null;
        } catch (IOException e) {
            Log.i(TAG, e.getClass().getSimpleName() + ": " + e.getMessage() + " to " + fullUrl());
            return null;
        } catch (Exception e) {
            Log.println(Constants.LOGD, TAG, Log.getStackTraceString(e));
            if (statusCode < 300 || statusCode > 499)
                throw new SendRequestFailedException(e.getMessage());
        }
        return null;
    }

    /**
     * Do a new HTTP <method> request with these headers and entity (request body) against
     * this path (or the current path, if null).  The headers & entity may also be null in
     * some simple cases.
     *
     * If the server requests Digest or Basic authentication a second request will be made
     * supplying these (if possible).  Likewise the method will follow up to five redirects
     * before giving up on a request.
     * @param method
     * @param pathOrUrl
     * @param headers
     * @param entity
     * @return
     * @throws SendRequestFailedException
     * @throws SSLException
     * @throws ConnectionFailedException
     */
    public InputStream doRequest(String method, String pathOrUrl, Header[] headers, String entity)
            throws SendRequestFailedException, SSLException, ConnectionFailedException {

        if (Constants.LOG_DEBUG || debugThisRequest)
            Log.println(Constants.LOGD, TAG, String.format("%s request on %s", method, fullUrl()));

        InputStream result = null;
        interpretUriString(pathOrUrl);
        this.method = method;
        do {
            try {
                result = sendRequest(headers, entity);
            } catch (SSLHandshakeException e) {
                throw e;
            } catch (SSLException e) {
                throw e;
            } catch (SendRequestFailedException e) {
                throw e;
            } catch (ConnectionFailedException e) {
                throw e;
            } catch (AuthenticationFailure e1) {
                statusCode = 401;
            } catch (Exception e) {
                Log.e(TAG, Log.getStackTraceString(e));
            }

            if (statusCode == 401) {
                // In this case we didn't send auth credentials the first time, so
                // we need to try again after we interpret the auth request.
                try {
                    interpretRequestedAuth(getAuthHeader());
                    return sendRequest(headers, entity);
                } catch (AuthenticationFailure e1) {
                    throw new SendRequestFailedException("Authentication Failed: " + e1.getMessage());
                } catch (Exception e) {
                    Log.e(TAG, Log.getStackTraceString(e));
                }
            }

            if ((statusCode >= 300 && statusCode <= 303) || statusCode == 307) {
                /**
                 * Other than 301/302 these are all pretty unlikely
                 *      300:  Multiple choices, but we take the one in the Location header anyway
                 *      301:  Moved permanently
                 *      302:  Found (was 'temporary redirect' once in prehistory)
                 *      303:  See other
                 *      307:  Temporary redirect. Meh.
                 */
                if (redirectCount++ < redirectLimit) {
                    String oldUrl = fullUrl();
                    interpretUriString(getLocationHeader());
                    if (debugThisRequest)
                        Log.println(Constants.LOGD, TAG, method + " " + oldUrl + " redirected to: " + fullUrl());

                    continue;
                }
            } else
                break;
        } while (redirectCount < redirectLimit);

        return result;
    }

    /**
     * <p>
     * Does an XML request against the specified path (or the previously set path, if null),
     * following redirects and returning the root DavNode of an XML tree.
     * </p>
     *
     * @return <p>
     *         A DavNode which is the root of the multistatus response, or null if it couldn't be parsed.
     *         </p>
     * @throws SSLHandshakeException
     */
    public DavNode doXmlRequest(String method, String requestPath, Header[] headers, String xml)
            throws SSLHandshakeException {
        long start = System.currentTimeMillis();

        InputStream responseStream = null;
        DavNode root = null;
        try {
            responseStream = doRequest(method, requestPath, headers, xml);
            if (responseHeaders == null) {
                return root;
            }
            for (Header h : responseHeaders) {
                if ("Content-Type".equals(h.getName())) {
                    for (HeaderElement he : h.getElements()) {
                        if ("text/plain".equals(he.getName()) || "text/html".equals(he.getName())) {
                            Log.println(Constants.LOGI, TAG, "Response is not an XML document");
                            if (responseStream != null)
                                responseStream.close();
                            return root;
                        }
                    }
                }
            }
            if (statusCode == 404 || statusCode == 401) {
                return root;
            }
            root = DavParserFactory.buildTreeFromXml(Constants.XMLParseMethod, responseStream);
        } catch (SSLHandshakeException e) {
            throw e;
        } catch (Exception e) {
            Log.i(TAG, e.getMessage(), e);
            return null;
        } finally {
            if (responseStream != null)
                try {
                    responseStream.close();
                } catch (IOException e) {
                }
        }

        if (debugThisRequest)
            Log.println(Constants.LOGV, TAG,
                    "Request and parse completed in " + (System.currentTimeMillis() - start) + "ms");
        return root;
    }

    /**
     * Get the current hostname used for the last request, or recently set.
     * @return
     */
    public String getHostName() {
        return this.hostName;
    }

    public void setHostName(String hostIn) {
        if (hostIn == null)
            throw new NullPointerException("May not set hostName to null");
        // This trick greatly reduces the occurrence of host not found errors.
        try {
            InetAddress.getByName(hostIn);
        } catch (UnknownHostException e1) {
        }
        this.hostName = hostIn;
    }

    public int getPort() {
        return port;
    }

    public String getProtocol() {
        return protocol;
    }

    public String getUserName() {
        return this.username;
    }

    public boolean protocolEquals(String otherProtocol) {
        if (protocol == null && otherProtocol == null)
            return true;
        else if (protocol == null)
            return false;

        return protocol.equals(otherProtocol);
    }

    public JSONObject doJsonRequest(String method, String requestPath, Header[] headers, String xml)
            throws SSLHandshakeException {
        InputStream responseStream = null;
        try {
            responseStream = doRequest(method, requestPath, headers, xml);
            if (statusCode == 404 || statusCode == 401) {
                if (Constants.LOG_DEBUG)
                    Log.e(TAG, "Status '" + statusCode + "' for " + fullUrl());
                return new JSONObject();
            }
            try {
                return new JSONObject(convertStreamToString(responseStream));
            } catch (IllegalStateException e) {
                if (Constants.LOG_DEBUG)
                    Log.e(TAG, "Error Sending Request: ", e);
                return new JSONObject();
            } catch (IOException e) {
                if (Constants.LOG_DEBUG)
                    Log.e(TAG, "IOException in request to '" + fullUrl() + "'", e);
                return new JSONObject();
            } catch (NullPointerException e) {
                if (Constants.LOG_DEBUG)
                    Log.e(TAG, "NullPointerException in request to '" + fullUrl() + "'", e);
                return new JSONObject();
            }
        } catch (SSLHandshakeException e) {
            throw e;
        } catch (Exception e) {
            Log.i(TAG, e.getMessage(), e);
            return new JSONObject();
        } finally {
            if (responseStream != null)
                try {
                    responseStream.close();
                } catch (IOException e) {
                }
        }
    }

    public static String convertStreamToString(InputStream in) throws IOException {
        if (in == null)
            return null;
        Writer writer = new StringWriter();
        char[] buffer = new char[1024];
        Reader reader = new BufferedReader(new InputStreamReader(in, "UTF-8"));
        int n;
        while ((n = reader.read(buffer)) != -1) {
            writer.write(buffer, 0, n);
        }
        in.close();
        return writer.toString();
    }

    public boolean wasRedirected() {
        return redirectCount > 0;
    }

}