001/**
002 * Copyright 2015, Digium, Inc.
003 * All rights reserved.
004 *
005 * This source code is licensed under The MIT License found in the
006 * LICENSE file in the root directory of this source tree.
007 *
008 * For all details and documentation:  https://www.respoke.io
009 */
010
011package com.digium.respokesdk;
012
013import android.content.Context;
014import android.content.SharedPreferences;
015import android.net.Uri;
016import android.os.Handler;
017import android.os.Looper;
018import android.util.Log;
019
020import com.digium.respokesdk.RestAPI.APIDoOpen;
021import com.digium.respokesdk.RestAPI.APIGetToken;
022import com.digium.respokesdk.RestAPI.APITransaction;
023
024import org.json.JSONArray;
025import org.json.JSONException;
026import org.json.JSONObject;
027
028import java.lang.ref.WeakReference;
029import java.util.ArrayList;
030import java.util.Date;
031import java.util.HashMap;
032import java.util.Iterator;
033import java.util.List;
034import java.util.Map;
035
036import static android.R.attr.data;
037import static android.R.attr.id;
038
039/**
040 *  This is a top-level interface to the API. It handles authenticating the app to the
041 *  API server, receiving server-side app-specific information, keeping track of connection status and presence,
042 *  accepting callbacks and listeners, and interacting with information the library keeps
043 *  track of, like groups and endpoints. The client also keeps track of default settings for calls and direct
044 *  connections as well as automatically reconnecting to the service when network activity is lost.
045 */
046public class RespokeClient implements RespokeSignalingChannel.Listener {
047
048    private static final String TAG = "RespokeClient";
049    private static final int RECONNECT_INTERVAL = 500;  ///< The exponential step interval between automatic reconnect attempts, in milliseconds
050
051    public static final String PROPERTY_LAST_VALID_PUSH_TOKEN = "pushToken";
052    public static final String PROPERTY_LAST_VALID_PUSH_TOKEN_ID = "pushTokenServiceID";
053
054    private WeakReference<Listener> listenerReference;
055    private WeakReference<ResolvePresenceListener> resolveListenerReference;
056    private String localEndpointID;  ///< The local endpoint ID
057    private String localConnectionID; ///< The local connection ID
058    private RespokeSignalingChannel signalingChannel;  ///< The signaling channel to use
059    private ArrayList<RespokeCall> calls;  ///< An array of the active calls
060    private HashMap<String, RespokeGroup> groups;  ///< An array of the groups this client is a member of
061    private ArrayList<RespokeEndpoint> knownEndpoints;  ///< An array of the known endpoints
062    private Object presence;  ///< The current presence of this client
063    private String applicationID;  ///< The application ID to use when connecting in development mode
064    private boolean reconnect;  ///< Indicates if the client should automatically reconnect if the web socket disconnects
065    private int reconnectCount;  ///< A count of how many times reconnection has been attempted
066    private boolean connectionInProgress;  ///< Indicates if the client is in the middle of attempting to connect
067    private Context appContext;  ///< The application context
068    private String pushServiceID; ///< The push service ID
069    private ArrayList<String> presenceRegistrationQueue; ///< An array of endpoints that need to be registered for presence updates
070    private HashMap<String, Boolean> presenceRegistered; ///< A Hash of all the endpoint IDs that have already been registered for presence updates
071    private boolean registrationTaskWaiting; ///< A flag to indicate that a task is scheduled to begin presence registration
072
073    public String baseURL = APITransaction.RESPOKE_BASE_URL;  ///< The base url of the Respoke service to use
074
075    /**
076     * A listener interface to notify the receiver of events occurring with the client
077     */
078    public interface Listener {
079        /**
080         *  Receive a notification Respoke has successfully connected to the cloud.
081         *
082         *  @param sender The RespokeClient that has connected
083         */
084        void onConnect(RespokeClient sender);
085
086
087        /**
088         *  Receive a notification Respoke has successfully disconnected from the cloud.
089         *
090         *  @param sender        The RespokeClient that has disconnected
091         *  @param reconnecting  Indicates if the Respoke SDK is attempting to automatically reconnect
092         */
093        void onDisconnect(RespokeClient sender, boolean reconnecting);
094
095        /**
096         *  Handle an error that resulted from a method call.
097         *
098         *  @param sender The RespokeClient that is reporting the error
099         *  @param errorMessage  The error that has occurred
100         */
101        void onError(RespokeClient sender, String errorMessage);
102
103        /**
104         *  Receive a notification that the client is receiving a call from a remote party.
105         *
106         *  @param sender The RespokeClient that is receiving the call
107         *  @param call   A reference to the incoming RespokeCall object
108         */
109        void onCall(RespokeClient sender, RespokeCall call);
110
111        /**
112         *  This event is fired when the logged-in endpoint is receiving a request to open a direct connection
113         *  to another endpoint.  If the user wishes to allow the direct connection, calling 'accept' on the
114         *  direct connection will allow the connection to be set up.
115         *
116         *  @param directConnection  The incoming direct connection object
117         *  @param endpoint          The remote endpoint initiating the direct connection
118         */
119        void onIncomingDirectConnection(RespokeDirectConnection directConnection, RespokeEndpoint endpoint);
120
121        /**
122         *  Receive a notification that a message addressed to this group has been received
123         *
124         *  @param message    The message
125         *  @param endpoint   The remote endpoint the message is related to
126         *  @param group      If this was a group message, the group to which this group message was posted.
127         *  @param timestamp  The timestamp of the message
128         *  @param didSend    True if the specified endpoint sent the message, False if it received the message. Null for group messages.
129         */
130        void onMessage(String message, RespokeEndpoint endpoint, RespokeGroup group, Date timestamp, Boolean didSend);
131    }
132
133    /**
134     * A listener interface to receive a notification that the task to join the groups has completed
135     */
136    public interface JoinGroupCompletionListener {
137
138        /**
139         *  Received notification that the groups were successfully joined
140         *
141         *  @param groupList  An array of RespokeGroup instances representing the groups that were successfully joined
142         */
143        void onSuccess(ArrayList<RespokeGroup> groupList);
144
145        /**
146         *  Receive a notification that the asynchronous operation failed
147         *
148         *  @param errorMessage  A human-readable description of the error that was encountered
149         */
150        void onError(String errorMessage);
151
152    }
153
154    /**
155     * A listener interface to receive a notification that the connect action has failed
156     */
157    public interface ConnectCompletionListener {
158
159        /**
160         *  Receive a notification that the asynchronous operation failed
161         *
162         *  @param errorMessage  A human-readable description of the error that was encountered
163         */
164        void onError(String errorMessage);
165
166    }
167
168    /**
169     *  A listener interface to ask the receiver to resolve a list of presence values for an endpoint
170     */
171    public interface ResolvePresenceListener {
172
173        /**
174         *  Resolve the presence among multiple connections belonging to this endpoint. Note that this callback will NOT be called in the UI thread.
175         *
176         *  @param presenceArray An array of presence values
177         *
178         *  @return The resolved presence value to use
179         */
180        Object resolvePresence(ArrayList<Object> presenceArray);
181
182    }
183
184    /**
185     * A listener interface to receive a notification when the request to retrieve the history
186     * of messages for a list of groups has completed
187     */
188    public interface GroupHistoriesCompletionListener {
189
190        void onSuccess(Map<String, List<RespokeGroupMessage>> groupsToMessages);
191
192        void onError(String errorMessage);
193    }
194
195    /**
196     * A listener interface to receive a notification when the request to retrieve the
197     * history of messages for a specific group has completed
198     */
199    public interface GroupHistoryCompletionListener {
200
201        void onSuccess(List<RespokeGroupMessage> messageList);
202
203        void onError(String errorMessage);
204    }
205
206    /**
207     * Encapsulates the info record for an Endpoint conversation.
208     */
209    public static class EndpointConversationInfo {
210        public String groupId;
211        public Date timestamp;
212        public RespokeGroupMessage latestMessage;
213        public String sourceId;
214        public int unreadCount;
215
216        public EndpointConversationInfo() {
217        }
218
219        public EndpointConversationInfo(
220                String groupId,
221                Date timestamp,
222                RespokeGroupMessage message,
223                String sourceId,
224                int unreadCount) {
225            this.groupId = groupId;
226            this.timestamp = timestamp;
227            this.latestMessage = message;
228            this.sourceId = sourceId;
229            this.unreadCount = unreadCount;
230        }
231    }
232
233    /**
234     * A listener interface to receive a notification when the request to retrieve
235     * the list of conversations for an endpoint has completed.
236     */
237    public interface EndpointConversationsCompletionListener {
238
239        void onSuccess(List<EndpointConversationInfo> conversations);
240
241        void onError(String errorMessage);
242    }
243
244    /**
245     *  The constructor for this class
246     */
247    public RespokeClient() {
248        calls = new ArrayList<RespokeCall>();
249        groups = new HashMap<String, RespokeGroup>();
250        knownEndpoints = new ArrayList<RespokeEndpoint>();
251        presenceRegistrationQueue = new ArrayList<String>();
252        presenceRegistered = new HashMap<String, Boolean>();
253    }
254
255    /**
256     *  Set a receiver for the Listener interface
257     *
258     *  @param listener  The new receiver for events from the Listener interface for this client instance
259     */
260    public void setListener(Listener listener) {
261        listenerReference = new WeakReference<Listener>(listener);
262    }
263
264    /**
265     *  Set a receiver for the ResolvePresenceListener interface
266     *
267     *  @param listener  The new receiver for events from the ResolvePresenceListener interface for this client instance
268     */
269    public void setResolvePresenceListener(ResolvePresenceListener listener) {
270        resolveListenerReference = new WeakReference<ResolvePresenceListener>(listener);
271    }
272
273    /**
274     *  Get the current receiver for the ResolvePresenceListener interface
275     *
276     *  @return The current receiver for the ResolvePresenceListener interface
277     */
278    public ResolvePresenceListener getResolvePresenceListener() {
279        if (null != resolveListenerReference) {
280            return resolveListenerReference.get();
281        } else {
282            return null;
283        }
284    }
285
286    /**
287     *  Connect to the Respoke infrastructure and authenticate in development mode using the specified endpoint ID and app ID.
288     *  Attempt to obtain an authentication token automatically from the Respoke infrastructure.
289     *
290     *  @param endpointID          The endpoint ID to use when connecting
291     *  @param appID               Your Application ID
292     *  @param shouldReconnect     Whether or not to automatically reconnect to the Respoke service when a disconnect occurs.
293     *  @param initialPresence     The optional initial presence value to set for this client
294     *  @param context             An application context with which to access system resources
295     *  @param completionListener  A listener to be called when an error occurs, passing a string describing the error
296     */
297    public void connect(String endpointID, String appID, boolean shouldReconnect, final Object initialPresence, Context context, final ConnectCompletionListener completionListener) {
298        if ((endpointID != null) && (appID != null) && (endpointID.length() > 0) && (appID.length() > 0)) {
299            connectionInProgress = true;
300            reconnect = shouldReconnect;
301            applicationID = appID;
302            appContext = context;
303
304            APIGetToken request = new APIGetToken(context, baseURL) {
305                @Override
306                public void transactionComplete() {
307                    super.transactionComplete();
308
309                    if (success) {
310                        connect(this.token, initialPresence, appContext, new ConnectCompletionListener() {
311                            @Override
312                            public void onError(final String errorMessage) {
313                                connectionInProgress = false;
314
315                                postConnectError(completionListener, errorMessage);
316                            }
317                        });
318                    } else {
319                        connectionInProgress = false;
320
321                        postConnectError(completionListener, this.errorMessage);
322                    }
323                }
324            };
325
326            request.appID = appID;
327            request.endpointID = endpointID;
328            request.go();
329        } else {
330            postConnectError(completionListener, "AppID and endpointID must be specified");
331        }
332    }
333
334    /**
335     *  Connect to the Respoke infrastructure and authenticate with the specified brokered auth token ID. 
336     *
337     *  @param tokenID             The token ID to use when connecting
338     *  @param initialPresence     The optional initial presence value to set for this client
339     *  @param context             An application context with which to access system resources
340     *  @param completionListener  A listener to be called when an error occurs, passing a string describing the error
341     */
342    public void connect(String tokenID, final Object initialPresence, Context context, final ConnectCompletionListener completionListener) {
343        if ((tokenID != null) && (tokenID.length() > 0)) {
344            connectionInProgress = true;
345            appContext = context;
346
347            APIDoOpen request = new APIDoOpen(context, baseURL) {
348                @Override
349                public void transactionComplete() {
350                    super.transactionComplete();
351
352                    if (success) {
353                        // Remember the presence value to set once connected
354                        presence = initialPresence;
355
356                        signalingChannel = new RespokeSignalingChannel(appToken, RespokeClient.this, baseURL, appContext);
357                        signalingChannel.authenticate();
358                    } else {
359                        connectionInProgress = false;
360
361                        postConnectError(completionListener, this.errorMessage);
362                    }
363                }
364            };
365
366            request.tokenID = tokenID;
367            request.go();
368        } else {
369            postConnectError(completionListener, "TokenID must be specified");
370        }
371    }
372
373    /**
374     *  Disconnect from the Respoke infrastructure, leave all groups, invalidate the token, and disconnect the websocket.
375     */
376    public void disconnect() {
377        reconnect = false;
378
379        if (null != signalingChannel) {
380            signalingChannel.disconnect();
381        }
382    }
383
384    /**
385     *  Check whether this client is connected to the backend infrastructure.
386     *
387     *  @return True if connected
388     */
389    public boolean isConnected() {
390        return ((signalingChannel != null) && (signalingChannel.connected));
391    }
392
393    /**
394     *  Join a list of Groups and begin keeping track of them.
395     *
396     *  @param groupIDList         An array of IDs of the groups to join
397     *  @param completionListener  A listener to receive a notification of the success or failure of the asynchronous operation
398     */
399    public void joinGroups(final ArrayList<String> groupIDList, final JoinGroupCompletionListener completionListener) {
400        if (isConnected()) {
401            if ((groupIDList != null) && (groupIDList.size() > 0)) {
402                String urlEndpoint = "/v1/groups";
403
404                JSONArray groupList = new JSONArray(groupIDList);
405                JSONObject data = new JSONObject();
406                try {
407                    data.put("groups", groupList);
408
409                    signalingChannel.sendRESTMessage("post", urlEndpoint, data, new RespokeSignalingChannel.RESTListener() {
410                        @Override
411                        public void onSuccess(Object response) {
412                            final ArrayList<RespokeGroup> newGroupList = new ArrayList<RespokeGroup>();
413                            for (String eachGroupID : groupIDList) {
414                                RespokeGroup newGroup = new RespokeGroup(eachGroupID, signalingChannel, RespokeClient.this);
415                                groups.put(eachGroupID, newGroup);
416                                newGroupList.add(newGroup);
417                            }
418
419                            new Handler(Looper.getMainLooper()).post(new Runnable() {
420                                @Override
421                                public void run() {
422                                    if (null != completionListener) {
423                                        completionListener.onSuccess(newGroupList);
424                                    }
425                                }
426                            });
427                        }
428
429                        @Override
430                        public void onError(final String errorMessage) {
431                            postJoinGroupMembersError(completionListener, errorMessage);
432                        }
433                    });
434                } catch (JSONException e) {
435                    postJoinGroupMembersError(completionListener, "Error encoding group list to json");
436                }
437            } else {
438                postJoinGroupMembersError(completionListener, "At least one group must be specified");
439            }
440        } else {
441            postJoinGroupMembersError(completionListener, "Can't complete request when not connected. Please reconnect!");
442        }
443    }
444
445    /**
446     *  Find a Connection by id and return it. In most cases, if we don't find it we will create it. This is useful
447     *  in the case of dynamic endpoints where groups are not in use. Set skipCreate=true to return null
448     *  if the Connection is not already known.
449     *
450     *  @param connectionID The ID of the connection to return
451     *  @param endpointID   The ID of the endpoint to which this connection belongs
452     *  @param skipCreate   If true, return null if the connection is not already known
453     *
454     *  @return The connection whose ID was specified
455     */
456    public RespokeConnection getConnection(String connectionID, String endpointID, boolean skipCreate) {
457        RespokeConnection connection = null;
458
459        if (null != connectionID) {
460            RespokeEndpoint endpoint = getEndpoint(endpointID, skipCreate);
461
462            if (null != endpoint) {
463                for (RespokeConnection eachConnection : endpoint.connections) {
464                    if (eachConnection.connectionID.equals(connectionID)) {
465                        connection = eachConnection;
466                        break;
467                    }
468                }
469
470                if ((null == connection) && (!skipCreate)) {
471                    connection = new RespokeConnection(connectionID, endpoint);
472                    endpoint.connections.add(connection);
473                }
474            }
475        }
476
477        return connection;
478    }
479
480    /**
481     *  Initiate a call to a conference.
482     *
483     *  @param callListener  A listener to receive notifications about the new call
484     *  @param context       An application context with which to access system resources
485     *  @param conferenceID  The ID of the conference to call
486     *
487     *  @return A reference to the new RespokeCall object representing this call
488     */
489    public RespokeCall joinConference(RespokeCall.Listener callListener, Context context, String conferenceID) {
490        RespokeCall call = null;
491
492        if ((null != signalingChannel) && (signalingChannel.connected)) {
493            call = new RespokeCall(signalingChannel, conferenceID, "conference");
494            call.setListener(callListener);
495
496            call.startCall(context, null, true);
497        }
498
499        return call;
500    }
501
502    /**
503     *  Find an endpoint by id and return it. In most cases, if we don't find it we will create it. This is useful
504     *  in the case of dynamic endpoints where groups are not in use. Set skipCreate=true to return null
505     *  if the Endpoint is not already known.
506     *
507     *  @param endpointIDToFind The ID of the endpoint to return
508     *  @param skipCreate       If true, return null if the connection is not already known
509     *
510     *  @return The endpoint whose ID was specified
511     */
512    public RespokeEndpoint getEndpoint(String endpointIDToFind, boolean skipCreate) {
513        RespokeEndpoint endpoint = null;
514
515        if (null != endpointIDToFind) {
516            for (RespokeEndpoint eachEndpoint : knownEndpoints) {
517                if (eachEndpoint.getEndpointID().equals(endpointIDToFind)) {
518                    endpoint = eachEndpoint;
519                    break;
520                }
521            }
522
523            if ((null == endpoint) && (!skipCreate)) {
524                endpoint = new RespokeEndpoint(signalingChannel, endpointIDToFind, this);
525                knownEndpoints.add(endpoint);
526            }
527
528            if (null != endpoint) {
529                queuePresenceRegistration(endpoint.getEndpointID());
530            }
531        }
532
533        return endpoint;
534    }
535
536    /**
537     *  Returns the group with the specified ID
538     *
539     *  @param groupIDToFind  The ID of the group to find
540     *
541     *  @return The group with specified ID, or null if it was not found
542     */
543    public RespokeGroup getGroup(String groupIDToFind) {
544        RespokeGroup group = null;
545
546        if (null != groupIDToFind) {
547            group = groups.get(groupIDToFind);
548        }
549
550        return group;
551    }
552
553    /**
554     * Retrieve the history of messages that have been persisted for 1 or more groups. Only those
555     * messages that have been marked to be persisted when sent will show up in the history. Only
556     * the most recent message in each group will be retrieved - to pull more messages, use the
557     * other method signature that allows `maxMessages` to be specified.
558     *
559     * @param groupIds The groups to pull history for
560     * @param completionListener The callback called when this async operation has completed
561     */
562    public void getGroupHistories(final List<String> groupIds,
563                                  final GroupHistoriesCompletionListener completionListener) {
564        getGroupHistories(groupIds, 1, completionListener);
565    }
566
567    /**
568     * Retrieve the history of messages that have been persisted for 1 or more groups. Only those
569     * messages that have been marked to be persisted when sent will show up in the history.
570     *
571     * Note: This operation returns history for a set of specified groups. If you simply want the
572     * set of conversations that the connected endpoint has stored on the server (without needing to
573     * specify the groups), use the getConversations() method.
574     *
575     * @param groupIds The groups to pull history for
576     * @param maxMessages The maximum number of messages per group to pull. Must be &gt;= 1
577     * @param completionListener The callback called when this async operation has completed
578     */
579    public void getGroupHistories(final List<String> groupIds, final Integer maxMessages,
580                                  final GroupHistoriesCompletionListener completionListener) {
581        if (!isConnected()) {
582            getGroupHistoriesError(completionListener, "Can't complete request when not connected, " +
583                    "Please reconnect!");
584            return;
585        }
586
587        if ((maxMessages == null) || (maxMessages < 1)) {
588            getGroupHistoriesError(completionListener, "maxMessages must be at least 1");
589            return;
590        }
591
592        if ((groupIds == null) || (groupIds.size() == 0)) {
593            getGroupHistoriesError(completionListener, "At least 1 group must be specified");
594            return;
595        }
596
597        JSONObject body = new JSONObject();
598        try {
599            body.put("limit", maxMessages.toString());
600            JSONArray groupIdParams = new JSONArray(groupIds);
601            body.put("groupIds", groupIdParams);
602        } catch(JSONException e) {
603            getGroupHistoriesError(completionListener, "Error forming JSON body to send.");
604            return;
605        }
606
607        // This has been modifed to use the newer group-history-search route over the
608        // deprecated group-histories route.
609        String urlEndpoint = "/v1/group-history-search";
610        signalingChannel.sendRESTMessage("post", urlEndpoint, body,
611                new RespokeSignalingChannel.RESTListener() {
612            @Override
613            public void onSuccess(Object response) {
614                if (!(response instanceof JSONObject)) {
615                    getGroupHistoriesError(completionListener, "Invalid response from server");
616                    return;
617                }
618
619                final JSONObject json = (JSONObject) response;
620                final HashMap<String, List<RespokeGroupMessage>> results = new HashMap<>();
621
622                for (Iterator<String> keys = json.keys(); keys.hasNext();) {
623                    final String key = keys.next();
624
625                    try {
626                        final JSONArray jsonMessages = json.getJSONArray(key);
627
628                        final ArrayList<RespokeGroupMessage> messageList =
629                                new ArrayList<>(jsonMessages.length());
630
631                        for (int i = 0; i < jsonMessages.length(); i++) {
632                            final JSONObject jsonMessage = jsonMessages.getJSONObject(i);
633                            final RespokeGroupMessage message = buildGroupMessage(jsonMessage);
634                            messageList.add(message);
635                        }
636
637                        results.put(key, messageList);
638                    } catch (JSONException e) {
639                        getGroupHistoriesError(completionListener, "Error parsing JSON response");
640                        return;
641                    }
642                }
643
644                new Handler(Looper.getMainLooper()).post(new Runnable() {
645                    @Override
646                    public void run() {
647                        if (completionListener != null) {
648                            completionListener.onSuccess(results);
649                        }
650                    }
651                });
652            }
653
654            @Override
655            public void onError(final String errorMessage) {
656                getGroupHistoriesError(completionListener, errorMessage);
657            }
658        });
659    }
660
661    /**
662     * Retrieve a list of conversations (groupIds) that this endpoint has
663     * message history with. Only group messages that have been marked to be
664     * persisted will show up in history (and thus create a conversation).
665     *
666     * The success handler is passed a list of EndpointConversationInfo records.
667     *
668     * @param completionListener The callback called when this async operation has completed
669     */
670    public void getConversations(final EndpointConversationsCompletionListener completionListener) {
671        if (!isConnected()) {
672            getEndpointConversationsError(completionListener, "Can't complete request when not connected, " +
673                "Please reconnect!");
674            return;
675        }
676
677        String urlEndpoint = "/v1/endpoints/" + localEndpointID + "/conversations";
678        signalingChannel.sendRESTMessage("get", urlEndpoint, null,
679            new RespokeSignalingChannel.RESTListener() {
680                @Override
681                public void onSuccess(Object response) {
682                    if (!(response instanceof JSONArray)) {
683                        getEndpointConversationsError(completionListener, "Invalid response from server");
684                        return;
685                    }
686
687                    final JSONArray json = (JSONArray) response;
688                    final List<EndpointConversationInfo> results = new ArrayList<EndpointConversationInfo>();
689
690                    try {
691                        for (int i = 0; i < json.length(); i++) {
692                            final JSONObject jsonConversationInfo = json.getJSONObject(i);
693
694                            final EndpointConversationInfo info = new EndpointConversationInfo();
695
696                            final JSONObject jsonMessage = jsonConversationInfo.getJSONObject("latestMsg");
697
698                            info.latestMessage = buildGroupMessage(jsonMessage);
699                            info.groupId = jsonConversationInfo.getString("groupId");
700                            info.sourceId = jsonConversationInfo.getString("sourceId");
701                            info.unreadCount = jsonConversationInfo.getInt("unreadCount");
702                            info.timestamp = new Date(jsonConversationInfo.getLong("timestamp"));
703
704                            results.add(info);
705                        }
706                    } catch (JSONException e) {
707                        getEndpointConversationsError(completionListener, "Error parsing JSON response");
708                        return;
709                    }
710
711                    new Handler(Looper.getMainLooper()).post(new Runnable() {
712                        @Override
713                        public void run() {
714                            if (completionListener != null) {
715                                completionListener.onSuccess(results);
716                            }
717                        }
718                    });
719                }
720
721                @Override
722                public void onError(final String errorMessage) {
723                    getEndpointConversationsError(completionListener, errorMessage);
724                }
725            });
726    }
727
728    /**
729     * Mark messages in a conversation as having been read, up to the given timestamp
730     *
731     * @param updates An array of records, each of which indicates a groupId and timestamp of the
732     *                the most recent message the client has "read".
733     * @param completionListener The callback called when this async operation has completed.
734     */
735    public void setConversationsRead(final List<RespokeConversationReadStatus> updates, final Respoke.TaskCompletionListener completionListener) {
736        if (!isConnected()) {
737            Respoke.postTaskError(completionListener, "Can't complete request when not connected, " +
738                "Please reconnect!");
739            return;
740        }
741
742        if ((updates == null) || (updates.size() == 0)) {
743            Respoke.postTaskError(completionListener, "At least 1 conversation must be specified");
744            return;
745        }
746
747        JSONObject body = new JSONObject();
748        JSONArray groupsJsonArray = new JSONArray();
749
750        try {
751            // Add each status object to the array.
752            for (RespokeConversationReadStatus status : updates) {
753                JSONObject jsonStatus = new JSONObject();
754                jsonStatus.put("groupId", status.groupId);
755                jsonStatus.put("timestamp", status.timestamp.toString());
756
757                groupsJsonArray.put(jsonStatus);
758            }
759
760            // Set the array to the 'groups' property.
761            body.put("groups", groupsJsonArray);
762        } catch(JSONException e) {
763            Respoke.postTaskError(completionListener, "Error forming JSON body to send.");
764            return;
765        }
766
767        String urlEndpoint = "/v1/endpoints/" + localEndpointID + "/conversations";
768        signalingChannel.sendRESTMessage("put", urlEndpoint, body,
769            new RespokeSignalingChannel.RESTListener() {
770                @Override
771                public void onSuccess(Object response) {
772                    new Handler(Looper.getMainLooper()).post(new Runnable() {
773                        @Override
774                        public void run() {
775                            if (completionListener != null) {
776                                completionListener.onSuccess();
777                            }
778                        }
779                    });
780                }
781
782                @Override
783                public void onError(final String errorMessage) {
784                    Respoke.postTaskError(completionListener, errorMessage);
785                }
786            });
787    }
788
789    /**
790     * Retrieve the history of messages that have been persisted for a specific group. Only those
791     * messages that have been marked to be persisted when sent will show up in the history. Only
792     * the 50 most recent messages in each group will be retrieved - to change the maximum number of
793     * messages pulled, use the other method signature that allows `maxMessages` to be specified. To
794     * retrieve messages further back in the history than right now, use the other method signature
795     * that allows `before` to be specified.
796     *
797     * @param groupId The groups to pull history for
798     * @param completionListener The callback called when this async operation has completed
799     */
800    public void getGroupHistory(final String groupId,
801                                final GroupHistoryCompletionListener completionListener) {
802        getGroupHistory(groupId, 50, null, completionListener);
803    }
804
805    /**
806     * Retrieve the history of messages that have been persisted for a specific group. Only those
807     * messages that have been marked to be persisted when sent will show up in the history. To
808     * retrieve messages further back in the history than right now, use the other method signature
809     * that allows `before` to be specified.
810     *
811     * @param groupId The groups to pull history for
812     * @param maxMessages The maximum number of messages per group to pull. Must be &gt;= 1
813     * @param completionListener The callback called when this async operation has completed
814     */
815    public void getGroupHistory(final String groupId, final Integer maxMessages,
816                                final GroupHistoryCompletionListener completionListener) {
817        getGroupHistory(groupId, maxMessages, null, completionListener);
818    }
819
820    /**
821     * Retrieve the history of messages that have been persisted for a specific group. Only those
822     * messages that have been marked to be persisted when sent will show up in the history.
823     *
824     * @param groupId The groups to pull history for
825     * @param maxMessages The maximum number of messages per group to pull. Must be &gt;= 1
826     * @param before Limit messages to those with a timestamp before this value
827     * @param completionListener The callback called when this async operation has completed
828     */
829    public void getGroupHistory(final String groupId, final Integer maxMessages, final Date before,
830                                final GroupHistoryCompletionListener completionListener) {
831        if (!isConnected()) {
832            getGroupHistoryError(completionListener, "Can't complete request when not connected, " +
833                    "Please reconnect!");
834            return;
835        }
836
837        if ((maxMessages == null) || (maxMessages < 1)) {
838            getGroupHistoryError(completionListener, "maxMessages must be at least 1");
839            return;
840        }
841
842        if ((groupId == null) || groupId.length() == 0) {
843            getGroupHistoryError(completionListener, "groupId cannot be blank");
844            return;
845        }
846
847        Uri.Builder builder = new Uri.Builder();
848        builder.appendQueryParameter("limit", maxMessages.toString());
849
850        if (before != null) {
851            builder.appendQueryParameter("before", Long.toString(before.getTime()));
852        }
853
854        String urlEndpoint = String.format("/v1/groups/%s/history%s", groupId, builder.build().toString());
855        signalingChannel.sendRESTMessage("get", urlEndpoint, null,
856                new RespokeSignalingChannel.RESTListener() {
857                    @Override
858                    public void onSuccess(Object response) {
859                        if (!(response instanceof JSONArray)) {
860                            getGroupHistoryError(completionListener, "Invalid response from server");
861                            return;
862                        }
863
864                        final JSONArray json = (JSONArray) response;
865                        final ArrayList<RespokeGroupMessage> results = new ArrayList<>(json.length());
866
867                        try {
868                            for (int i = 0; i < json.length(); i++) {
869                                final JSONObject jsonMessage = json.getJSONObject(i);
870                                final RespokeGroupMessage message = buildGroupMessage(jsonMessage);
871                                results.add(message);
872                            }
873                        } catch (JSONException e) {
874                            getGroupHistoryError(completionListener, "Error parsing JSON response");
875                            return;
876                        }
877
878                        new Handler(Looper.getMainLooper()).post(new Runnable() {
879                            @Override
880                            public void run() {
881                                if (completionListener != null) {
882                                    completionListener.onSuccess(results);
883                                }
884                            }
885                        });
886                    }
887
888                    @Override
889                    public void onError(final String errorMessage) {
890                        getGroupHistoryError(completionListener, errorMessage);
891                    }
892                });
893    }
894
895    /**
896     *  Return the Endpoint ID of this client
897     *
898     *  @return The Endpoint ID of this client
899     */
900    public String getEndpointID() {
901        return localEndpointID;
902    }
903
904    /**
905     *  Set the presence on the client session
906     *
907     *  @param newPresence         The new presence to use
908     *  @param completionListener  A listener to receive the notification on the success or failure of the asynchronous operation
909     */
910    public void setPresence(Object newPresence, final Respoke.TaskCompletionListener completionListener) {
911        if (isConnected()) {
912            Object presenceToSet = newPresence;
913
914            if (null == presenceToSet) {
915                presenceToSet = "available";
916            }
917
918            JSONObject typeData = new JSONObject();
919            JSONObject data = new JSONObject();
920
921            try {
922                typeData.put("type", presenceToSet);
923                data.put("presence", typeData);
924
925                final Object finalPresence = presenceToSet;
926
927                signalingChannel.sendRESTMessage("post", "/v1/presence", data, new RespokeSignalingChannel.RESTListener() {
928                    @Override
929                    public void onSuccess(Object response) {
930                        presence = finalPresence;
931
932                       Respoke.postTaskSuccess(completionListener);
933                    }
934
935                    @Override
936                    public void onError(final String errorMessage) {
937                        Respoke.postTaskError(completionListener, errorMessage);
938                    }
939                });
940            } catch (JSONException e) {
941                Respoke.postTaskError(completionListener, "Error encoding presence to json");
942            }
943        } else {
944            Respoke.postTaskError(completionListener, "Can't complete request when not connected. Please reconnect!");
945        }
946    }
947
948    /**
949     *  Get the current presence of this client
950     *
951     *  @return The current presence value
952     */
953    public Object getPresence() {
954        return presence;
955    }
956
957    /**
958     *  Register the client to receive push notifications when the socket is not active
959     *
960     *  @param token  The GCMS token to register
961     */
962    public void registerPushServicesWithToken(final String token) {
963        String httpURI;
964        String httpMethod;
965
966        JSONObject data = new JSONObject();
967        try {
968            data.put("token", token);
969            data.put("service", "google");
970
971            SharedPreferences prefs = appContext.getSharedPreferences(appContext.getPackageName(), Context.MODE_PRIVATE);
972
973            if (null != prefs) {
974                String lastKnownPushToken = prefs.getString(PROPERTY_LAST_VALID_PUSH_TOKEN, "notAvailable");
975                String lastKnownPushTokenID = prefs.getString(PROPERTY_LAST_VALID_PUSH_TOKEN_ID, "notAvailable");
976
977                if ((null == lastKnownPushTokenID) || (lastKnownPushTokenID.equals("notAvailable"))) {
978                    httpURI = String.format("/v1/connections/%s/push-token", localConnectionID);
979                    httpMethod = "post";
980                    createOrUpdatePushServiceToken(token, httpURI, httpMethod, data, prefs);
981                } else if (!lastKnownPushToken.equals("notAvailable") && !lastKnownPushToken.equals(token)) {
982                    httpURI = String.format("/v1/connections/%s/push-token/%s", localConnectionID, lastKnownPushTokenID);
983                    httpMethod = "put";
984                    createOrUpdatePushServiceToken(token, httpURI, httpMethod, data, prefs);
985                }
986            }
987        } catch(JSONException e) {
988            Log.d("", "Invalid JSON format for token");
989        }
990    }
991
992
993    /**
994     *  Unregister this client from the push service so that no more notifications will be received for this endpoint ID
995     *
996     *  @param completionListener  A listener to receive the notification on the success or failure of the asynchronous operation
997     */
998    public void unregisterFromPushServices(final Respoke.TaskCompletionListener completionListener) {
999        if (isConnected()) {
1000            SharedPreferences prefs = appContext.getSharedPreferences(appContext.getPackageName(), Context.MODE_PRIVATE);
1001
1002            if (null != prefs) {
1003                String lastKnownPushTokenID = prefs.getString(PROPERTY_LAST_VALID_PUSH_TOKEN_ID, "notAvailable");
1004
1005                if ((null != lastKnownPushTokenID) && !lastKnownPushTokenID.equals("notAvailable")) {
1006                    // A push token has previously been registered successfully
1007                    String httpURI = String.format("/v1/connections/%s/push-token/%s", localConnectionID, lastKnownPushTokenID);
1008                    signalingChannel.sendRESTMessage("delete", httpURI, null, new RespokeSignalingChannel.RESTListener() {
1009                        @Override
1010                        public void onSuccess(Object response) {
1011                            // Remove the push token ID from shared memory so that push may be registered again in the future
1012                            SharedPreferences prefs = appContext.getSharedPreferences(appContext.getPackageName(), Context.MODE_PRIVATE);
1013                            SharedPreferences.Editor editor = prefs.edit();
1014                            editor.remove(PROPERTY_LAST_VALID_PUSH_TOKEN_ID);
1015                            editor.apply();
1016
1017                            Respoke.postTaskSuccess(completionListener);
1018                        }
1019
1020                        @Override
1021                        public void onError(String errorMessage) {
1022                            Respoke.postTaskError(completionListener, "Error unregistering push service token: " + errorMessage);
1023                        }
1024                    });
1025                } else {
1026                    Respoke.postTaskSuccess(completionListener);
1027                }
1028            } else {
1029                Respoke.postTaskError(completionListener, "Unable to access shared preferences to look for push token");
1030            }
1031        } else {
1032            Respoke.postTaskError(completionListener, "Can't complete request when not connected. Please reconnect!");
1033        }
1034    }
1035
1036    //** Private methods
1037
1038    private void createOrUpdatePushServiceToken(final String token, String httpURI, String httpMethod, JSONObject data, final SharedPreferences prefs) {
1039        signalingChannel.sendRESTMessage(httpMethod, httpURI, data, new RespokeSignalingChannel.RESTListener() {
1040            @Override
1041            public void onSuccess(Object response) {
1042                if (response instanceof JSONObject) {
1043                    try {
1044                        JSONObject responseJSON = (JSONObject) response;
1045                        pushServiceID = responseJSON.getString("id");
1046
1047                        SharedPreferences.Editor editor = prefs.edit();
1048                        editor.putString(PROPERTY_LAST_VALID_PUSH_TOKEN, token);
1049                        editor.putString(PROPERTY_LAST_VALID_PUSH_TOKEN_ID, pushServiceID);
1050                        editor.apply();
1051                    } catch (JSONException e) {
1052                        Log.d(TAG, "Unexpected response from server while registering push service token");
1053                    }
1054                } else {
1055                    Log.d(TAG, "Unexpected response from server while registering push service token");
1056                }
1057            }
1058
1059            @Override
1060            public void onError(String errorMessage) {
1061                Log.d(TAG, "Error registering push service token: " + errorMessage);
1062            }
1063        });
1064    }
1065
1066    /**
1067     *  A convenience method for posting errors to a ConnectCompletionListener
1068     *
1069     *  @param completionListener  The listener to notify
1070     *  @param errorMessage        The human-readable error message that occurred
1071     */
1072    private void postConnectError(final ConnectCompletionListener completionListener, final String errorMessage) {
1073        new Handler(Looper.getMainLooper()).post(new Runnable() {
1074            @Override
1075            public void run() {
1076                if (null != completionListener) {
1077                    completionListener.onError(errorMessage);
1078                }
1079            }
1080        });
1081    }
1082
1083    /**
1084     *  A convenience method for posting errors to a JoinGroupCompletionListener
1085     *
1086     *  @param completionListener  The listener to notify
1087     *  @param errorMessage        The human-readable error message that occurred
1088     */
1089    private void postJoinGroupMembersError(final JoinGroupCompletionListener completionListener, final String errorMessage) {
1090        new Handler(Looper.getMainLooper()).post(new Runnable() {
1091            @Override
1092            public void run() {
1093                if (null != completionListener) {
1094                    completionListener.onError(errorMessage);
1095                }
1096            }
1097        });
1098    }
1099
1100    private void getEndpointConversationsError(final EndpointConversationsCompletionListener completionListener,
1101                                        final String errorMessage) {
1102        new Handler(Looper.getMainLooper()).post(new Runnable() {
1103            @Override
1104            public void run() {
1105                if (completionListener != null) {
1106                    completionListener.onError(errorMessage);
1107                }
1108            }
1109        });
1110    }
1111
1112    private void getGroupHistoriesError(final GroupHistoriesCompletionListener completionListener,
1113                                        final String errorMessage) {
1114        new Handler(Looper.getMainLooper()).post(new Runnable() {
1115            @Override
1116            public void run() {
1117                if (completionListener != null) {
1118                    completionListener.onError(errorMessage);
1119                }
1120            }
1121        });
1122    }
1123
1124    private void getGroupHistoryError(final GroupHistoryCompletionListener completionListener,
1125                                      final String errorMessage) {
1126        new Handler(Looper.getMainLooper()).post(new Runnable() {
1127            @Override
1128            public void run() {
1129                if (completionListener != null) {
1130                    completionListener.onError(errorMessage);
1131                }
1132            }
1133        });
1134    }
1135
1136    /**
1137     *  Attempt to reconnect the client after a small delay
1138     */
1139    private void performReconnect() {
1140        if (null != applicationID) {
1141            reconnectCount++;
1142
1143            new java.util.Timer().schedule(
1144                new java.util.TimerTask() {
1145                    @Override
1146                    public void run() {
1147                        actuallyReconnect();
1148                    }
1149                },
1150                RECONNECT_INTERVAL * (reconnectCount - 1)
1151            );
1152        }
1153    }
1154
1155    /**
1156     *  Attempt to reconnect the client if it is not already trying in another thread
1157     */
1158    private void actuallyReconnect() {
1159        if (((null == signalingChannel) || !signalingChannel.connected) && reconnect) {
1160            if (connectionInProgress) {
1161                // The client app must have initiated a connection manually during the timeout period. Try again later
1162                performReconnect();
1163            } else {
1164                Log.d(TAG, "Trying to reconnect...");
1165                connect(localEndpointID, applicationID, reconnect, presence, appContext, new ConnectCompletionListener() {
1166                    @Override
1167                    public void onError(final String errorMessage) {
1168                        // A REST API call failed. Socket errors are handled in the onError callback
1169                        new Handler(Looper.getMainLooper()).post(new Runnable() {
1170                            @Override
1171                            public void run() {
1172                                Listener listener = listenerReference.get();
1173                                if (null != listener) {
1174                                    listener.onError(RespokeClient.this, errorMessage);
1175                                }
1176                            }
1177                        });
1178
1179                        // Try again later
1180                        performReconnect();
1181                    }
1182                });
1183            }
1184        }
1185    }
1186
1187    /**
1188     *  Register for presence updates for the specified endpoint ID. Registration will not occur immediately, 
1189     *  it will be queued and performed asynchronously. Queuing allows for large numbers of presence 
1190     *  registration requests to occur in batches, minimizing the number of network transactions (and overall 
1191     *  time required).
1192     *
1193     *  @param endpointID  The ID of the endpoint for which to register for presence updates
1194     */
1195    private void queuePresenceRegistration(String endpointID) {
1196        if (null != endpointID) {
1197            Boolean shouldSpawnRegistrationTask = false;
1198
1199            synchronized (this) {
1200                Boolean alreadyRegistered = presenceRegistered.get(endpointID);
1201                if ((null == alreadyRegistered) || !alreadyRegistered) {
1202                    presenceRegistrationQueue.add(endpointID);
1203
1204                    // If a Runnable to register presence has not already been scheduled, note that one will be shortly
1205                    if (!registrationTaskWaiting) {
1206                        shouldSpawnRegistrationTask = true;
1207                        registrationTaskWaiting = true;
1208                    }
1209                }
1210            }
1211
1212            if (shouldSpawnRegistrationTask) {
1213                // 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
1214                new Handler(Looper.getMainLooper()).post(new Runnable() {
1215                    @Override
1216                    public void run() {
1217                        final HashMap<String, Boolean> endpointIDMap = new HashMap<String, Boolean>();
1218
1219                        synchronized (this) {
1220                            // 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
1221                            while (presenceRegistrationQueue.size() > 0) {
1222                                String nextEndpointID = presenceRegistrationQueue.remove(0);
1223                                Boolean alreadyRegistered = presenceRegistered.get(nextEndpointID);
1224                                if ((null == alreadyRegistered) || !alreadyRegistered) {
1225                                    endpointIDMap.put(nextEndpointID, true);
1226                                }
1227                            }
1228
1229                            // 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
1230                            registrationTaskWaiting = false;
1231                        }
1232
1233                        // Build an array from the map keySet to ensure there are no duplicates in the list
1234                        final ArrayList<String> endpointIDsToRegister = new ArrayList<String>(endpointIDMap.keySet());
1235
1236                        if ((endpointIDsToRegister.size() > 0) && isConnected()) {
1237                            signalingChannel.registerPresence(endpointIDsToRegister, new RespokeSignalingChannel.RegisterPresenceListener() {
1238                                @Override
1239                                public void onSuccess(JSONArray initialPresenceData) {
1240                                    // Indicate that registration was successful for each endpoint ID in the list
1241                                    synchronized (RespokeClient.this) {
1242                                        for (String eachID : endpointIDsToRegister) {
1243                                            presenceRegistered.put(eachID, true);
1244                                        }
1245                                    }
1246
1247                                    if (null != initialPresenceData) {
1248                                        for (int ii = 0; ii < initialPresenceData.length(); ii++) {
1249                                            try {
1250                                                JSONObject eachEndpointData = (JSONObject) initialPresenceData.get(ii);
1251                                                String dataEndpointID = eachEndpointData.getString("endpointId");
1252                                                RespokeEndpoint endpoint = getEndpoint(dataEndpointID, true);
1253
1254                                                if (null != endpoint) {
1255                                                    JSONObject connectionData = eachEndpointData.getJSONObject("connectionStates");
1256                                                    Iterator<?> keys = connectionData.keys();
1257
1258                                                    while (keys.hasNext()) {
1259                                                        String eachConnectionID = (String) keys.next();
1260                                                        JSONObject presenceDict = connectionData.getJSONObject(eachConnectionID);
1261                                                        Object newPresence = presenceDict.get("type");
1262                                                        RespokeConnection connection = endpoint.getConnection(eachConnectionID, false);
1263
1264                                                        if ((null != connection) && (null != newPresence)) {
1265                                                            connection.presence = newPresence;
1266                                                        }
1267                                                    }
1268                                                }
1269                                            } catch (JSONException e) {
1270                                                // Silently skip this problem
1271                                            }
1272                                        }
1273                                    }
1274
1275                                    for (String eachID : endpointIDsToRegister) {
1276                                        RespokeEndpoint endpoint = getEndpoint(eachID, true);
1277                                        
1278                                        if (null != endpoint) {
1279                                            endpoint.resolvePresence();
1280                                        }
1281                                    }
1282                                }
1283
1284                                @Override
1285                                public void onError(final String errorMessage) {
1286                                    Log.d(TAG, "Error registering presence: " + errorMessage);
1287                                }
1288                            });
1289                        }
1290                    }
1291                });
1292            }
1293        }
1294    }
1295
1296    // RespokeSignalingChannelListener methods
1297    public void onConnect(RespokeSignalingChannel sender, String endpointID, String connectionID) {
1298        connectionInProgress = false;
1299        reconnectCount = 0;
1300        localEndpointID = endpointID;
1301        localConnectionID = connectionID;
1302
1303        Respoke.sharedInstance().clientConnected(this);
1304
1305        // Try to set the presence to the initial or last set state
1306        setPresence(presence, new Respoke.TaskCompletionListener() {
1307            @Override
1308            public void onSuccess() {
1309                // do nothing
1310            }
1311
1312            @Override
1313            public void onError(String errorMessage) {
1314                // do nothing
1315            }
1316        });
1317
1318        new Handler(Looper.getMainLooper()).post(new Runnable() {
1319            @Override
1320            public void run() {
1321                Listener listener = listenerReference.get();
1322                if (null != listener) {
1323                    listener.onConnect(RespokeClient.this);
1324                }
1325            }
1326        });
1327    }
1328
1329    public void onDisconnect(RespokeSignalingChannel sender) {
1330        // Can only reconnect in development mode, not brokered mode
1331        final boolean willReconnect = reconnect && (applicationID != null);
1332
1333        calls.clear();
1334        groups.clear();
1335        knownEndpoints.clear();
1336        presenceRegistrationQueue.clear();
1337        presenceRegistered.clear();
1338        registrationTaskWaiting = false;
1339
1340        new Handler(Looper.getMainLooper()).post(new Runnable() {
1341            @Override
1342            public void run() {
1343                Listener listener = listenerReference.get();
1344                if (null != listener) {
1345                    listener.onDisconnect(RespokeClient.this, willReconnect);
1346                }
1347            }
1348        });
1349
1350        signalingChannel = null;
1351
1352        if (willReconnect) {
1353            performReconnect();
1354        }
1355    }
1356
1357    public void onIncomingCall(JSONObject sdp, String sessionID, String connectionID, String endpointID, String fromType, Date timestamp, RespokeSignalingChannel sender) {
1358        RespokeEndpoint endpoint = null;
1359
1360        if (fromType.equals("web")) {
1361            /* Only create endpoints for type web */
1362            endpoint = getEndpoint(endpointID, false);
1363
1364            if (null == endpoint) {
1365                Log.d(TAG, "Error: Could not create Endpoint for incoming call");
1366                return;
1367            }
1368        }
1369
1370        final RespokeCall call = new RespokeCall(signalingChannel, sdp, sessionID, connectionID, endpointID, fromType, endpoint, false, timestamp);
1371
1372        new Handler(Looper.getMainLooper()).post(new Runnable() {
1373            @Override
1374            public void run() {
1375                Listener listener = listenerReference.get();
1376                if (null != listener) {
1377                    listener.onCall(RespokeClient.this, call);
1378                }
1379            }
1380        });
1381    }
1382
1383    public void onIncomingDirectConnection(JSONObject sdp, String sessionID, String connectionID, String endpointID, Date timestamp, RespokeSignalingChannel sender) {
1384        RespokeEndpoint endpoint = getEndpoint(endpointID, false);
1385
1386        if (null != endpoint) {
1387            final RespokeCall call = new RespokeCall(signalingChannel, sdp, sessionID, connectionID, endpointID, "web", endpoint, true, timestamp);
1388        } else {
1389            Log.d(TAG, "Error: Could not create Endpoint for incoming direct connection");
1390        }
1391    }
1392
1393    public void onError(final String errorMessage, RespokeSignalingChannel sender) {
1394        new Handler(Looper.getMainLooper()).post(new Runnable() {
1395            @Override
1396            public void run() {
1397                Listener listener = listenerReference.get();
1398                if (null != listener) {
1399                    listener.onError(RespokeClient.this, errorMessage);
1400                }
1401            }
1402        });
1403
1404        if ((null != signalingChannel) && (!signalingChannel.connected)) {
1405            connectionInProgress = false;
1406
1407            if (reconnect) {
1408                performReconnect();
1409            }
1410        }
1411    }
1412
1413    public void onJoinGroup(String groupID, String endpointID, String connectionID, RespokeSignalingChannel sender) {
1414        // only pass on notifications about people other than ourselves
1415        if ((null != endpointID) && (!endpointID.equals(localEndpointID))) {
1416            RespokeGroup group = groups.get(groupID);
1417
1418            if (null != group) {
1419                // Get the existing instance for this connection, or create a new one if necessary
1420                RespokeConnection connection = getConnection(connectionID, endpointID, false);
1421
1422                if (null != connection) {
1423                    group.connectionDidJoin(connection);
1424                }
1425            }
1426        }
1427    }
1428
1429    public void onLeaveGroup(String groupID, String endpointID, String connectionID, RespokeSignalingChannel sender) {
1430        // only pass on notifications about people other than ourselves
1431        if ((null != endpointID) && (!endpointID.equals(localEndpointID))) {
1432            RespokeGroup group = groups.get(groupID);
1433
1434            if (null != group) {
1435                // Get the existing instance for this connection. If we are not already aware of it, ignore it
1436                RespokeConnection connection = getConnection(connectionID, endpointID, true);
1437
1438                if (null != connection) {
1439                    group.connectionDidLeave(connection);
1440                }
1441            }
1442        }
1443    }
1444
1445    private void didReceiveMessage(final RespokeEndpoint endpoint, final String message, final Date timestamp) {
1446        new Handler(Looper.getMainLooper()).post(new Runnable() {
1447            @Override
1448            public void run() {
1449                if (null != listenerReference) {
1450                    Listener listener = listenerReference.get();
1451                    if (null != listener) {
1452                        listener.onMessage(message, endpoint, null, timestamp, false);
1453                    }
1454                }
1455            }
1456        });
1457    }
1458
1459    private void didSendMessage(final RespokeEndpoint endpoint, final String message, final Date timestamp) {
1460        new Handler(Looper.getMainLooper()).post(new Runnable() {
1461            @Override
1462            public void run() {
1463                if (null != listenerReference) {
1464                    Listener listener = listenerReference.get();
1465                    if (null != listener) {
1466                        listener.onMessage(message, endpoint, null, timestamp, true);
1467                    }
1468                }
1469            }
1470        });
1471    }
1472
1473    public void onMessage(final String message, final Date timestamp, String fromEndpointID, String toEndpointID, RespokeSignalingChannel sender) {
1474
1475        if (localEndpointID.equals(fromEndpointID) && (null != toEndpointID)) {
1476            // The local endpoint sent this message to the remote endpoint from another device (ccSelf)
1477            final RespokeEndpoint endpoint = getEndpoint(toEndpointID, false);
1478            if (null != endpoint) {
1479                endpoint.didReceiveMessage(message, timestamp);
1480
1481                // Notify the client listener of the message
1482                didReceiveMessage(endpoint, message, timestamp);
1483            }
1484            return;
1485        }
1486
1487        // The local endpoint received this message from a remote endpoint
1488        final RespokeEndpoint endpoint = getEndpoint(fromEndpointID, false);
1489        if (null != endpoint) {
1490            endpoint.didSendMessage(message, timestamp);
1491
1492            // Notify the client listener of the message
1493            didSendMessage(endpoint, message, timestamp);
1494        }
1495    }
1496
1497    public void onGroupMessage(final String message, String groupID, String endpointID, RespokeSignalingChannel sender, final Date timestamp) {
1498        final RespokeGroup group = groups.get(groupID);
1499
1500        if (null != group) {
1501            final RespokeEndpoint endpoint = getEndpoint(endpointID, false);
1502
1503            // Notify the group of the new message
1504            group.didReceiveMessage(message, endpoint, timestamp);
1505
1506            // Notify the client listener of the group message
1507            new Handler(Looper.getMainLooper()).post(new Runnable() {
1508                @Override
1509                public void run() {
1510                    if (null != listenerReference) {
1511                        Listener listener = listenerReference.get();
1512                        if (null != listener) {
1513                            listener.onMessage(message, endpoint, group, timestamp, null);
1514                        }
1515                    }
1516                }
1517            });
1518        }
1519    }
1520
1521    public void onPresence(Object presence, String connectionID, String endpointID, RespokeSignalingChannel sender) {
1522        RespokeConnection connection = getConnection(connectionID, endpointID, false);
1523
1524        if (null != connection) {
1525            connection.presence = presence;
1526
1527            RespokeEndpoint endpoint = connection.getEndpoint();
1528            endpoint.resolvePresence();
1529        }
1530    }
1531
1532    public void callCreated(RespokeCall call) {
1533        calls.add(call);
1534    }
1535
1536    public void callTerminated(RespokeCall call) {
1537        calls.remove(call);
1538    }
1539
1540    public RespokeCall callWithID(String sessionID) {
1541        RespokeCall call = null;
1542
1543        for (RespokeCall eachCall : calls) {
1544            if (eachCall.getSessionID().equals(sessionID)) {
1545                call = eachCall;
1546                break;
1547            }
1548        }
1549
1550        return call;
1551    }
1552
1553    public void directConnectionAvailable(final RespokeDirectConnection directConnection, final RespokeEndpoint endpoint) {
1554        new Handler(Looper.getMainLooper()).post(new Runnable() {
1555            @Override
1556            public void run() {
1557                if (null != listenerReference) {
1558                    Listener listener = listenerReference.get();
1559                    if (null != listener) {
1560                        listener.onIncomingDirectConnection(directConnection, endpoint);
1561                    }
1562                }
1563            }
1564        });
1565    }
1566
1567    /**
1568     * Build a group message from a JSON object. The format of the JSON object would be the
1569     * format that comes over the wire from Respoke when receiving a pubsub message. This same
1570     * format is used when retrieving message history.
1571     *
1572     * @param source The source JSON object to build the RespokeGroupMessage from
1573     * @return The built RespokeGroupMessage
1574     * @throws JSONException
1575     */
1576    private RespokeGroupMessage buildGroupMessage(JSONObject source) throws JSONException {
1577        if (source == null) {
1578            throw new IllegalArgumentException("source cannot be null");
1579        }
1580
1581        final JSONObject header = source.getJSONObject("header");
1582        final String endpointID = header.getString("from");
1583        final RespokeEndpoint endpoint = getEndpoint(endpointID, false);
1584        final String groupID = header.getString("channel");
1585        RespokeGroup group = getGroup(groupID);
1586
1587        if (group == null) {
1588            group = new RespokeGroup(groupID, signalingChannel, this, false);
1589            groups.put(groupID, group);
1590        }
1591
1592        final String message = source.getString("message");
1593
1594        final Date timestamp;
1595
1596        if (!header.isNull("timestamp")) {
1597            timestamp = new Date(header.getLong("timestamp"));
1598        } else {
1599            // Just use the current time if no date is specified in the header data
1600            timestamp = new Date();
1601        }
1602
1603        return new RespokeGroupMessage(message, group, endpoint, timestamp);
1604    }
1605}