Persistent cookie support using Volley and HttpUrlConnection

Posted on

Problem

I need to add support for persistent cookies on an Android app that I’m building for Authentication/Authorization. This app uses Volley for making HTTP requests and its minSdkVersion is set to 9 (Gingerbread). Therefore Volley uses HttpUrlConnection internally.

What I had to do was create a new java.net.CookieStore that saves the incoming cookies to the SharedPreferences. I basically had similar requirements as this question.

I kind of merged java.net.CookieStoreImple with com.loopj.android.http.PersistentCookieStore and this little creature was created:

PersistentHttpCookieStore:

/**
 * A shared preferences cookie store.
 */
final class PersistentHttpCookieStore implements CookieStore {
    private static final String LOG_TAG = "PersistentHttpCookieStore";
    private static final String COOKIE_PREFS = "CookiePrefsFile";
    private static final String COOKIE_NAME_STORE = "names";
    private static final String COOKIE_NAME_PREFIX = "cookie_";

    /** this map may have null keys! */
    private final Map<URI, List<HttpCookie>> map;

    private final SharedPreferences cookiePrefs;

    /**
     * Construct a persistent cookie store.
     * 
     * @param context
     *            Context to attach cookie store to
     */
    public PersistentHttpCookieStore(Context context) {
        cookiePrefs = context.getSharedPreferences(COOKIE_PREFS, 0);
        map = new HashMap<URI, List<HttpCookie>>();

        // Load any previously stored cookies into the store
        String storedCookieNames = cookiePrefs.getString(COOKIE_NAME_STORE,
                null);
        if (storedCookieNames != null) {
            String[] cookieNames = TextUtils.split(storedCookieNames, ",");
            for (String name : cookieNames) {
                String encodedCookie = cookiePrefs.getString(COOKIE_NAME_PREFIX
                        + name, null);
                if (encodedCookie != null) {
                    HttpCookie decodedCookie = decodeCookie(encodedCookie);
                    if (decodedCookie != null) {
                        List<HttpCookie> cookies = new ArrayList<HttpCookie>();
                        cookies.add(decodedCookie);
                        map.put(URI.create(name), cookies);
                    }
                }
            }
        }
    }

    public synchronized void add(URI uri, HttpCookie cookie) {
        if (cookie == null) {
            throw new NullPointerException("cookie == null");
        }

        uri = cookiesUri(uri);
        List<HttpCookie> cookies = map.get(uri);
        if (cookies == null) {
            cookies = new ArrayList<HttpCookie>();
            map.put(uri, cookies);
        } else {
            cookies.remove(cookie);
        }
        cookies.add(cookie);

        // Save cookie into persistent store
        SharedPreferences.Editor prefsWriter = cookiePrefs.edit();
        prefsWriter.putString(COOKIE_NAME_STORE,
                TextUtils.join(",", map.keySet()));
        prefsWriter.putString(COOKIE_NAME_PREFIX + uri,
                encodeCookie(new SerializableHttpCookie(cookie)));
        prefsWriter.commit();
    }

    public synchronized List<HttpCookie> get(URI uri) {
        if (uri == null) {
            throw new NullPointerException("uri == null");
        }

        List<HttpCookie> result = new ArrayList<HttpCookie>();
        // get cookies associated with given URI. If none, returns an empty list
        List<HttpCookie> cookiesForUri = map.get(uri);
        if (cookiesForUri != null) {
            for (Iterator<HttpCookie> i = cookiesForUri.iterator(); i.hasNext();) {
                HttpCookie cookie = i.next();
                if (cookie.hasExpired()) {
                    i.remove(); // remove expired cookies
                } else {
                    result.add(cookie);
                }
            }
        }
        // get all cookies that domain matches the URI
        for (Map.Entry<URI, List<HttpCookie>> entry : map.entrySet()) {
            if (uri.equals(entry.getKey())) {
                continue; // skip the given URI; we've already handled it
            }
            List<HttpCookie> entryCookies = entry.getValue();
            for (Iterator<HttpCookie> i = entryCookies.iterator(); i.hasNext();) {
                HttpCookie cookie = i.next();
                if (!HttpCookie
                        .domainMatches(cookie.getDomain(), uri.getHost())) {
                    continue;
                }
                if (cookie.hasExpired()) {
                    i.remove(); // remove expired cookies
                } else if (!result.contains(cookie)) {
                    result.add(cookie);
                }
            }
        }
        return Collections.unmodifiableList(result);
    }

    public synchronized List<HttpCookie> getCookies() {
        List<HttpCookie> result = new ArrayList<HttpCookie>();
        for (List<HttpCookie> list : map.values()) {
            for (Iterator<HttpCookie> i = list.iterator(); i.hasNext();) {
                HttpCookie cookie = i.next();
                if (cookie.hasExpired()) {
                    i.remove(); // remove expired cookies
                } else if (!result.contains(cookie)) {
                    result.add(cookie);
                }
            }
        }
        return Collections.unmodifiableList(result);
    }

    public synchronized List<URI> getURIs() {
        List<URI> result = new ArrayList<URI>(map.keySet());
        result.remove(null); // sigh
        return Collections.unmodifiableList(result);
    }

    public synchronized boolean remove(URI uri, HttpCookie cookie) {
        if (cookie == null) {
            throw new NullPointerException("cookie == null");
        }

        uri = cookiesUri(uri);
        List<HttpCookie> cookies = map.get(uri);
        if (cookies != null) {
            SharedPreferences.Editor prefsWriter = cookiePrefs.edit();
            prefsWriter.remove(COOKIE_NAME_PREFIX + uri);
            prefsWriter.commit();

            return cookies.remove(cookie);
        } else {
            return false;
        }
    }

    public synchronized boolean removeAll() {
        // Clear cookies from persistent store
        SharedPreferences.Editor prefsWriter = cookiePrefs.edit();
        for (URI name : map.keySet()) {
            prefsWriter.remove(COOKIE_NAME_PREFIX + name);
        }
        prefsWriter.remove(COOKIE_NAME_STORE);
        prefsWriter.commit();

        // Clear cookies from local store
        boolean result = !map.isEmpty();
        map.clear();
        return result;
    }

    /**
     * Serializes HttpCookie 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 HttpCookie 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 &lt;-&gt; 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;
    }

    private URI cookiesUri(URI uri) {
        if (uri == null) {
            return null;
        }

        try {
            return new URI("http", uri.getHost(), null, null);
        } catch (URISyntaxException e) {
            return uri; // probably a URI with no host
        }
    }
}

SerializableHttpCookie:

public class SerializableHttpCookie implements Serializable {
    private static final long serialVersionUID = -6051428667568260064L;

    private transient HttpCookie cookie;

    public SerializableHttpCookie(HttpCookie cookie) {
        this.cookie = cookie;
    }

    public HttpCookie getCookie() {
        return cookie;
    }

    private void writeObject(ObjectOutputStream out) throws IOException {
        out.writeObject(cookie.getName());
        out.writeObject(cookie.getValue());
        out.writeObject(cookie.getComment());
        out.writeObject(cookie.getCommentURL());
        out.writeBoolean(cookie.getDiscard());
        out.writeObject(cookie.getDomain());
        out.writeLong(cookie.getMaxAge());
        out.writeObject(cookie.getPath());
        out.writeObject(cookie.getPortlist());
        out.writeBoolean(cookie.getSecure());
        out.writeInt(cookie.getVersion());
    }

    private void readObject(ObjectInputStream in) throws IOException,
            ClassNotFoundException {
        String name = (String) in.readObject();
        String value = (String) in.readObject();
        cookie = new HttpCookie(name, value);
        cookie.setComment((String) in.readObject());
        cookie.setCommentURL((String) in.readObject());
        cookie.setDiscard(in.readBoolean());
        cookie.setDomain((String) in.readObject());
        cookie.setMaxAge(in.readLong());
        cookie.setPath((String) in.readObject());
        cookie.setPortlist((String) in.readObject());
        cookie.setSecure(in.readBoolean());
        cookie.setVersion(in.readInt());
    }
}

References:

What do you think of this implementation? It seems to be working fine so far but I’m a little bit worried about performance (regarding encoding and decoding cookies).

Solution

A faster way of serialization is using DataOutputStream and DataInputStream. They are faster than ObjectOutputStream because they specifically handle primitive data types, and do not handle arbitrary data formats. In the case of cookies, that’s all you need.

As part of that, instead of Serializable, consider using this SO answer’s Packageable interface which wraps DataOutputStream and provides a convenient interface for it.

You are actually only persisting one cookie per uri, but you’re holding multiple cookies in memory. Every time you add a cookie to your shared preferences you’re overwriting the previous cookie you saved to that uri. Something like this will save all the cookies for you, but you will also need to update the constructer to read them all back out:

    // Save cookie into persistent store
    SharedPreferences.Editor prefsWriter = cookiePrefs.edit();
    prefsWriter.putString(COOKIE_NAME_STORE,
            TextUtils.join(",", map.keySet()));
    ArrayList<String> encodedCookies = new ArrayList<String>();

    // Serialize all the cookies and join them before saving them.
    for (HttpCookie cookieToEncode : cookies) {
        encodedCookies.add(encodeCookie(new SerializableHttpCookie(cookieToEncode)));
    }

    prefsWriter.putString(COOKIE_NAME_PREFIX + uri,
            TextUtils.join(",", encodedCookies)
    );
    prefsWriter.commit();

This isn’t the most efficient way to do this, because now we’re encoding and writing all the cookies for a given uri every time any of them change, but at least we’re keeping them all.

I have tested your version of a Serializable HttpCookie and the below Parcelable implementation, the performance difference is significant 1.5X faster for encoding and 3X faster for decoding.

I obtained this result from a quick benchmark test with 1000 iterations to encode and decode a fully loaded HttpCookie object to and from a Hex String. I repeated the benchmark experiment 10 times and calculated the average performance where I got 3357ms for encoding Serializable HttpCookie compared to 2228ms for Parcelable implementation. Decoding time was 991ms and 329ms respectively (time was calculated for the 1000 iterations)

Below is the full code for HttpCookieParcelable:

public class HttpCookieParcelable implements Parcelable {
    private HttpCookie cookie;

    public HttpCookieParcelable(HttpCookie cookie) {
        this.cookie = cookie;
    }

    public HttpCookieParcelable(Parcel source) {
        String name = source.readString();
        String value = source.readString();
        cookie = new HttpCookie(name, value);
        cookie.setComment(source.readString());
        cookie.setCommentURL(source.readString());
        cookie.setDiscard(source.readByte() != 0);
        cookie.setDomain(source.readString());
        cookie.setMaxAge(source.readLong());
        cookie.setPath(source.readString());
        cookie.setPortlist(source.readString());
        cookie.setSecure(source.readByte() != 0);
        cookie.setVersion(source.readInt());
    }

    public HttpCookie getCookie() {
        return cookie;
    }

    public void setCookie(HttpCookie cookie) {
        this.cookie = cookie;
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeString(cookie.getName());
        dest.writeString(cookie.getValue());
        dest.writeString(cookie.getComment());
        dest.writeString(cookie.getCommentURL());
        dest.writeByte((byte) (cookie.getDiscard() ? 1 : 0));
        dest.writeString(cookie.getDomain());
        dest.writeLong(cookie.getMaxAge());
        dest.writeString(cookie.getPath());
        dest.writeString(cookie.getPortlist());
        dest.writeByte((byte) (cookie.getSecure() ? 1 : 0));
        dest.writeInt(cookie.getVersion());
    }

    public static final Parcelable.Creator<HttpCookieParcelable> CREATOR = 
        new Creator<HttpCookieParcelable>() {

            @Override
            public HttpCookieParcelable[] newArray(int size) {
                return new HttpCookieParcelable[size];
            }

            @Override
            public HttpCookieParcelable createFromParcel(Parcel source) {
                return new HttpCookieParcelable(source);
            }
        };
}

Below is the Hex String for exactly the same HttpCookie object, the Parcelable version looks pretty sparse (lots of zeros) which I guess might explain why it is much faster.

Serializable Hex String

ACED0005737200486F72672E6F6E656974732E7574696C2E53657269616C697A655574696C73245465737453657269616C697A655574696C732448747470436F6F6B696553657269616C697A61626C6500000000000000010300007870740005636E616D657400066376616C7565740007636F6D6D656E7474000B636F6D6D6F656E7455524C770100740006646F6D61696E7708000000000000271074000470617468740008706F72744C6973747705010000000078

Parcelable Hex String

0500000063006E0061006D0065000000060000006300760061006C0075006500000000000700000063006F006D006D0065006E00740000000B00000063006F006D006D006F0065006E007400550052004C000000000000000600000064006F006D00610069006E00000000001027000000000000040000007000610074006800000000000800000070006F00720074004C00690073007400000000000100000000000000

Here is the code that fixes the bug where all the cookies aren’t being saved. The user above also points this out.

It is still using the Parcelable Cookie vs Serializable cookie for performance.

public class CookieManager implements CookieStore
{
    private static final String LOG_TAG = "PersistentCookieStore";
    private static final String COOKIE_PREFS = "CookiePrefsFile";
    private static final String COOKIE_NAME_PREFIX = "cookie_";

    private final HashMap<String, ConcurrentHashMap<String, HttpCookie>> cookies;
    private final SharedPreferences cookiePrefs;

    /**
     * Construct a persistent cookie store.
     *
     * @param context Context to attach cookie store to
     */
    public CookieManager(Context context)
    {
        cookiePrefs = context.getSharedPreferences(COOKIE_PREFS, 0);
        cookies = new HashMap<String, ConcurrentHashMap<String, HttpCookie>>();

        // Load any previously stored cookies into the store
        Map<String, ?> prefsMap = cookiePrefs.getAll();
        for(Map.Entry<String, ?> entry : prefsMap.entrySet())
        {
            if (((String)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<String, HttpCookie>());

                            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<String, HttpCookie>());
            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 HttpCookieParcelable(cookie)));
        prefsWriter.commit();
    }

    protected String getCookieToken(URI uri, HttpCookie cookie) {
        return cookie.getName() + cookie.getDomain();
    }

    @Override
    public List<HttpCookie> get(URI uri) {
        ArrayList<HttpCookie> ret = new ArrayList<HttpCookie>();
        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.commit();
        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.commit();

            return true;
        } else {
            return false;
        }
    }

    @Override
    public List<HttpCookie> getCookies() {
        ArrayList<HttpCookie> ret = new ArrayList<HttpCookie>();
        for (String key : cookies.keySet())
            ret.addAll(cookies.get(key).values());

        return ret;
    }

    @Override
    public List<URI> getURIs() {
        ArrayList<URI> ret = new ArrayList<URI>();
        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(HttpCookieParcelable cookie)
    {
        if (cookie == null)
            return null;

        return byteArrayToHexString(ParcelableUtil.marshall(cookie));
    }

    /**
     * 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);

        HttpCookieParcelable cookieParcel = ParcelableUtil.unmarshall(bytes, HttpCookieParcelable.CREATOR);
        HttpCookie cookie = cookieParcel.getCookie();

        return cookie;
    }

    /**
     * Using some super basic byte array &lt;-&gt; 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 array
     *
     * @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;
    }
}

Leave a Reply

Your email address will not be published. Required fields are marked *