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 org.appspot.apprtc.RoomParametersFetcher.RoomParametersFetcherEvents; 14 import org.appspot.apprtc.WebSocketChannelClient.WebSocketChannelEvents; 15 import org.appspot.apprtc.WebSocketChannelClient.WebSocketConnectionState; 16 import org.appspot.apprtc.util.AsyncHttpURLConnection; 17 import org.appspot.apprtc.util.AsyncHttpURLConnection.AsyncHttpEvents; 18 import org.appspot.apprtc.util.LooperExecutor; 19 20 import android.util.Log; 21 22 import org.json.JSONException; 23 import org.json.JSONObject; 24 import org.webrtc.IceCandidate; 25 import 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 */ 37 public 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