1/*
2 *  Copyright 2014 The WebRTC Project Authors. All rights reserved.
3 *
4 *  Use of this source code is governed by a BSD-style license
5 *  that can be found in the LICENSE file in the root of the source
6 *  tree. An additional intellectual property rights grant can be found
7 *  in the file PATENTS.  All contributing project authors may
8 *  be found in the AUTHORS file in the root of the source tree.
9 */
10
11package org.appspot.apprtc;
12
13import org.appspot.apprtc.RoomParametersFetcher.RoomParametersFetcherEvents;
14import org.appspot.apprtc.WebSocketChannelClient.WebSocketChannelEvents;
15import org.appspot.apprtc.WebSocketChannelClient.WebSocketConnectionState;
16import org.appspot.apprtc.util.AsyncHttpURLConnection;
17import org.appspot.apprtc.util.AsyncHttpURLConnection.AsyncHttpEvents;
18import org.appspot.apprtc.util.LooperExecutor;
19
20import android.util.Log;
21
22import org.json.JSONException;
23import org.json.JSONObject;
24import org.webrtc.IceCandidate;
25import org.webrtc.SessionDescription;
26
27/**
28 * Negotiates signaling for chatting with apprtc.appspot.com "rooms".
29 * Uses the client<->server specifics of the apprtc AppEngine webapp.
30 *
31 * <p>To use: create an instance of this object (registering a message handler) and
32 * call connectToRoom().  Once room connection is established
33 * onConnectedToRoom() callback with room parameters is invoked.
34 * Messages to other party (with local Ice candidates and answer SDP) can
35 * be sent after WebSocket connection is established.
36 */
37public class WebSocketRTCClient implements AppRTCClient,
38    WebSocketChannelEvents {
39  private static final String TAG = "WSRTCClient";
40  private static final String ROOM_JOIN = "join";
41  private static final String ROOM_MESSAGE = "message";
42  private static final String ROOM_LEAVE = "leave";
43
44  private enum ConnectionState {
45    NEW, CONNECTED, CLOSED, ERROR
46  };
47  private enum MessageType {
48    MESSAGE, LEAVE
49  };
50  private final LooperExecutor executor;
51  private boolean initiator;
52  private SignalingEvents events;
53  private WebSocketChannelClient wsClient;
54  private ConnectionState roomState;
55  private RoomConnectionParameters connectionParameters;
56  private String messageUrl;
57  private String leaveUrl;
58
59  public WebSocketRTCClient(SignalingEvents events, LooperExecutor executor) {
60    this.events = events;
61    this.executor = executor;
62    roomState = ConnectionState.NEW;
63    executor.requestStart();
64  }
65
66  // --------------------------------------------------------------------
67  // AppRTCClient interface implementation.
68  // Asynchronously connect to an AppRTC room URL using supplied connection
69  // parameters, retrieves room parameters and connect to WebSocket server.
70  @Override
71  public void connectToRoom(RoomConnectionParameters connectionParameters) {
72    this.connectionParameters = connectionParameters;
73    executor.execute(new Runnable() {
74      @Override
75      public void run() {
76        connectToRoomInternal();
77      }
78    });
79  }
80
81  @Override
82  public void disconnectFromRoom() {
83    executor.execute(new Runnable() {
84      @Override
85      public void run() {
86        disconnectFromRoomInternal();
87      }
88    });
89    executor.requestStop();
90  }
91
92  // Connects to room - function runs on a local looper thread.
93  private void connectToRoomInternal() {
94    String connectionUrl = getConnectionUrl(connectionParameters);
95    Log.d(TAG, "Connect to room: " + connectionUrl);
96    roomState = ConnectionState.NEW;
97    wsClient = new WebSocketChannelClient(executor, this);
98
99    RoomParametersFetcherEvents callbacks = new RoomParametersFetcherEvents() {
100      @Override
101      public void onSignalingParametersReady(
102          final SignalingParameters params) {
103        WebSocketRTCClient.this.executor.execute(new Runnable() {
104          @Override
105          public void run() {
106            WebSocketRTCClient.this.signalingParametersReady(params);
107          }
108        });
109      }
110
111      @Override
112      public void onSignalingParametersError(String description) {
113        WebSocketRTCClient.this.reportError(description);
114      }
115    };
116
117    new RoomParametersFetcher(connectionUrl, null, callbacks).makeRequest();
118  }
119
120  // Disconnect from room and send bye messages - runs on a local looper thread.
121  private void disconnectFromRoomInternal() {
122    Log.d(TAG, "Disconnect. Room state: " + roomState);
123    if (roomState == ConnectionState.CONNECTED) {
124      Log.d(TAG, "Closing room.");
125      sendPostMessage(MessageType.LEAVE, leaveUrl, null);
126    }
127    roomState = ConnectionState.CLOSED;
128    if (wsClient != null) {
129      wsClient.disconnect(true);
130    }
131  }
132
133  // Helper functions to get connection, post message and leave message URLs
134  private String getConnectionUrl(
135      RoomConnectionParameters connectionParameters) {
136    return connectionParameters.roomUrl + "/" + ROOM_JOIN + "/"
137        + connectionParameters.roomId;
138  }
139
140  private String getMessageUrl(RoomConnectionParameters connectionParameters,
141      SignalingParameters signalingParameters) {
142    return connectionParameters.roomUrl + "/" + ROOM_MESSAGE + "/"
143      + connectionParameters.roomId + "/" + signalingParameters.clientId;
144  }
145
146  private String getLeaveUrl(RoomConnectionParameters connectionParameters,
147      SignalingParameters signalingParameters) {
148    return connectionParameters.roomUrl + "/" + ROOM_LEAVE + "/"
149        + connectionParameters.roomId + "/" + signalingParameters.clientId;
150  }
151
152  // Callback issued when room parameters are extracted. Runs on local
153  // looper thread.
154  private void signalingParametersReady(
155      final SignalingParameters signalingParameters) {
156    Log.d(TAG, "Room connection completed.");
157    if (connectionParameters.loopback
158        && (!signalingParameters.initiator
159            || signalingParameters.offerSdp != null)) {
160      reportError("Loopback room is busy.");
161      return;
162    }
163    if (!connectionParameters.loopback
164        && !signalingParameters.initiator
165        && signalingParameters.offerSdp == null) {
166      Log.w(TAG, "No offer SDP in room response.");
167    }
168    initiator = signalingParameters.initiator;
169    messageUrl = getMessageUrl(connectionParameters, signalingParameters);
170    leaveUrl = getLeaveUrl(connectionParameters, signalingParameters);
171    Log.d(TAG, "Message URL: " + messageUrl);
172    Log.d(TAG, "Leave URL: " + leaveUrl);
173    roomState = ConnectionState.CONNECTED;
174
175    // Fire connection and signaling parameters events.
176    events.onConnectedToRoom(signalingParameters);
177
178    // Connect and register WebSocket client.
179    wsClient.connect(signalingParameters.wssUrl, signalingParameters.wssPostUrl);
180    wsClient.register(connectionParameters.roomId, signalingParameters.clientId);
181  }
182
183  // Send local offer SDP to the other participant.
184  @Override
185  public void sendOfferSdp(final SessionDescription sdp) {
186    executor.execute(new Runnable() {
187      @Override
188      public void run() {
189        if (roomState != ConnectionState.CONNECTED) {
190          reportError("Sending offer SDP in non connected state.");
191          return;
192        }
193        JSONObject json = new JSONObject();
194        jsonPut(json, "sdp", sdp.description);
195        jsonPut(json, "type", "offer");
196        sendPostMessage(MessageType.MESSAGE, messageUrl, json.toString());
197        if (connectionParameters.loopback) {
198          // In loopback mode rename this offer to answer and route it back.
199          SessionDescription sdpAnswer = new SessionDescription(
200              SessionDescription.Type.fromCanonicalForm("answer"),
201              sdp.description);
202          events.onRemoteDescription(sdpAnswer);
203        }
204      }
205    });
206  }
207
208  // Send local answer SDP to the other participant.
209  @Override
210  public void sendAnswerSdp(final SessionDescription sdp) {
211    executor.execute(new Runnable() {
212      @Override
213      public void run() {
214        if (connectionParameters.loopback) {
215          Log.e(TAG, "Sending answer in loopback mode.");
216          return;
217        }
218        JSONObject json = new JSONObject();
219        jsonPut(json, "sdp", sdp.description);
220        jsonPut(json, "type", "answer");
221        wsClient.send(json.toString());
222      }
223    });
224  }
225
226  // Send Ice candidate to the other participant.
227  @Override
228  public void sendLocalIceCandidate(final IceCandidate candidate) {
229    executor.execute(new Runnable() {
230      @Override
231      public void run() {
232        JSONObject json = new JSONObject();
233        jsonPut(json, "type", "candidate");
234        jsonPut(json, "label", candidate.sdpMLineIndex);
235        jsonPut(json, "id", candidate.sdpMid);
236        jsonPut(json, "candidate", candidate.sdp);
237        if (initiator) {
238          // Call initiator sends ice candidates to GAE server.
239          if (roomState != ConnectionState.CONNECTED) {
240            reportError("Sending ICE candidate in non connected state.");
241            return;
242          }
243          sendPostMessage(MessageType.MESSAGE, messageUrl, json.toString());
244          if (connectionParameters.loopback) {
245            events.onRemoteIceCandidate(candidate);
246          }
247        } else {
248          // Call receiver sends ice candidates to websocket server.
249          wsClient.send(json.toString());
250        }
251      }
252    });
253  }
254
255  // --------------------------------------------------------------------
256  // WebSocketChannelEvents interface implementation.
257  // All events are called by WebSocketChannelClient on a local looper thread
258  // (passed to WebSocket client constructor).
259  @Override
260  public void onWebSocketMessage(final String msg) {
261    if (wsClient.getState() != WebSocketConnectionState.REGISTERED) {
262      Log.e(TAG, "Got WebSocket message in non registered state.");
263      return;
264    }
265    try {
266      JSONObject json = new JSONObject(msg);
267      String msgText = json.getString("msg");
268      String errorText = json.optString("error");
269      if (msgText.length() > 0) {
270        json = new JSONObject(msgText);
271        String type = json.optString("type");
272        if (type.equals("candidate")) {
273          IceCandidate candidate = new IceCandidate(
274              json.getString("id"),
275              json.getInt("label"),
276              json.getString("candidate"));
277          events.onRemoteIceCandidate(candidate);
278        } else if (type.equals("answer")) {
279          if (initiator) {
280            SessionDescription sdp = new SessionDescription(
281                SessionDescription.Type.fromCanonicalForm(type),
282                json.getString("sdp"));
283            events.onRemoteDescription(sdp);
284          } else {
285            reportError("Received answer for call initiator: " + msg);
286          }
287        } else if (type.equals("offer")) {
288          if (!initiator) {
289            SessionDescription sdp = new SessionDescription(
290                SessionDescription.Type.fromCanonicalForm(type),
291                json.getString("sdp"));
292            events.onRemoteDescription(sdp);
293          } else {
294            reportError("Received offer for call receiver: " + msg);
295          }
296        } else if (type.equals("bye")) {
297          events.onChannelClose();
298        } else {
299          reportError("Unexpected WebSocket message: " + msg);
300        }
301      } else {
302        if (errorText != null && errorText.length() > 0) {
303          reportError("WebSocket error message: " + errorText);
304        } else {
305          reportError("Unexpected WebSocket message: " + msg);
306        }
307      }
308    } catch (JSONException e) {
309      reportError("WebSocket message JSON parsing error: " + e.toString());
310    }
311  }
312
313  @Override
314  public void onWebSocketClose() {
315    events.onChannelClose();
316  }
317
318  @Override
319  public void onWebSocketError(String description) {
320    reportError("WebSocket error: " + description);
321  }
322
323  // --------------------------------------------------------------------
324  // Helper functions.
325  private void reportError(final String errorMessage) {
326    Log.e(TAG, errorMessage);
327    executor.execute(new Runnable() {
328      @Override
329      public void run() {
330        if (roomState != ConnectionState.ERROR) {
331          roomState = ConnectionState.ERROR;
332          events.onChannelError(errorMessage);
333        }
334      }
335    });
336  }
337
338  // Put a |key|->|value| mapping in |json|.
339  private static void jsonPut(JSONObject json, String key, Object value) {
340    try {
341      json.put(key, value);
342    } catch (JSONException e) {
343      throw new RuntimeException(e);
344    }
345  }
346
347  // Send SDP or ICE candidate to a room server.
348  private void sendPostMessage(
349      final MessageType messageType, final String url, final String message) {
350    String logInfo = url;
351    if (message != null) {
352      logInfo += ". Message: " + message;
353    }
354    Log.d(TAG, "C->GAE: " + logInfo);
355    AsyncHttpURLConnection httpConnection = new AsyncHttpURLConnection(
356      "POST", url, message, new AsyncHttpEvents() {
357        @Override
358        public void onHttpError(String errorMessage) {
359          reportError("GAE POST error: " + errorMessage);
360        }
361
362        @Override
363        public void onHttpComplete(String response) {
364          if (messageType == MessageType.MESSAGE) {
365            try {
366              JSONObject roomJson = new JSONObject(response);
367              String result = roomJson.getString("result");
368              if (!result.equals("SUCCESS")) {
369                reportError("GAE POST error: " + result);
370              }
371            } catch (JSONException e) {
372              reportError("GAE POST JSON error: " + e.toString());
373            }
374          }
375        }
376      });
377    httpConnection.send();
378  }
379}
380