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