com.digium.respokesdk.RespokeClient.java Source code

Java tutorial

Introduction

Here is the source code for com.digium.respokesdk.RespokeClient.java

Source

/**
 * Copyright 2015, Digium, Inc.
 * All rights reserved.
 *
 * This source code is licensed under The MIT License found in the
 * LICENSE file in the root directory of this source tree.
 *
 * For all details and documentation:  https://www.respoke.io
 */

package com.digium.respokesdk;

import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;

import com.digium.respokesdk.RestAPI.APIDoOpen;
import com.digium.respokesdk.RestAPI.APIGetToken;
import com.digium.respokesdk.RestAPI.APITransaction;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 *  This is a top-level interface to the API. It handles authenticating the app to the
 *  API server, receiving server-side app-specific information, keeping track of connection status and presence,
 *  accepting callbacks and listeners, and interacting with information the library keeps
 *  track of, like groups and endpoints. The client also keeps track of default settings for calls and direct
 *  connections as well as automatically reconnecting to the service when network activity is lost.
 */
public class RespokeClient implements RespokeSignalingChannel.Listener {

    private static final String TAG = "RespokeClient";
    private static final int RECONNECT_INTERVAL = 500; ///< The exponential step interval between automatic reconnect attempts, in milliseconds

    public static final String PROPERTY_LAST_VALID_PUSH_TOKEN = "pushToken";
    public static final String PROPERTY_LAST_VALID_PUSH_TOKEN_ID = "pushTokenServiceID";

    private WeakReference<Listener> listenerReference;
    private WeakReference<ResolvePresenceListener> resolveListenerReference;
    private String localEndpointID; ///< The local endpoint ID
    private String localConnectionID; ///< The local connection ID
    private RespokeSignalingChannel signalingChannel; ///< The signaling channel to use
    private ArrayList<RespokeCall> calls; ///< An array of the active calls
    private HashMap<String, RespokeGroup> groups; ///< An array of the groups this client is a member of
    private ArrayList<RespokeEndpoint> knownEndpoints; ///< An array of the known endpoints
    private Object presence; ///< The current presence of this client
    private String applicationID; ///< The application ID to use when connecting in development mode
    private boolean reconnect; ///< Indicates if the client should automatically reconnect if the web socket disconnects
    private int reconnectCount; ///< A count of how many times reconnection has been attempted
    private boolean connectionInProgress; ///< Indicates if the client is in the middle of attempting to connect
    private Context appContext; ///< The application context
    private String pushServiceID; ///< The push service ID
    private ArrayList<String> presenceRegistrationQueue; ///< An array of endpoints that need to be registered for presence updates
    private HashMap<String, Boolean> presenceRegistered; ///< A Hash of all the endpoint IDs that have already been registered for presence updates
    private boolean registrationTaskWaiting; ///< A flag to indicate that a task is scheduled to begin presence registration

    public String baseURL = APITransaction.RESPOKE_BASE_URL; ///< The base url of the Respoke service to use

    /**
     * A listener interface to notify the receiver of events occurring with the client
     */
    public interface Listener {

        /**
         *  Receive a notification Respoke has successfully connected to the cloud.
         *
         *  @param sender The RespokeClient that has connected
         */
        void onConnect(RespokeClient sender);

        /**
         *  Receive a notification Respoke has successfully disconnected from the cloud.
         *
         *  @param sender        The RespokeClient that has disconnected
         *  @param reconnecting  Indicates if the Respoke SDK is attempting to automatically reconnect
         */
        void onDisconnect(RespokeClient sender, boolean reconnecting);

        /**
         *  Handle an error that resulted from a method call.
         *
         *  @param sender The RespokeClient that is reporting the error
         *  @param errorMessage  The error that has occurred
         */
        void onError(RespokeClient sender, String errorMessage);

        /**
         *  Receive a notification that the client is receiving a call from a remote party.
         *
         *  @param sender The RespokeClient that is receiving the call
         *  @param call   A reference to the incoming RespokeCall object
         */
        void onCall(RespokeClient sender, RespokeCall call);

        /**
         *  This event is fired when the logged-in endpoint is receiving a request to open a direct connection
         *  to another endpoint.  If the user wishes to allow the direct connection, calling 'accept' on the
         *  direct connection will allow the connection to be set up.
         *
         *  @param directConnection  The incoming direct connection object
         *  @param endpoint          The remote endpoint initiating the direct connection
         */
        void onIncomingDirectConnection(RespokeDirectConnection directConnection, RespokeEndpoint endpoint);

        /**
         *  Receive a notification that a message addressed to this group has been received
         *
         *  @param message    The message
         *  @param endpoint   The remote endpoint the message is related to
         *  @param group      If this was a group message, the group to which this group message was posted.
         *  @param timestamp  The timestamp of the message
         *  @param didSend    True if the specified endpoint sent the message, False if it received the message. Null for group messages.
         */
        void onMessage(String message, RespokeEndpoint endpoint, RespokeGroup group, Date timestamp,
                Boolean didSend);
    }

    /**
     * A listener interface to receive a notification that the task to join the groups has completed
     */
    public interface JoinGroupCompletionListener {

        /**
         *  Received notification that the groups were successfully joined
         *
         *  @param groupList  An array of RespokeGroup instances representing the groups that were successfully joined
         */
        void onSuccess(ArrayList<RespokeGroup> groupList);

        /**
         *  Receive a notification that the asynchronous operation failed
         *
         *  @param errorMessage  A human-readable description of the error that was encountered
         */
        void onError(String errorMessage);

    }

    /**
     * A listener interface to receive a notification that the connect action has failed
     */
    public interface ConnectCompletionListener {

        /**
         *  Receive a notification that the asynchronous operation failed
         *
         *  @param errorMessage  A human-readable description of the error that was encountered
         */
        void onError(String errorMessage);

    }

    /**
     *  A listener interface to ask the receiver to resolve a list of presence values for an endpoint
     */
    public interface ResolvePresenceListener {

        /**
         *  Resolve the presence among multiple connections belonging to this endpoint. Note that this callback will NOT be called in the UI thread.
         *
         *  @param presenceArray An array of presence values
         *
         *  @return The resolved presence value to use
         */
        Object resolvePresence(ArrayList<Object> presenceArray);

    }

    /**
     * A listener interface to receive a notification when the request to retrieve the history
     * of messages for a list of groups has completed
     */
    public interface GroupHistoriesCompletionListener {

        void onSuccess(Map<String, List<RespokeGroupMessage>> groupsToMessages);

        void onError(String errorMessage);
    }

    /**
     * A listener interface to receive a notification when the request to retrieve the
     * history of messages for a specific group has completed
     */
    public interface GroupHistoryCompletionListener {

        void onSuccess(List<RespokeGroupMessage> messageList);

        void onError(String errorMessage);
    }

    /**
     *  The constructor for this class
     */
    public RespokeClient() {
        calls = new ArrayList<RespokeCall>();
        groups = new HashMap<String, RespokeGroup>();
        knownEndpoints = new ArrayList<RespokeEndpoint>();
        presenceRegistrationQueue = new ArrayList<String>();
        presenceRegistered = new HashMap<String, Boolean>();
    }

    /**
     *  Set a receiver for the Listener interface
     *
     *  @param listener  The new receiver for events from the Listener interface for this client instance
     */
    public void setListener(Listener listener) {
        listenerReference = new WeakReference<Listener>(listener);
    }

    /**
     *  Set a receiver for the ResolvePresenceListener interface
     *
     *  @param listener  The new receiver for events from the ResolvePresenceListener interface for this client instance
     */
    public void setResolvePresenceListener(ResolvePresenceListener listener) {
        resolveListenerReference = new WeakReference<ResolvePresenceListener>(listener);
    }

    /**
     *  Get the current receiver for the ResolvePresenceListener interface
     *
     *  @return The current receiver for the ResolvePresenceListener interface
     */
    public ResolvePresenceListener getResolvePresenceListener() {
        if (null != resolveListenerReference) {
            return resolveListenerReference.get();
        } else {
            return null;
        }
    }

    /**
     *  Connect to the Respoke infrastructure and authenticate in development mode using the specified endpoint ID and app ID.
     *  Attempt to obtain an authentication token automatically from the Respoke infrastructure.
     *
     *  @param endpointID          The endpoint ID to use when connecting
     *  @param appID               Your Application ID
     *  @param shouldReconnect     Whether or not to automatically reconnect to the Respoke service when a disconnect occurs.
     *  @param initialPresence     The optional initial presence value to set for this client
     *  @param context             An application context with which to access system resources
     *  @param completionListener  A listener to be called when an error occurs, passing a string describing the error
     */
    public void connect(String endpointID, String appID, boolean shouldReconnect, final Object initialPresence,
            Context context, final ConnectCompletionListener completionListener) {
        if ((endpointID != null) && (appID != null) && (endpointID.length() > 0) && (appID.length() > 0)) {
            connectionInProgress = true;
            reconnect = shouldReconnect;
            applicationID = appID;
            appContext = context;

            APIGetToken request = new APIGetToken(context, baseURL) {
                @Override
                public void transactionComplete() {
                    super.transactionComplete();

                    if (success) {
                        connect(this.token, initialPresence, appContext, new ConnectCompletionListener() {
                            @Override
                            public void onError(final String errorMessage) {
                                connectionInProgress = false;

                                postConnectError(completionListener, errorMessage);
                            }
                        });
                    } else {
                        connectionInProgress = false;

                        postConnectError(completionListener, this.errorMessage);
                    }
                }
            };

            request.appID = appID;
            request.endpointID = endpointID;
            request.go();
        } else {
            postConnectError(completionListener, "AppID and endpointID must be specified");
        }
    }

    /**
     *  Connect to the Respoke infrastructure and authenticate with the specified brokered auth token ID. 
     *
     *  @param tokenID             The token ID to use when connecting
     *  @param initialPresence     The optional initial presence value to set for this client
     *  @param context             An application context with which to access system resources
     *  @param completionListener  A listener to be called when an error occurs, passing a string describing the error
     */
    public void connect(String tokenID, final Object initialPresence, Context context,
            final ConnectCompletionListener completionListener) {
        if ((tokenID != null) && (tokenID.length() > 0)) {
            connectionInProgress = true;
            appContext = context;

            APIDoOpen request = new APIDoOpen(context, baseURL) {
                @Override
                public void transactionComplete() {
                    super.transactionComplete();

                    if (success) {
                        // Remember the presence value to set once connected
                        presence = initialPresence;

                        signalingChannel = new RespokeSignalingChannel(appToken, RespokeClient.this, baseURL,
                                appContext);
                        signalingChannel.authenticate();
                    } else {
                        connectionInProgress = false;

                        postConnectError(completionListener, this.errorMessage);
                    }
                }
            };

            request.tokenID = tokenID;
            request.go();
        } else {
            postConnectError(completionListener, "TokenID must be specified");
        }
    }

    /**
     *  Disconnect from the Respoke infrastructure, leave all groups, invalidate the token, and disconnect the websocket.
     */
    public void disconnect() {
        reconnect = false;

        if (null != signalingChannel) {
            signalingChannel.disconnect();
        }
    }

    /**
     *  Check whether this client is connected to the backend infrastructure.
     *
     *  @return True if connected
     */
    public boolean isConnected() {
        return ((signalingChannel != null) && (signalingChannel.connected));
    }

    /**
     *  Join a list of Groups and begin keeping track of them.
     *
     *  @param groupIDList         An array of IDs of the groups to join
     *  @param completionListener  A listener to receive a notification of the success or failure of the asynchronous operation
     */
    public void joinGroups(final ArrayList<String> groupIDList,
            final JoinGroupCompletionListener completionListener) {
        if (isConnected()) {
            if ((groupIDList != null) && (groupIDList.size() > 0)) {
                String urlEndpoint = "/v1/groups";

                JSONArray groupList = new JSONArray(groupIDList);
                JSONObject data = new JSONObject();
                try {
                    data.put("groups", groupList);

                    signalingChannel.sendRESTMessage("post", urlEndpoint, data,
                            new RespokeSignalingChannel.RESTListener() {
                                @Override
                                public void onSuccess(Object response) {
                                    final ArrayList<RespokeGroup> newGroupList = new ArrayList<RespokeGroup>();
                                    for (String eachGroupID : groupIDList) {
                                        RespokeGroup newGroup = new RespokeGroup(eachGroupID, signalingChannel,
                                                RespokeClient.this);
                                        groups.put(eachGroupID, newGroup);
                                        newGroupList.add(newGroup);
                                    }

                                    new Handler(Looper.getMainLooper()).post(new Runnable() {
                                        @Override
                                        public void run() {
                                            if (null != completionListener) {
                                                completionListener.onSuccess(newGroupList);
                                            }
                                        }
                                    });
                                }

                                @Override
                                public void onError(final String errorMessage) {
                                    postJoinGroupMembersError(completionListener, errorMessage);
                                }
                            });
                } catch (JSONException e) {
                    postJoinGroupMembersError(completionListener, "Error encoding group list to json");
                }
            } else {
                postJoinGroupMembersError(completionListener, "At least one group must be specified");
            }
        } else {
            postJoinGroupMembersError(completionListener,
                    "Can't complete request when not connected. Please reconnect!");
        }
    }

    /**
     *  Find a Connection by id and return it. In most cases, if we don't find it we will create it. This is useful
     *  in the case of dynamic endpoints where groups are not in use. Set skipCreate=true to return null
     *  if the Connection is not already known.
     *
     *  @param connectionID The ID of the connection to return
     *  @param endpointID   The ID of the endpoint to which this connection belongs
     *  @param skipCreate   If true, return null if the connection is not already known
     *
     *  @return The connection whose ID was specified
     */
    public RespokeConnection getConnection(String connectionID, String endpointID, boolean skipCreate) {
        RespokeConnection connection = null;

        if (null != connectionID) {
            RespokeEndpoint endpoint = getEndpoint(endpointID, skipCreate);

            if (null != endpoint) {
                for (RespokeConnection eachConnection : endpoint.connections) {
                    if (eachConnection.connectionID.equals(connectionID)) {
                        connection = eachConnection;
                        break;
                    }
                }

                if ((null == connection) && (!skipCreate)) {
                    connection = new RespokeConnection(connectionID, endpoint);
                    endpoint.connections.add(connection);
                }
            }
        }

        return connection;
    }

    /**
     *  Initiate a call to a conference.
     *
     *  @param callListener  A listener to receive notifications about the new call
     *  @param context       An application context with which to access system resources
     *  @param conferenceID  The ID of the conference to call
     *
     *  @return A reference to the new RespokeCall object representing this call
     */
    public RespokeCall joinConference(RespokeCall.Listener callListener, Context context, String conferenceID) {
        RespokeCall call = null;

        if ((null != signalingChannel) && (signalingChannel.connected)) {
            call = new RespokeCall(signalingChannel, conferenceID, "conference");
            call.setListener(callListener);

            call.startCall(context, null, true);
        }

        return call;
    }

    /**
     *  Find an endpoint by id and return it. In most cases, if we don't find it we will create it. This is useful
     *  in the case of dynamic endpoints where groups are not in use. Set skipCreate=true to return null
     *  if the Endpoint is not already known.
     *
     *  @param endpointIDToFind The ID of the endpoint to return
     *  @param skipCreate       If true, return null if the connection is not already known
     *
     *  @return The endpoint whose ID was specified
     */
    public RespokeEndpoint getEndpoint(String endpointIDToFind, boolean skipCreate) {
        RespokeEndpoint endpoint = null;

        if (null != endpointIDToFind) {
            for (RespokeEndpoint eachEndpoint : knownEndpoints) {
                if (eachEndpoint.getEndpointID().equals(endpointIDToFind)) {
                    endpoint = eachEndpoint;
                    break;
                }
            }

            if ((null == endpoint) && (!skipCreate)) {
                endpoint = new RespokeEndpoint(signalingChannel, endpointIDToFind, this);
                knownEndpoints.add(endpoint);
            }

            if (null != endpoint) {
                queuePresenceRegistration(endpoint.getEndpointID());
            }
        }

        return endpoint;
    }

    /**
     *  Returns the group with the specified ID
     *
     *  @param groupIDToFind  The ID of the group to find
     *
     *  @return The group with specified ID, or null if it was not found
     */
    public RespokeGroup getGroup(String groupIDToFind) {
        RespokeGroup group = null;

        if (null != groupIDToFind) {
            group = groups.get(groupIDToFind);
        }

        return group;
    }

    /**
     * Retrieve the history of messages that have been persisted for 1 or more groups. Only those
     * messages that have been marked to be persisted when sent will show up in the history. Only
     * the most recent message in each group will be retrieved - to pull more messages, use the
     * other method signature that allows `maxMessages` to be specified.
     *
     * @param groupIds The groups to pull history for
     * @param completionListener The callback called when this async operation has completed
     */
    public void getGroupHistories(final List<String> groupIds,
            final GroupHistoriesCompletionListener completionListener) {
        getGroupHistories(groupIds, 1, completionListener);
    }

    /**
     * Retrieve the history of messages that have been persisted for 1 or more groups. Only those
     * messages that have been marked to be persisted when sent will show up in the history.
     *
     * @param groupIds The groups to pull history for
     * @param maxMessages The maximum number of messages per group to pull. Must be &gt;= 1
     * @param completionListener The callback called when this async operation has completed
     */
    public void getGroupHistories(final List<String> groupIds, final Integer maxMessages,
            final GroupHistoriesCompletionListener completionListener) {
        if (!isConnected()) {
            getGroupHistoriesError(completionListener,
                    "Can't complete request when not connected, " + "Please reconnect!");
            return;
        }

        if ((maxMessages == null) || (maxMessages < 1)) {
            getGroupHistoriesError(completionListener, "maxMessages must be at least 1");
            return;
        }

        if ((groupIds == null) || (groupIds.size() == 0)) {
            getGroupHistoriesError(completionListener, "At least 1 group must be specified");
            return;
        }

        Uri.Builder builder = new Uri.Builder();
        builder.appendQueryParameter("limit", maxMessages.toString());
        for (String id : groupIds) {
            builder.appendQueryParameter("groupIds", id);
        }

        String urlEndpoint = "/v1/group-histories" + builder.build().toString();
        signalingChannel.sendRESTMessage("get", urlEndpoint, null, new RespokeSignalingChannel.RESTListener() {
            @Override
            public void onSuccess(Object response) {
                if (!(response instanceof JSONObject)) {
                    getGroupHistoriesError(completionListener, "Invalid response from server");
                    return;
                }

                final JSONObject json = (JSONObject) response;
                final HashMap<String, List<RespokeGroupMessage>> results = new HashMap<>();

                for (Iterator<String> keys = json.keys(); keys.hasNext();) {
                    final String key = keys.next();

                    try {
                        final JSONArray jsonMessages = json.getJSONArray(key);

                        final ArrayList<RespokeGroupMessage> messageList = new ArrayList<>(jsonMessages.length());

                        for (int i = 0; i < jsonMessages.length(); i++) {
                            final JSONObject jsonMessage = jsonMessages.getJSONObject(i);
                            final RespokeGroupMessage message = buildGroupMessage(jsonMessage);
                            messageList.add(message);
                        }

                        results.put(key, messageList);
                    } catch (JSONException e) {
                        getGroupHistoriesError(completionListener, "Error parsing JSON response");
                        return;
                    }
                }

                new Handler(Looper.getMainLooper()).post(new Runnable() {
                    @Override
                    public void run() {
                        if (completionListener != null) {
                            completionListener.onSuccess(results);
                        }
                    }
                });
            }

            @Override
            public void onError(final String errorMessage) {
                getGroupHistoriesError(completionListener, errorMessage);
            }
        });
    }

    /**
     * Retrieve the history of messages that have been persisted for a specific group. Only those
     * messages that have been marked to be persisted when sent will show up in the history. Only
     * the 50 most recent messages in each group will be retrieved - to change the maximum number of
     * messages pulled, use the other method signature that allows `maxMessages` to be specified. To
     * retrieve messages further back in the history than right now, use the other method signature
     * that allows `before` to be specified.
     *
     * @param groupId The groups to pull history for
     * @param completionListener The callback called when this async operation has completed
     */
    public void getGroupHistory(final String groupId, final GroupHistoryCompletionListener completionListener) {
        getGroupHistory(groupId, 50, null, completionListener);
    }

    /**
     * Retrieve the history of messages that have been persisted for a specific group. Only those
     * messages that have been marked to be persisted when sent will show up in the history. To
     * retrieve messages further back in the history than right now, use the other method signature
     * that allows `before` to be specified.
     *
     * @param groupId The groups to pull history for
     * @param maxMessages The maximum number of messages per group to pull. Must be &gt;= 1
     * @param completionListener The callback called when this async operation has completed
     */
    public void getGroupHistory(final String groupId, final Integer maxMessages,
            final GroupHistoryCompletionListener completionListener) {
        getGroupHistory(groupId, maxMessages, null, completionListener);
    }

    /**
     * Retrieve the history of messages that have been persisted for a specific group. Only those
     * messages that have been marked to be persisted when sent will show up in the history.
     *
     * @param groupId The groups to pull history for
     * @param maxMessages The maximum number of messages per group to pull. Must be &gt;= 1
     * @param before Limit messages to those with a timestamp before this value
     * @param completionListener The callback called when this async operation has completed
     */
    public void getGroupHistory(final String groupId, final Integer maxMessages, final Date before,
            final GroupHistoryCompletionListener completionListener) {
        if (!isConnected()) {
            getGroupHistoryError(completionListener,
                    "Can't complete request when not connected, " + "Please reconnect!");
            return;
        }

        if ((maxMessages == null) || (maxMessages < 1)) {
            getGroupHistoryError(completionListener, "maxMessages must be at least 1");
            return;
        }

        if ((groupId == null) || groupId.length() == 0) {
            getGroupHistoryError(completionListener, "groupId cannot be blank");
            return;
        }

        Uri.Builder builder = new Uri.Builder();
        builder.appendQueryParameter("limit", maxMessages.toString());

        if (before != null) {
            builder.appendQueryParameter("before", Long.toString(before.getTime()));
        }

        String urlEndpoint = String.format("/v1/groups/%s/history%s", groupId, builder.build().toString());
        signalingChannel.sendRESTMessage("get", urlEndpoint, null, new RespokeSignalingChannel.RESTListener() {
            @Override
            public void onSuccess(Object response) {
                if (!(response instanceof JSONArray)) {
                    getGroupHistoryError(completionListener, "Invalid response from server");
                    return;
                }

                final JSONArray json = (JSONArray) response;
                final ArrayList<RespokeGroupMessage> results = new ArrayList<>(json.length());

                try {
                    for (int i = 0; i < json.length(); i++) {
                        final JSONObject jsonMessage = json.getJSONObject(i);
                        final RespokeGroupMessage message = buildGroupMessage(jsonMessage);
                        results.add(message);
                    }
                } catch (JSONException e) {
                    getGroupHistoryError(completionListener, "Error parsing JSON response");
                    return;
                }

                new Handler(Looper.getMainLooper()).post(new Runnable() {
                    @Override
                    public void run() {
                        if (completionListener != null) {
                            completionListener.onSuccess(results);
                        }
                    }
                });
            }

            @Override
            public void onError(final String errorMessage) {
                getGroupHistoryError(completionListener, errorMessage);
            }
        });
    }

    /**
     *  Return the Endpoint ID of this client
     *
     *  @return The Endpoint ID of this client
     */
    public String getEndpointID() {
        return localEndpointID;
    }

    /**
     *  Set the presence on the client session
     *
     *  @param newPresence         The new presence to use
     *  @param completionListener  A listener to receive the notification on the success or failure of the asynchronous operation
     */
    public void setPresence(Object newPresence, final Respoke.TaskCompletionListener completionListener) {
        if (isConnected()) {
            Object presenceToSet = newPresence;

            if (null == presenceToSet) {
                presenceToSet = "available";
            }

            JSONObject typeData = new JSONObject();
            JSONObject data = new JSONObject();

            try {
                typeData.put("type", presenceToSet);
                data.put("presence", typeData);

                final Object finalPresence = presenceToSet;

                signalingChannel.sendRESTMessage("post", "/v1/presence", data,
                        new RespokeSignalingChannel.RESTListener() {
                            @Override
                            public void onSuccess(Object response) {
                                presence = finalPresence;

                                Respoke.postTaskSuccess(completionListener);
                            }

                            @Override
                            public void onError(final String errorMessage) {
                                Respoke.postTaskError(completionListener, errorMessage);
                            }
                        });
            } catch (JSONException e) {
                Respoke.postTaskError(completionListener, "Error encoding presence to json");
            }
        } else {
            Respoke.postTaskError(completionListener,
                    "Can't complete request when not connected. Please reconnect!");
        }
    }

    /**
     *  Get the current presence of this client
     *
     *  @return The current presence value
     */
    public Object getPresence() {
        return presence;
    }

    /**
     *  Register the client to receive push notifications when the socket is not active
     *
     *  @param token  The GCMS token to register
     */
    public void registerPushServicesWithToken(final String token) {
        String httpURI;
        String httpMethod;

        JSONObject data = new JSONObject();
        try {
            data.put("token", token);
            data.put("service", "google");

            SharedPreferences prefs = appContext.getSharedPreferences(appContext.getPackageName(),
                    Context.MODE_PRIVATE);

            if (null != prefs) {
                String lastKnownPushToken = prefs.getString(PROPERTY_LAST_VALID_PUSH_TOKEN, "notAvailable");
                String lastKnownPushTokenID = prefs.getString(PROPERTY_LAST_VALID_PUSH_TOKEN_ID, "notAvailable");

                if ((null == lastKnownPushTokenID) || (lastKnownPushTokenID.equals("notAvailable"))) {
                    httpURI = String.format("/v1/connections/%s/push-token", localConnectionID);
                    httpMethod = "post";
                    createOrUpdatePushServiceToken(token, httpURI, httpMethod, data, prefs);
                } else if (!lastKnownPushToken.equals("notAvailable") && !lastKnownPushToken.equals(token)) {
                    httpURI = String.format("/v1/connections/%s/push-token/%s", localConnectionID,
                            lastKnownPushTokenID);
                    httpMethod = "put";
                    createOrUpdatePushServiceToken(token, httpURI, httpMethod, data, prefs);
                }
            }
        } catch (JSONException e) {
            Log.d("", "Invalid JSON format for token");
        }
    }

    /**
     *  Unregister this client from the push service so that no more notifications will be received for this endpoint ID
     *
     *  @param completionListener  A listener to receive the notification on the success or failure of the asynchronous operation
     */
    public void unregisterFromPushServices(final Respoke.TaskCompletionListener completionListener) {
        if (isConnected()) {
            SharedPreferences prefs = appContext.getSharedPreferences(appContext.getPackageName(),
                    Context.MODE_PRIVATE);

            if (null != prefs) {
                String lastKnownPushTokenID = prefs.getString(PROPERTY_LAST_VALID_PUSH_TOKEN_ID, "notAvailable");

                if ((null != lastKnownPushTokenID) && !lastKnownPushTokenID.equals("notAvailable")) {
                    // A push token has previously been registered successfully
                    String httpURI = String.format("/v1/connections/%s/push-token/%s", localConnectionID,
                            lastKnownPushTokenID);
                    signalingChannel.sendRESTMessage("delete", httpURI, null,
                            new RespokeSignalingChannel.RESTListener() {
                                @Override
                                public void onSuccess(Object response) {
                                    // Remove the push token ID from shared memory so that push may be registered again in the future
                                    SharedPreferences prefs = appContext.getSharedPreferences(
                                            appContext.getPackageName(), Context.MODE_PRIVATE);
                                    SharedPreferences.Editor editor = prefs.edit();
                                    editor.remove(PROPERTY_LAST_VALID_PUSH_TOKEN_ID);
                                    editor.apply();

                                    Respoke.postTaskSuccess(completionListener);
                                }

                                @Override
                                public void onError(String errorMessage) {
                                    Respoke.postTaskError(completionListener,
                                            "Error unregistering push service token: " + errorMessage);
                                }
                            });
                } else {
                    Respoke.postTaskSuccess(completionListener);
                }
            } else {
                Respoke.postTaskError(completionListener,
                        "Unable to access shared preferences to look for push token");
            }
        } else {
            Respoke.postTaskError(completionListener,
                    "Can't complete request when not connected. Please reconnect!");
        }
    }

    //** Private methods

    private void createOrUpdatePushServiceToken(final String token, String httpURI, String httpMethod,
            JSONObject data, final SharedPreferences prefs) {
        signalingChannel.sendRESTMessage(httpMethod, httpURI, data, new RespokeSignalingChannel.RESTListener() {
            @Override
            public void onSuccess(Object response) {
                if (response instanceof JSONObject) {
                    try {
                        JSONObject responseJSON = (JSONObject) response;
                        pushServiceID = responseJSON.getString("id");

                        SharedPreferences.Editor editor = prefs.edit();
                        editor.putString(PROPERTY_LAST_VALID_PUSH_TOKEN, token);
                        editor.putString(PROPERTY_LAST_VALID_PUSH_TOKEN_ID, pushServiceID);
                        editor.apply();
                    } catch (JSONException e) {
                        Log.d(TAG, "Unexpected response from server while registering push service token");
                    }
                } else {
                    Log.d(TAG, "Unexpected response from server while registering push service token");
                }
            }

            @Override
            public void onError(String errorMessage) {
                Log.d(TAG, "Error registering push service token: " + errorMessage);
            }
        });
    }

    /**
     *  A convenience method for posting errors to a ConnectCompletionListener
     *
     *  @param completionListener  The listener to notify
     *  @param errorMessage        The human-readable error message that occurred
     */
    private void postConnectError(final ConnectCompletionListener completionListener, final String errorMessage) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                if (null != completionListener) {
                    completionListener.onError(errorMessage);
                }
            }
        });
    }

    /**
     *  A convenience method for posting errors to a JoinGroupCompletionListener
     *
     *  @param completionListener  The listener to notify
     *  @param errorMessage        The human-readable error message that occurred
     */
    private void postJoinGroupMembersError(final JoinGroupCompletionListener completionListener,
            final String errorMessage) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                if (null != completionListener) {
                    completionListener.onError(errorMessage);
                }
            }
        });
    }

    private void getGroupHistoriesError(final GroupHistoriesCompletionListener completionListener,
            final String errorMessage) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                if (completionListener != null) {
                    completionListener.onError(errorMessage);
                }
            }
        });
    }

    private void getGroupHistoryError(final GroupHistoryCompletionListener completionListener,
            final String errorMessage) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                if (completionListener != null) {
                    completionListener.onError(errorMessage);
                }
            }
        });
    }

    /**
     *  Attempt to reconnect the client after a small delay
     */
    private void performReconnect() {
        if (null != applicationID) {
            reconnectCount++;

            new java.util.Timer().schedule(new java.util.TimerTask() {
                @Override
                public void run() {
                    actuallyReconnect();
                }
            }, RECONNECT_INTERVAL * (reconnectCount - 1));
        }
    }

    /**
     *  Attempt to reconnect the client if it is not already trying in another thread
     */
    private void actuallyReconnect() {
        if (((null == signalingChannel) || !signalingChannel.connected) && reconnect) {
            if (connectionInProgress) {
                // The client app must have initiated a connection manually during the timeout period. Try again later
                performReconnect();
            } else {
                Log.d(TAG, "Trying to reconnect...");
                connect(localEndpointID, applicationID, reconnect, presence, appContext,
                        new ConnectCompletionListener() {
                            @Override
                            public void onError(final String errorMessage) {
                                // A REST API call failed. Socket errors are handled in the onError callback
                                new Handler(Looper.getMainLooper()).post(new Runnable() {
                                    @Override
                                    public void run() {
                                        Listener listener = listenerReference.get();
                                        if (null != listener) {
                                            listener.onError(RespokeClient.this, errorMessage);
                                        }
                                    }
                                });

                                // Try again later
                                performReconnect();
                            }
                        });
            }
        }
    }

    /**
     *  Register for presence updates for the specified endpoint ID. Registration will not occur immediately, 
     *  it will be queued and performed asynchronously. Queuing allows for large numbers of presence 
     *  registration requests to occur in batches, minimizing the number of network transactions (and overall 
     *  time required).
     *
     *  @param endpointID  The ID of the endpoint for which to register for presence updates
     */
    private void queuePresenceRegistration(String endpointID) {
        if (null != endpointID) {
            Boolean shouldSpawnRegistrationTask = false;

            synchronized (this) {
                Boolean alreadyRegistered = presenceRegistered.get(endpointID);
                if ((null == alreadyRegistered) || !alreadyRegistered) {
                    presenceRegistrationQueue.add(endpointID);

                    // If a Runnable to register presence has not already been scheduled, note that one will be shortly
                    if (!registrationTaskWaiting) {
                        shouldSpawnRegistrationTask = true;
                        registrationTaskWaiting = true;
                    }
                }
            }

            if (shouldSpawnRegistrationTask) {
                // Schedule a Runnable to register presence on the next context switch, which should allow multiple subsequent calls to queuePresenceRegistration to get batched into a single socket transaction for efficiency
                new Handler(Looper.getMainLooper()).post(new Runnable() {
                    @Override
                    public void run() {
                        final HashMap<String, Boolean> endpointIDMap = new HashMap<String, Boolean>();

                        synchronized (this) {
                            // Build a list of the endpointIDs that have been scheduled for registration, and have not already been taken care of by a previous loop of this task
                            while (presenceRegistrationQueue.size() > 0) {
                                String nextEndpointID = presenceRegistrationQueue.remove(0);
                                Boolean alreadyRegistered = presenceRegistered.get(nextEndpointID);
                                if ((null == alreadyRegistered) || !alreadyRegistered) {
                                    endpointIDMap.put(nextEndpointID, true);
                                }
                            }

                            // Now that the batch of endpoint IDs to register has been determined, indicate to the client that any new registration calls should schedule a new Runnable
                            registrationTaskWaiting = false;
                        }

                        // Build an array from the map keySet to ensure there are no duplicates in the list
                        final ArrayList<String> endpointIDsToRegister = new ArrayList<String>(
                                endpointIDMap.keySet());

                        if ((endpointIDsToRegister.size() > 0) && isConnected()) {
                            signalingChannel.registerPresence(endpointIDsToRegister,
                                    new RespokeSignalingChannel.RegisterPresenceListener() {
                                        @Override
                                        public void onSuccess(JSONArray initialPresenceData) {
                                            // Indicate that registration was successful for each endpoint ID in the list
                                            synchronized (RespokeClient.this) {
                                                for (String eachID : endpointIDsToRegister) {
                                                    presenceRegistered.put(eachID, true);
                                                }
                                            }

                                            if (null != initialPresenceData) {
                                                for (int ii = 0; ii < initialPresenceData.length(); ii++) {
                                                    try {
                                                        JSONObject eachEndpointData = (JSONObject) initialPresenceData
                                                                .get(ii);
                                                        String dataEndpointID = eachEndpointData
                                                                .getString("endpointId");
                                                        RespokeEndpoint endpoint = getEndpoint(dataEndpointID,
                                                                true);

                                                        if (null != endpoint) {
                                                            JSONObject connectionData = eachEndpointData
                                                                    .getJSONObject("connectionStates");
                                                            Iterator<?> keys = connectionData.keys();

                                                            while (keys.hasNext()) {
                                                                String eachConnectionID = (String) keys.next();
                                                                JSONObject presenceDict = connectionData
                                                                        .getJSONObject(eachConnectionID);
                                                                Object newPresence = presenceDict.get("type");
                                                                RespokeConnection connection = endpoint
                                                                        .getConnection(eachConnectionID, false);

                                                                if ((null != connection) && (null != newPresence)) {
                                                                    connection.presence = newPresence;
                                                                }
                                                            }
                                                        }
                                                    } catch (JSONException e) {
                                                        // Silently skip this problem
                                                    }
                                                }
                                            }

                                            for (String eachID : endpointIDsToRegister) {
                                                RespokeEndpoint endpoint = getEndpoint(eachID, true);

                                                if (null != endpoint) {
                                                    endpoint.resolvePresence();
                                                }
                                            }
                                        }

                                        @Override
                                        public void onError(final String errorMessage) {
                                            Log.d(TAG, "Error registering presence: " + errorMessage);
                                        }
                                    });
                        }
                    }
                });
            }
        }
    }

    // RespokeSignalingChannelListener methods

    public void onConnect(RespokeSignalingChannel sender, String endpointID, String connectionID) {
        connectionInProgress = false;
        reconnectCount = 0;
        localEndpointID = endpointID;
        localConnectionID = connectionID;

        Respoke.sharedInstance().clientConnected(this);

        // Try to set the presence to the initial or last set state
        setPresence(presence, new Respoke.TaskCompletionListener() {
            @Override
            public void onSuccess() {
                // do nothing
            }

            @Override
            public void onError(String errorMessage) {
                // do nothing
            }
        });

        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                Listener listener = listenerReference.get();
                if (null != listener) {
                    listener.onConnect(RespokeClient.this);
                }
            }
        });
    }

    public void onDisconnect(RespokeSignalingChannel sender) {
        // Can only reconnect in development mode, not brokered mode
        final boolean willReconnect = reconnect && (applicationID != null);

        calls.clear();
        groups.clear();
        knownEndpoints.clear();
        presenceRegistrationQueue.clear();
        presenceRegistered.clear();
        registrationTaskWaiting = false;

        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                Listener listener = listenerReference.get();
                if (null != listener) {
                    listener.onDisconnect(RespokeClient.this, willReconnect);
                }
            }
        });

        signalingChannel = null;

        if (willReconnect) {
            performReconnect();
        }
    }

    public void onIncomingCall(JSONObject sdp, String sessionID, String connectionID, String endpointID,
            String fromType, Date timestamp, RespokeSignalingChannel sender) {
        RespokeEndpoint endpoint = null;

        if (fromType.equals("web")) {
            /* Only create endpoints for type web */
            endpoint = getEndpoint(endpointID, false);

            if (null == endpoint) {
                Log.d(TAG, "Error: Could not create Endpoint for incoming call");
                return;
            }
        }

        final RespokeCall call = new RespokeCall(signalingChannel, sdp, sessionID, connectionID, endpointID,
                fromType, endpoint, false, timestamp);

        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                Listener listener = listenerReference.get();
                if (null != listener) {
                    listener.onCall(RespokeClient.this, call);
                }
            }
        });
    }

    public void onIncomingDirectConnection(JSONObject sdp, String sessionID, String connectionID, String endpointID,
            Date timestamp, RespokeSignalingChannel sender) {
        RespokeEndpoint endpoint = getEndpoint(endpointID, false);

        if (null != endpoint) {
            final RespokeCall call = new RespokeCall(signalingChannel, sdp, sessionID, connectionID, endpointID,
                    "web", endpoint, true, timestamp);
        } else {
            Log.d(TAG, "Error: Could not create Endpoint for incoming direct connection");
        }
    }

    public void onError(final String errorMessage, RespokeSignalingChannel sender) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                Listener listener = listenerReference.get();
                if (null != listener) {
                    listener.onError(RespokeClient.this, errorMessage);
                }
            }
        });

        if ((null != signalingChannel) && (!signalingChannel.connected)) {
            connectionInProgress = false;

            if (reconnect) {
                performReconnect();
            }
        }
    }

    public void onJoinGroup(String groupID, String endpointID, String connectionID,
            RespokeSignalingChannel sender) {
        // only pass on notifications about people other than ourselves
        if ((null != endpointID) && (!endpointID.equals(localEndpointID))) {
            RespokeGroup group = groups.get(groupID);

            if (null != group) {
                // Get the existing instance for this connection, or create a new one if necessary
                RespokeConnection connection = getConnection(connectionID, endpointID, false);

                if (null != connection) {
                    group.connectionDidJoin(connection);
                }
            }
        }
    }

    public void onLeaveGroup(String groupID, String endpointID, String connectionID,
            RespokeSignalingChannel sender) {
        // only pass on notifications about people other than ourselves
        if ((null != endpointID) && (!endpointID.equals(localEndpointID))) {
            RespokeGroup group = groups.get(groupID);

            if (null != group) {
                // Get the existing instance for this connection. If we are not already aware of it, ignore it
                RespokeConnection connection = getConnection(connectionID, endpointID, true);

                if (null != connection) {
                    group.connectionDidLeave(connection);
                }
            }
        }
    }

    private void didReceiveMessage(final RespokeEndpoint endpoint, final String message, final Date timestamp) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                if (null != listenerReference) {
                    Listener listener = listenerReference.get();
                    if (null != listener) {
                        listener.onMessage(message, endpoint, null, timestamp, false);
                    }
                }
            }
        });
    }

    private void didSendMessage(final RespokeEndpoint endpoint, final String message, final Date timestamp) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                if (null != listenerReference) {
                    Listener listener = listenerReference.get();
                    if (null != listener) {
                        listener.onMessage(message, endpoint, null, timestamp, true);
                    }
                }
            }
        });
    }

    public void onMessage(final String message, final Date timestamp, String fromEndpointID, String toEndpointID,
            RespokeSignalingChannel sender) {

        if (localEndpointID.equals(fromEndpointID) && (null != toEndpointID)) {
            // The local endpoint sent this message to the remote endpoint from another device (ccSelf)
            final RespokeEndpoint endpoint = getEndpoint(toEndpointID, false);
            if (null != endpoint) {
                endpoint.didReceiveMessage(message, timestamp);

                // Notify the client listener of the message
                didReceiveMessage(endpoint, message, timestamp);
            }
            return;
        }

        // The local endpoint received this message from a remote endpoint
        final RespokeEndpoint endpoint = getEndpoint(fromEndpointID, false);
        if (null != endpoint) {
            endpoint.didSendMessage(message, timestamp);

            // Notify the client listener of the message
            didSendMessage(endpoint, message, timestamp);
        }
    }

    public void onGroupMessage(final String message, String groupID, String endpointID,
            RespokeSignalingChannel sender, final Date timestamp) {
        final RespokeGroup group = groups.get(groupID);

        if (null != group) {
            final RespokeEndpoint endpoint = getEndpoint(endpointID, false);

            // Notify the group of the new message
            group.didReceiveMessage(message, endpoint, timestamp);

            // Notify the client listener of the group message
            new Handler(Looper.getMainLooper()).post(new Runnable() {
                @Override
                public void run() {
                    if (null != listenerReference) {
                        Listener listener = listenerReference.get();
                        if (null != listener) {
                            listener.onMessage(message, endpoint, group, timestamp, null);
                        }
                    }
                }
            });
        }
    }

    public void onPresence(Object presence, String connectionID, String endpointID,
            RespokeSignalingChannel sender) {
        RespokeConnection connection = getConnection(connectionID, endpointID, false);

        if (null != connection) {
            connection.presence = presence;

            RespokeEndpoint endpoint = connection.getEndpoint();
            endpoint.resolvePresence();
        }
    }

    public void callCreated(RespokeCall call) {
        calls.add(call);
    }

    public void callTerminated(RespokeCall call) {
        calls.remove(call);
    }

    public RespokeCall callWithID(String sessionID) {
        RespokeCall call = null;

        for (RespokeCall eachCall : calls) {
            if (eachCall.getSessionID().equals(sessionID)) {
                call = eachCall;
                break;
            }
        }

        return call;
    }

    public void directConnectionAvailable(final RespokeDirectConnection directConnection,
            final RespokeEndpoint endpoint) {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                if (null != listenerReference) {
                    Listener listener = listenerReference.get();
                    if (null != listener) {
                        listener.onIncomingDirectConnection(directConnection, endpoint);
                    }
                }
            }
        });
    }

    /**
     * Build a group message from a JSON object. The format of the JSON object would be the
     * format that comes over the wire from Respoke when receiving a pubsub message. This same
     * format is used when retrieving message history.
     *
     * @param source The source JSON object to build the RespokeGroupMessage from
     * @return The built RespokeGroupMessage
     * @throws JSONException
     */
    private RespokeGroupMessage buildGroupMessage(JSONObject source) throws JSONException {
        if (source == null) {
            throw new IllegalArgumentException("source cannot be null");
        }

        final JSONObject header = source.getJSONObject("header");
        final String endpointID = header.getString("from");
        final RespokeEndpoint endpoint = getEndpoint(endpointID, false);
        final String groupID = header.getString("channel");
        RespokeGroup group = getGroup(groupID);

        if (group == null) {
            group = new RespokeGroup(groupID, signalingChannel, this, false);
            groups.put(groupID, group);
        }

        final String message = source.getString("message");

        final Date timestamp;

        if (!header.isNull("timestamp")) {
            timestamp = new Date(header.getLong("timestamp"));
        } else {
            // Just use the current time if no date is specified in the header data
            timestamp = new Date();
        }

        return new RespokeGroupMessage(message, group, endpoint, timestamp);
    }
}