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 import java.util.regex.Matcher; 49 import java.util.regex.Pattern; 50 51 /** 52 * Negotiates signaling for chatting with apprtc.appspot.com "rooms". 53 * Uses the client<->server specifics of the apprtc AppEngine webapp. 54 * 55 * To use: create an instance of this object (registering a message handler) and 56 * call connectToRoom(). Once that's done call sendMessage() and wait for the 57 * registered handler to be called with received messages. 58 */ 59 public class AppRTCClient { 60 private static final String TAG = "AppRTCClient"; 61 private GAEChannelClient channelClient; 62 private final Activity activity; 63 private final GAEChannelClient.MessageHandler gaeHandler; 64 private final IceServersObserver iceServersObserver; 65 66 // These members are only read/written under sendQueue's lock. 67 private LinkedList<String> sendQueue = new LinkedList<String>(); 68 private AppRTCSignalingParameters appRTCSignalingParameters; 69 70 /** 71 * Callback fired once the room's signaling parameters specify the set of 72 * ICE servers to use. 73 */ 74 public static interface IceServersObserver { 75 public void onIceServers(List<PeerConnection.IceServer> iceServers); 76 } 77 78 public AppRTCClient( 79 Activity activity, GAEChannelClient.MessageHandler gaeHandler, 80 IceServersObserver iceServersObserver) { 81 this.activity = activity; 82 this.gaeHandler = gaeHandler; 83 this.iceServersObserver = iceServersObserver; 84 } 85 86 /** 87 * Asynchronously connect to an AppRTC room URL, e.g. 88 * https://apprtc.appspot.com/?r=NNN and register message-handling callbacks 89 * on its GAE Channel. 90 */ 91 public void connectToRoom(String url) { 92 while (url.indexOf('?') < 0) { 93 // Keep redirecting until we get a room number. 94 (new RedirectResolver()).execute(url); 95 return; // RedirectResolver above calls us back with the next URL. 96 } 97 (new RoomParameterGetter()).execute(url); 98 } 99 100 /** 101 * Disconnect from the GAE Channel. 102 */ 103 public void disconnect() { 104 if (channelClient != null) { 105 channelClient.close(); 106 channelClient = null; 107 } 108 } 109 110 /** 111 * Queue a message for sending to the room's channel and send it if already 112 * connected (other wise queued messages are drained when the channel is 113 eventually established). 114 */ 115 public synchronized void sendMessage(String msg) { 116 synchronized (sendQueue) { 117 sendQueue.add(msg); 118 } 119 requestQueueDrainInBackground(); 120 } 121 122 public boolean isInitiator() { 123 return appRTCSignalingParameters.initiator; 124 } 125 126 public MediaConstraints pcConstraints() { 127 return appRTCSignalingParameters.pcConstraints; 128 } 129 130 public MediaConstraints videoConstraints() { 131 return appRTCSignalingParameters.videoConstraints; 132 } 133 134 // Struct holding the signaling parameters of an AppRTC room. 135 private class AppRTCSignalingParameters { 136 public final List<PeerConnection.IceServer> iceServers; 137 public final String gaeBaseHref; 138 public final String channelToken; 139 public final String postMessageUrl; 140 public final boolean initiator; 141 public final MediaConstraints pcConstraints; 142 public final MediaConstraints videoConstraints; 143 144 public AppRTCSignalingParameters( 145 List<PeerConnection.IceServer> iceServers, 146 String gaeBaseHref, String channelToken, String postMessageUrl, 147 boolean initiator, MediaConstraints pcConstraints, 148 MediaConstraints videoConstraints) { 149 this.iceServers = iceServers; 150 this.gaeBaseHref = gaeBaseHref; 151 this.channelToken = channelToken; 152 this.postMessageUrl = postMessageUrl; 153 this.initiator = initiator; 154 this.pcConstraints = pcConstraints; 155 this.videoConstraints = videoConstraints; 156 } 157 } 158 159 // Load the given URL and return the value of the Location header of the 160 // resulting 302 response. If the result is not a 302, throws. 161 private class RedirectResolver extends AsyncTask<String, Void, String> { 162 @Override 163 protected String doInBackground(String... urls) { 164 if (urls.length != 1) { 165 throw new RuntimeException("Must be called with a single URL"); 166 } 167 try { 168 return followRedirect(urls[0]); 169 } catch (IOException e) { 170 throw new RuntimeException(e); 171 } 172 } 173 174 @Override 175 protected void onPostExecute(String url) { 176 connectToRoom(url); 177 } 178 179 private String followRedirect(String url) throws IOException { 180 HttpURLConnection connection = (HttpURLConnection) 181 new URL(url).openConnection(); 182 connection.setInstanceFollowRedirects(false); 183 int code = connection.getResponseCode(); 184 if (code != HttpURLConnection.HTTP_MOVED_TEMP) { 185 throw new IOException("Unexpected response: " + code + " for " + url + 186 ", with contents: " + drainStream(connection.getInputStream())); 187 } 188 int n = 0; 189 String name, value; 190 while ((name = connection.getHeaderFieldKey(n)) != null) { 191 value = connection.getHeaderField(n); 192 if (name.equals("Location")) { 193 return value; 194 } 195 ++n; 196 } 197 throw new IOException("Didn't find Location header!"); 198 } 199 } 200 201 // AsyncTask that converts an AppRTC room URL into the set of signaling 202 // parameters to use with that room. 203 private class RoomParameterGetter 204 extends AsyncTask<String, Void, AppRTCSignalingParameters> { 205 @Override 206 protected AppRTCSignalingParameters doInBackground(String... urls) { 207 if (urls.length != 1) { 208 throw new RuntimeException("Must be called with a single URL"); 209 } 210 try { 211 return getParametersForRoomUrl(urls[0]); 212 } catch (IOException e) { 213 throw new RuntimeException(e); 214 } 215 } 216 217 @Override 218 protected void onPostExecute(AppRTCSignalingParameters params) { 219 channelClient = 220 new GAEChannelClient(activity, params.channelToken, gaeHandler); 221 synchronized (sendQueue) { 222 appRTCSignalingParameters = params; 223 } 224 requestQueueDrainInBackground(); 225 iceServersObserver.onIceServers(appRTCSignalingParameters.iceServers); 226 } 227 228 // Fetches |url| and fishes the signaling parameters out of the HTML via 229 // regular expressions. 230 // 231 // TODO(fischman): replace this hackery with a dedicated JSON-serving URL in 232 // apprtc so that this isn't necessary (here and in other future apps that 233 // want to interop with apprtc). 234 private AppRTCSignalingParameters getParametersForRoomUrl(String url) 235 throws IOException { 236 final Pattern fullRoomPattern = Pattern.compile( 237 ".*\n *Sorry, this room is full\\..*"); 238 239 String roomHtml = 240 drainStream((new URL(url)).openConnection().getInputStream()); 241 242 Matcher fullRoomMatcher = fullRoomPattern.matcher(roomHtml); 243 if (fullRoomMatcher.find()) { 244 throw new IOException("Room is full!"); 245 } 246 247 String gaeBaseHref = url.substring(0, url.indexOf('?')); 248 String token = getVarValue(roomHtml, "channelToken", true); 249 String postMessageUrl = "/message?r=" + 250 getVarValue(roomHtml, "roomKey", true) + "&u=" + 251 getVarValue(roomHtml, "me", true); 252 boolean initiator = getVarValue(roomHtml, "initiator", false).equals("1"); 253 LinkedList<PeerConnection.IceServer> iceServers = 254 iceServersFromPCConfigJSON(getVarValue(roomHtml, "pcConfig", false)); 255 256 boolean isTurnPresent = false; 257 for (PeerConnection.IceServer server : iceServers) { 258 if (server.uri.startsWith("turn:")) { 259 isTurnPresent = true; 260 break; 261 } 262 } 263 if (!isTurnPresent) { 264 iceServers.add( 265 requestTurnServer(getVarValue(roomHtml, "turnUrl", true))); 266 } 267 268 MediaConstraints pcConstraints = constraintsFromJSON( 269 getVarValue(roomHtml, "pcConstraints", false)); 270 Log.d(TAG, "pcConstraints: " + pcConstraints); 271 272 MediaConstraints videoConstraints = constraintsFromJSON( 273 getVideoConstraints( 274 getVarValue(roomHtml, "mediaConstraints", false))); 275 Log.d(TAG, "videoConstraints: " + videoConstraints); 276 277 return new AppRTCSignalingParameters( 278 iceServers, gaeBaseHref, token, postMessageUrl, initiator, 279 pcConstraints, videoConstraints); 280 } 281 282 private String getVideoConstraints(String mediaConstraintsString) { 283 try { 284 JSONObject json = new JSONObject(mediaConstraintsString); 285 JSONObject videoJson = json.optJSONObject("video"); 286 if (videoJson == null) { 287 return ""; 288 } 289 return videoJson.toString(); 290 } catch (JSONException e) { 291 throw new RuntimeException(e); 292 } 293 } 294 295 private MediaConstraints constraintsFromJSON(String jsonString) { 296 try { 297 MediaConstraints constraints = new MediaConstraints(); 298 JSONObject json = new JSONObject(jsonString); 299 JSONObject mandatoryJSON = json.optJSONObject("mandatory"); 300 if (mandatoryJSON != null) { 301 JSONArray mandatoryKeys = mandatoryJSON.names(); 302 if (mandatoryKeys != null) { 303 for (int i = 0; i < mandatoryKeys.length(); ++i) { 304 String key = (String) mandatoryKeys.getString(i); 305 String value = mandatoryJSON.getString(key); 306 constraints.mandatory.add( 307 new MediaConstraints.KeyValuePair(key, value)); 308 } 309 } 310 } 311 JSONArray optionalJSON = json.optJSONArray("optional"); 312 if (optionalJSON != null) { 313 for (int i = 0; i < optionalJSON.length(); ++i) { 314 JSONObject keyValueDict = optionalJSON.getJSONObject(i); 315 String key = keyValueDict.names().getString(0); 316 String value = keyValueDict.getString(key); 317 constraints.optional.add( 318 new MediaConstraints.KeyValuePair(key, value)); 319 } 320 } 321 return constraints; 322 } catch (JSONException e) { 323 throw new RuntimeException(e); 324 } 325 } 326 327 // Scan |roomHtml| for declaration & assignment of |varName| and return its 328 // value, optionally stripping outside quotes if |stripQuotes| requests it. 329 private String getVarValue( 330 String roomHtml, String varName, boolean stripQuotes) 331 throws IOException { 332 final Pattern pattern = Pattern.compile( 333 ".*\n *var " + varName + " = ([^\n]*);\n.*"); 334 Matcher matcher = pattern.matcher(roomHtml); 335 if (!matcher.find()) { 336 throw new IOException("Missing " + varName + " in HTML: " + roomHtml); 337 } 338 String varValue = matcher.group(1); 339 if (matcher.find()) { 340 throw new IOException("Too many " + varName + " in HTML: " + roomHtml); 341 } 342 if (stripQuotes) { 343 varValue = varValue.substring(1, varValue.length() - 1); 344 } 345 return varValue; 346 } 347 348 // Requests & returns a TURN ICE Server based on a request URL. Must be run 349 // off the main thread! 350 private PeerConnection.IceServer requestTurnServer(String url) { 351 try { 352 URLConnection connection = (new URL(url)).openConnection(); 353 connection.addRequestProperty("user-agent", "Mozilla/5.0"); 354 connection.addRequestProperty("origin", "https://apprtc.appspot.com"); 355 String response = drainStream(connection.getInputStream()); 356 JSONObject responseJSON = new JSONObject(response); 357 String uri = responseJSON.getJSONArray("uris").getString(0); 358 String username = responseJSON.getString("username"); 359 String password = responseJSON.getString("password"); 360 return new PeerConnection.IceServer(uri, username, password); 361 } catch (JSONException e) { 362 throw new RuntimeException(e); 363 } catch (IOException e) { 364 throw new RuntimeException(e); 365 } 366 } 367 } 368 369 // Return the list of ICE servers described by a WebRTCPeerConnection 370 // configuration string. 371 private LinkedList<PeerConnection.IceServer> iceServersFromPCConfigJSON( 372 String pcConfig) { 373 try { 374 JSONObject json = new JSONObject(pcConfig); 375 JSONArray servers = json.getJSONArray("iceServers"); 376 LinkedList<PeerConnection.IceServer> ret = 377 new LinkedList<PeerConnection.IceServer>(); 378 for (int i = 0; i < servers.length(); ++i) { 379 JSONObject server = servers.getJSONObject(i); 380 String url = server.getString("url"); 381 String credential = 382 server.has("credential") ? server.getString("credential") : ""; 383 ret.add(new PeerConnection.IceServer(url, "", credential)); 384 } 385 return ret; 386 } catch (JSONException e) { 387 throw new RuntimeException(e); 388 } 389 } 390 391 // Request an attempt to drain the send queue, on a background thread. 392 private void requestQueueDrainInBackground() { 393 (new AsyncTask<Void, Void, Void>() { 394 public Void doInBackground(Void... unused) { 395 maybeDrainQueue(); 396 return null; 397 } 398 }).execute(); 399 } 400 401 // Send all queued messages if connected to the room. 402 private void maybeDrainQueue() { 403 synchronized (sendQueue) { 404 if (appRTCSignalingParameters == null) { 405 return; 406 } 407 try { 408 for (String msg : sendQueue) { 409 URLConnection connection = new URL( 410 appRTCSignalingParameters.gaeBaseHref + 411 appRTCSignalingParameters.postMessageUrl).openConnection(); 412 connection.setDoOutput(true); 413 connection.getOutputStream().write(msg.getBytes("UTF-8")); 414 if (!connection.getHeaderField(null).startsWith("HTTP/1.1 200 ")) { 415 throw new IOException( 416 "Non-200 response to POST: " + connection.getHeaderField(null) + 417 " for msg: " + msg); 418 } 419 } 420 } catch (IOException e) { 421 throw new RuntimeException(e); 422 } 423 sendQueue.clear(); 424 } 425 } 426 427 // Return the contents of an InputStream as a String. 428 private static String drainStream(InputStream in) { 429 Scanner s = new Scanner(in).useDelimiter("\\A"); 430 return s.hasNext() ? s.next() : ""; 431 } 432 } 433