1 /* 2 * libjingle 3 * Copyright 2013, Google Inc. 4 * 5 * Redistribution and use in source and binary forms, with or without 6 * modification, are permitted provided that the following conditions are met: 7 * 8 * 1. Redistributions of source code must retain the above copyright notice, 9 * this list of conditions and the following disclaimer. 10 * 2. Redistributions in binary form must reproduce the above copyright notice, 11 * this list of conditions and the following disclaimer in the documentation 12 * and/or other materials provided with the distribution. 13 * 3. The name of the author may not be used to endorse or promote products 14 * derived from this software without specific prior written permission. 15 * 16 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED 17 * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 18 * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 19 * EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 20 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 21 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 22 * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 23 * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 24 * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 25 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 */ 27 28 package org.appspot.apprtc; 29 30 import android.app.Activity; 31 import android.os.AsyncTask; 32 import android.util.Log; 33 34 import org.json.JSONArray; 35 import org.json.JSONException; 36 import org.json.JSONObject; 37 import org.webrtc.MediaConstraints; 38 import org.webrtc.PeerConnection; 39 40 import java.io.IOException; 41 import java.io.InputStream; 42 import java.net.HttpURLConnection; 43 import java.net.URL; 44 import java.net.URLConnection; 45 import java.util.LinkedList; 46 import java.util.List; 47 import java.util.Scanner; 48 49 /** 50 * Negotiates signaling for chatting with apprtc.appspot.com "rooms". 51 * Uses the client<->server specifics of the apprtc AppEngine webapp. 52 * 53 * To use: create an instance of this object (registering a message handler) and 54 * call connectToRoom(). Once that's done call sendMessage() and wait for the 55 * registered handler to be called with received messages. 56 */ 57 public class AppRTCClient { 58 private static final String TAG = "AppRTCClient"; 59 private GAEChannelClient channelClient; 60 private final Activity activity; 61 private final GAEChannelClient.MessageHandler gaeHandler; 62 private final IceServersObserver iceServersObserver; 63 64 // These members are only read/written under sendQueue's lock. 65 private LinkedList<String> sendQueue = new LinkedList<String>(); 66 private AppRTCSignalingParameters appRTCSignalingParameters; 67 68 /** 69 * Callback fired once the room's signaling parameters specify the set of 70 * ICE servers to use. 71 */ 72 public static interface IceServersObserver { 73 public void onIceServers(List<PeerConnection.IceServer> iceServers); 74 } 75 76 public AppRTCClient( 77 Activity activity, GAEChannelClient.MessageHandler gaeHandler, 78 IceServersObserver iceServersObserver) { 79 this.activity = activity; 80 this.gaeHandler = gaeHandler; 81 this.iceServersObserver = iceServersObserver; 82 } 83 84 /** 85 * Asynchronously connect to an AppRTC room URL, e.g. 86 * https://apprtc.appspot.com/?r=NNN and register message-handling callbacks 87 * on its GAE Channel. 88 */ 89 public void connectToRoom(String url) { 90 while (url.indexOf('?') < 0) { 91 // Keep redirecting until we get a room number. 92 (new RedirectResolver()).execute(url); 93 return; // RedirectResolver above calls us back with the next URL. 94 } 95 (new RoomParameterGetter()).execute(url); 96 } 97 98 /** 99 * Disconnect from the GAE Channel. 100 */ 101 public void disconnect() { 102 if (channelClient != null) { 103 channelClient.close(); 104 channelClient = null; 105 } 106 } 107 108 /** 109 * Queue a message for sending to the room's channel and send it if already 110 * connected (other wise queued messages are drained when the channel is 111 eventually established). 112 */ 113 public synchronized void sendMessage(String msg) { 114 synchronized (sendQueue) { 115 sendQueue.add(msg); 116 } 117 requestQueueDrainInBackground(); 118 } 119 120 public boolean isInitiator() { 121 return appRTCSignalingParameters.initiator; 122 } 123 124 public MediaConstraints pcConstraints() { 125 return appRTCSignalingParameters.pcConstraints; 126 } 127 128 public MediaConstraints videoConstraints() { 129 return appRTCSignalingParameters.videoConstraints; 130 } 131 132 public MediaConstraints audioConstraints() { 133 return appRTCSignalingParameters.audioConstraints; 134 } 135 136 // Struct holding the signaling parameters of an AppRTC room. 137 private class AppRTCSignalingParameters { 138 public final List<PeerConnection.IceServer> iceServers; 139 public final String gaeBaseHref; 140 public final String channelToken; 141 public final String postMessageUrl; 142 public final boolean initiator; 143 public final MediaConstraints pcConstraints; 144 public final MediaConstraints videoConstraints; 145 public final MediaConstraints audioConstraints; 146 147 public AppRTCSignalingParameters( 148 List<PeerConnection.IceServer> iceServers, 149 String gaeBaseHref, String channelToken, String postMessageUrl, 150 boolean initiator, MediaConstraints pcConstraints, 151 MediaConstraints videoConstraints, MediaConstraints audioConstraints) { 152 this.iceServers = iceServers; 153 this.gaeBaseHref = gaeBaseHref; 154 this.channelToken = channelToken; 155 this.postMessageUrl = postMessageUrl; 156 this.initiator = initiator; 157 this.pcConstraints = pcConstraints; 158 this.videoConstraints = videoConstraints; 159 this.audioConstraints = audioConstraints; 160 } 161 } 162 163 // Load the given URL and return the value of the Location header of the 164 // resulting 302 response. If the result is not a 302, throws. 165 private class RedirectResolver extends AsyncTask<String, Void, String> { 166 @Override 167 protected String doInBackground(String... urls) { 168 if (urls.length != 1) { 169 throw new RuntimeException("Must be called with a single URL"); 170 } 171 try { 172 return followRedirect(urls[0]); 173 } catch (IOException e) { 174 throw new RuntimeException(e); 175 } 176 } 177 178 @Override 179 protected void onPostExecute(String url) { 180 connectToRoom(url); 181 } 182 183 private String followRedirect(String url) throws IOException { 184 HttpURLConnection connection = (HttpURLConnection) 185 new URL(url).openConnection(); 186 connection.setInstanceFollowRedirects(false); 187 int code = connection.getResponseCode(); 188 if (code != HttpURLConnection.HTTP_MOVED_TEMP) { 189 throw new IOException("Unexpected response: " + code + " for " + url + 190 ", with contents: " + drainStream(connection.getInputStream())); 191 } 192 int n = 0; 193 String name, value; 194 while ((name = connection.getHeaderFieldKey(n)) != null) { 195 value = connection.getHeaderField(n); 196 if (name.equals("Location")) { 197 return value; 198 } 199 ++n; 200 } 201 throw new IOException("Didn't find Location header!"); 202 } 203 } 204 205 // AsyncTask that converts an AppRTC room URL into the set of signaling 206 // parameters to use with that room. 207 private class RoomParameterGetter 208 extends AsyncTask<String, Void, AppRTCSignalingParameters> { 209 @Override 210 protected AppRTCSignalingParameters doInBackground(String... urls) { 211 if (urls.length != 1) { 212 throw new RuntimeException("Must be called with a single URL"); 213 } 214 try { 215 return getParametersForRoomUrl(urls[0]); 216 } catch (JSONException e) { 217 throw new RuntimeException(e); 218 } catch (IOException e) { 219 throw new RuntimeException(e); 220 } 221 } 222 223 @Override 224 protected void onPostExecute(AppRTCSignalingParameters params) { 225 channelClient = 226 new GAEChannelClient(activity, params.channelToken, gaeHandler); 227 synchronized (sendQueue) { 228 appRTCSignalingParameters = params; 229 } 230 requestQueueDrainInBackground(); 231 iceServersObserver.onIceServers(appRTCSignalingParameters.iceServers); 232 } 233 234 // Fetches |url| and fishes the signaling parameters out of the JSON. 235 private AppRTCSignalingParameters getParametersForRoomUrl(String url) 236 throws IOException, JSONException { 237 url = url + "&t=json"; 238 JSONObject roomJson = new JSONObject( 239 drainStream((new URL(url)).openConnection().getInputStream())); 240 241 if (roomJson.has("error")) { 242 JSONArray errors = roomJson.getJSONArray("error_messages"); 243 throw new IOException(errors.toString()); 244 } 245 246 String gaeBaseHref = url.substring(0, url.indexOf('?')); 247 String token = roomJson.getString("token"); 248 String postMessageUrl = "/message?r=" + 249 roomJson.getString("room_key") + "&u=" + 250 roomJson.getString("me"); 251 boolean initiator = roomJson.getInt("initiator") == 1; 252 LinkedList<PeerConnection.IceServer> iceServers = 253 iceServersFromPCConfigJSON(roomJson.getString("pc_config")); 254 255 boolean isTurnPresent = false; 256 for (PeerConnection.IceServer server : iceServers) { 257 if (server.uri.startsWith("turn:")) { 258 isTurnPresent = true; 259 break; 260 } 261 } 262 if (!isTurnPresent) { 263 iceServers.add(requestTurnServer(roomJson.getString("turn_url"))); 264 } 265 266 MediaConstraints pcConstraints = constraintsFromJSON( 267 roomJson.getString("pc_constraints")); 268 addDTLSConstraintIfMissing(pcConstraints); 269 Log.d(TAG, "pcConstraints: " + pcConstraints); 270 MediaConstraints videoConstraints = constraintsFromJSON( 271 getAVConstraints("video", 272 roomJson.getString("media_constraints"))); 273 Log.d(TAG, "videoConstraints: " + videoConstraints); 274 MediaConstraints audioConstraints = constraintsFromJSON( 275 getAVConstraints("audio", 276 roomJson.getString("media_constraints"))); 277 Log.d(TAG, "audioConstraints: " + audioConstraints); 278 279 return new AppRTCSignalingParameters( 280 iceServers, gaeBaseHref, token, postMessageUrl, initiator, 281 pcConstraints, videoConstraints, audioConstraints); 282 } 283 284 // Mimic Chrome and set DtlsSrtpKeyAgreement to true if not set to false by 285 // the web-app. 286 private void addDTLSConstraintIfMissing( 287 MediaConstraints pcConstraints) { 288 for (MediaConstraints.KeyValuePair pair : pcConstraints.mandatory) { 289 if (pair.getKey().equals("DtlsSrtpKeyAgreement")) { 290 return; 291 } 292 } 293 for (MediaConstraints.KeyValuePair pair : pcConstraints.optional) { 294 if (pair.getKey().equals("DtlsSrtpKeyAgreement")) { 295 return; 296 } 297 } 298 // DTLS isn't being suppressed (e.g. for debug=loopback calls), so enable 299 // it by default. 300 pcConstraints.optional.add( 301 new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true")); 302 } 303 304 // Return the constraints specified for |type| of "audio" or "video" in 305 // |mediaConstraintsString|. 306 private String getAVConstraints( 307 String type, String mediaConstraintsString) { 308 try { 309 JSONObject json = new JSONObject(mediaConstraintsString); 310 // Tricksy handling of values that are allowed to be (boolean or 311 // MediaTrackConstraints) by the getUserMedia() spec. There are three 312 // cases below. 313 if (!json.has(type) || !json.optBoolean(type, true)) { 314 // Case 1: "audio"/"video" is not present, or is an explicit "false" 315 // boolean. 316 return null; 317 } 318 if (json.optBoolean(type, false)) { 319 // Case 2: "audio"/"video" is an explicit "true" boolean. 320 return "{\"mandatory\": {}, \"optional\": []}"; 321 } 322 // Case 3: "audio"/"video" is an object. 323 return json.getJSONObject(type).toString(); 324 } catch (JSONException e) { 325 throw new RuntimeException(e); 326 } 327 } 328 329 private MediaConstraints constraintsFromJSON(String jsonString) { 330 if (jsonString == null) { 331 return null; 332 } 333 try { 334 MediaConstraints constraints = new MediaConstraints(); 335 JSONObject json = new JSONObject(jsonString); 336 JSONObject mandatoryJSON = json.optJSONObject("mandatory"); 337 if (mandatoryJSON != null) { 338 JSONArray mandatoryKeys = mandatoryJSON.names(); 339 if (mandatoryKeys != null) { 340 for (int i = 0; i < mandatoryKeys.length(); ++i) { 341 String key = mandatoryKeys.getString(i); 342 String value = mandatoryJSON.getString(key); 343 constraints.mandatory.add( 344 new MediaConstraints.KeyValuePair(key, value)); 345 } 346 } 347 } 348 JSONArray optionalJSON = json.optJSONArray("optional"); 349 if (optionalJSON != null) { 350 for (int i = 0; i < optionalJSON.length(); ++i) { 351 JSONObject keyValueDict = optionalJSON.getJSONObject(i); 352 String key = keyValueDict.names().getString(0); 353 String value = keyValueDict.getString(key); 354 constraints.optional.add( 355 new MediaConstraints.KeyValuePair(key, value)); 356 } 357 } 358 return constraints; 359 } catch (JSONException e) { 360 throw new RuntimeException(e); 361 } 362 } 363 364 // Requests & returns a TURN ICE Server based on a request URL. Must be run 365 // off the main thread! 366 private PeerConnection.IceServer requestTurnServer(String url) { 367 try { 368 URLConnection connection = (new URL(url)).openConnection(); 369 connection.addRequestProperty("user-agent", "Mozilla/5.0"); 370 connection.addRequestProperty("origin", "https://apprtc.appspot.com"); 371 String response = drainStream(connection.getInputStream()); 372 JSONObject responseJSON = new JSONObject(response); 373 String uri = responseJSON.getJSONArray("uris").getString(0); 374 String username = responseJSON.getString("username"); 375 String password = responseJSON.getString("password"); 376 return new PeerConnection.IceServer(uri, username, password); 377 } catch (JSONException e) { 378 throw new RuntimeException(e); 379 } catch (IOException e) { 380 throw new RuntimeException(e); 381 } 382 } 383 } 384 385 // Return the list of ICE servers described by a WebRTCPeerConnection 386 // configuration string. 387 private LinkedList<PeerConnection.IceServer> iceServersFromPCConfigJSON( 388 String pcConfig) { 389 try { 390 JSONObject json = new JSONObject(pcConfig); 391 JSONArray servers = json.getJSONArray("iceServers"); 392 LinkedList<PeerConnection.IceServer> ret = 393 new LinkedList<PeerConnection.IceServer>(); 394 for (int i = 0; i < servers.length(); ++i) { 395 JSONObject server = servers.getJSONObject(i); 396 String url = server.getString("urls"); 397 String credential = 398 server.has("credential") ? server.getString("credential") : ""; 399 ret.add(new PeerConnection.IceServer(url, "", credential)); 400 } 401 return ret; 402 } catch (JSONException e) { 403 throw new RuntimeException(e); 404 } 405 } 406 407 // Request an attempt to drain the send queue, on a background thread. 408 private void requestQueueDrainInBackground() { 409 (new AsyncTask<Void, Void, Void>() { 410 public Void doInBackground(Void... unused) { 411 maybeDrainQueue(); 412 return null; 413 } 414 }).execute(); 415 } 416 417 // Send all queued messages if connected to the room. 418 private void maybeDrainQueue() { 419 synchronized (sendQueue) { 420 if (appRTCSignalingParameters == null) { 421 return; 422 } 423 try { 424 for (String msg : sendQueue) { 425 URLConnection connection = new URL( 426 appRTCSignalingParameters.gaeBaseHref + 427 appRTCSignalingParameters.postMessageUrl).openConnection(); 428 connection.setDoOutput(true); 429 connection.getOutputStream().write(msg.getBytes("UTF-8")); 430 if (!connection.getHeaderField(null).startsWith("HTTP/1.1 200 ")) { 431 throw new IOException( 432 "Non-200 response to POST: " + connection.getHeaderField(null) + 433 " for msg: " + msg); 434 } 435 } 436 } catch (IOException e) { 437 throw new RuntimeException(e); 438 } 439 sendQueue.clear(); 440 } 441 } 442 443 // Return the contents of an InputStream as a String. 444 private static String drainStream(InputStream in) { 445 Scanner s = new Scanner(in).useDelimiter("\\A"); 446 return s.hasNext() ? s.next() : ""; 447 } 448 } 449