3 * Copyright 2013, Google Inc.
5 * Redistribution and use in source and binary forms, with or without
6 * modification, are permitted provided that the following conditions are met:
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.
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.
28 package org.appspot.apprtc;
30 import android.app.Activity;
31 import android.os.AsyncTask;
32 import android.util.Log;
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;
40 import java.io.IOException;
41 import java.io.InputStream;
42 import java.net.HttpURLConnection;
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;
52 * Negotiates signaling for chatting with apprtc.appspot.com "rooms".
53 * Uses the client<->server specifics of the apprtc AppEngine webapp.
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.
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;
66 // These members are only read/written under sendQueue's lock.
67 private LinkedList<String> sendQueue = new LinkedList<String>();
68 private AppRTCSignalingParameters appRTCSignalingParameters;
71 * Callback fired once the room's signaling parameters specify the set of
74 public static interface IceServersObserver {
75 public void onIceServers(List<PeerConnection.IceServer> iceServers);
79 Activity activity, GAEChannelClient.MessageHandler gaeHandler,
80 IceServersObserver iceServersObserver) {
81 this.activity = activity;
82 this.gaeHandler = gaeHandler;
83 this.iceServersObserver = iceServersObserver;
87 * Asynchronously connect to an AppRTC room URL, e.g.
88 * https://apprtc.appspot.com/?r=NNN and register message-handling callbacks
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.
97 (new RoomParameterGetter()).execute(url);
101 * Disconnect from the GAE Channel.
103 public void disconnect() {
104 if (channelClient != null) {
105 channelClient.close();
106 channelClient = null;
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).
115 public synchronized void sendMessage(String msg) {
116 synchronized (sendQueue) {
119 requestQueueDrainInBackground();
122 public boolean isInitiator() {
123 return appRTCSignalingParameters.initiator;
126 public MediaConstraints pcConstraints() {
127 return appRTCSignalingParameters.pcConstraints;
130 public MediaConstraints videoConstraints() {
131 return appRTCSignalingParameters.videoConstraints;
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;
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;
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> {
163 protected String doInBackground(String... urls) {
164 if (urls.length != 1) {
165 throw new RuntimeException("Must be called with a single URL");
168 return followRedirect(urls[0]);
169 } catch (IOException e) {
170 throw new RuntimeException(e);
175 protected void onPostExecute(String url) {
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()));
190 while ((name = connection.getHeaderFieldKey(n)) != null) {
191 value = connection.getHeaderField(n);
192 if (name.equals("Location")) {
197 throw new IOException("Didn't find Location header!");
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> {
206 protected AppRTCSignalingParameters doInBackground(String... urls) {
207 if (urls.length != 1) {
208 throw new RuntimeException("Must be called with a single URL");
211 return getParametersForRoomUrl(urls[0]);
212 } catch (IOException e) {
213 throw new RuntimeException(e);
218 protected void onPostExecute(AppRTCSignalingParameters params) {
220 new GAEChannelClient(activity, params.channelToken, gaeHandler);
221 synchronized (sendQueue) {
222 appRTCSignalingParameters = params;
224 requestQueueDrainInBackground();
225 iceServersObserver.onIceServers(appRTCSignalingParameters.iceServers);
228 // Fetches |url| and fishes the signaling parameters out of the HTML via
229 // regular expressions.
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)
236 final Pattern fullRoomPattern = Pattern.compile(
237 ".*\n *Sorry, this room is full\\..*");
240 drainStream((new URL(url)).openConnection().getInputStream());
242 Matcher fullRoomMatcher = fullRoomPattern.matcher(roomHtml);
243 if (fullRoomMatcher.find()) {
244 throw new IOException("Room is full!");
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));
256 boolean isTurnPresent = false;
257 for (PeerConnection.IceServer server : iceServers) {
258 if (server.uri.startsWith("turn:")) {
259 isTurnPresent = true;
263 if (!isTurnPresent) {
265 requestTurnServer(getVarValue(roomHtml, "turnUrl", true)));
268 MediaConstraints pcConstraints = constraintsFromJSON(
269 getVarValue(roomHtml, "pcConstraints", false));
270 Log.d(TAG, "pcConstraints: " + pcConstraints);
272 MediaConstraints videoConstraints = constraintsFromJSON(
274 getVarValue(roomHtml, "mediaConstraints", false)));
276 Log.d(TAG, "videoConstraints: " + videoConstraints);
278 return new AppRTCSignalingParameters(
279 iceServers, gaeBaseHref, token, postMessageUrl, initiator,
280 pcConstraints, videoConstraints);
283 private String getVideoConstraints(String mediaConstraintsString) {
285 JSONObject json = new JSONObject(mediaConstraintsString);
286 // Tricksy handling of values that are allowed to be (boolean or
287 // MediaTrackConstraints) by the getUserMedia() spec. There are three
289 if (!json.has("video") || !json.optBoolean("video", true)) {
290 // Case 1: "video" is not present, or is an explicit "false" boolean.
293 if (json.optBoolean("video", false)) {
294 // Case 2: "video" is an explicit "true" boolean.
295 return "{\"mandatory\": {}, \"optional\": []}";
297 // Case 3: "video" is an object.
298 return json.getJSONObject("video").toString();
299 } catch (JSONException e) {
300 throw new RuntimeException(e);
304 private MediaConstraints constraintsFromJSON(String jsonString) {
305 if (jsonString == null) {
309 MediaConstraints constraints = new MediaConstraints();
310 JSONObject json = new JSONObject(jsonString);
311 JSONObject mandatoryJSON = json.optJSONObject("mandatory");
312 if (mandatoryJSON != null) {
313 JSONArray mandatoryKeys = mandatoryJSON.names();
314 if (mandatoryKeys != null) {
315 for (int i = 0; i < mandatoryKeys.length(); ++i) {
316 String key = mandatoryKeys.getString(i);
317 String value = mandatoryJSON.getString(key);
318 constraints.mandatory.add(
319 new MediaConstraints.KeyValuePair(key, value));
323 JSONArray optionalJSON = json.optJSONArray("optional");
324 if (optionalJSON != null) {
325 for (int i = 0; i < optionalJSON.length(); ++i) {
326 JSONObject keyValueDict = optionalJSON.getJSONObject(i);
327 String key = keyValueDict.names().getString(0);
328 String value = keyValueDict.getString(key);
329 constraints.optional.add(
330 new MediaConstraints.KeyValuePair(key, value));
334 } catch (JSONException e) {
335 throw new RuntimeException(e);
339 // Scan |roomHtml| for declaration & assignment of |varName| and return its
340 // value, optionally stripping outside quotes if |stripQuotes| requests it.
341 private String getVarValue(
342 String roomHtml, String varName, boolean stripQuotes)
344 final Pattern pattern = Pattern.compile(
345 ".*\n *var " + varName + " = ([^\n]*);\n.*");
346 Matcher matcher = pattern.matcher(roomHtml);
347 if (!matcher.find()) {
348 throw new IOException("Missing " + varName + " in HTML: " + roomHtml);
350 String varValue = matcher.group(1);
351 if (matcher.find()) {
352 throw new IOException("Too many " + varName + " in HTML: " + roomHtml);
355 varValue = varValue.substring(1, varValue.length() - 1);
360 // Requests & returns a TURN ICE Server based on a request URL. Must be run
361 // off the main thread!
362 private PeerConnection.IceServer requestTurnServer(String url) {
364 URLConnection connection = (new URL(url)).openConnection();
365 connection.addRequestProperty("user-agent", "Mozilla/5.0");
366 connection.addRequestProperty("origin", "https://apprtc.appspot.com");
367 String response = drainStream(connection.getInputStream());
368 JSONObject responseJSON = new JSONObject(response);
369 String uri = responseJSON.getJSONArray("uris").getString(0);
370 String username = responseJSON.getString("username");
371 String password = responseJSON.getString("password");
372 return new PeerConnection.IceServer(uri, username, password);
373 } catch (JSONException e) {
374 throw new RuntimeException(e);
375 } catch (IOException e) {
376 throw new RuntimeException(e);
381 // Return the list of ICE servers described by a WebRTCPeerConnection
382 // configuration string.
383 private LinkedList<PeerConnection.IceServer> iceServersFromPCConfigJSON(
386 JSONObject json = new JSONObject(pcConfig);
387 JSONArray servers = json.getJSONArray("iceServers");
388 LinkedList<PeerConnection.IceServer> ret =
389 new LinkedList<PeerConnection.IceServer>();
390 for (int i = 0; i < servers.length(); ++i) {
391 JSONObject server = servers.getJSONObject(i);
392 String url = server.getString("url");
394 server.has("credential") ? server.getString("credential") : "";
395 ret.add(new PeerConnection.IceServer(url, "", credential));
398 } catch (JSONException e) {
399 throw new RuntimeException(e);
403 // Request an attempt to drain the send queue, on a background thread.
404 private void requestQueueDrainInBackground() {
405 (new AsyncTask<Void, Void, Void>() {
406 public Void doInBackground(Void... unused) {
413 // Send all queued messages if connected to the room.
414 private void maybeDrainQueue() {
415 synchronized (sendQueue) {
416 if (appRTCSignalingParameters == null) {
420 for (String msg : sendQueue) {
421 URLConnection connection = new URL(
422 appRTCSignalingParameters.gaeBaseHref +
423 appRTCSignalingParameters.postMessageUrl).openConnection();
424 connection.setDoOutput(true);
425 connection.getOutputStream().write(msg.getBytes("UTF-8"));
426 if (!connection.getHeaderField(null).startsWith("HTTP/1.1 200 ")) {
427 throw new IOException(
428 "Non-200 response to POST: " + connection.getHeaderField(null) +
432 } catch (IOException e) {
433 throw new RuntimeException(e);
439 // Return the contents of an InputStream as a String.
440 private static String drainStream(InputStream in) {
441 Scanner s = new Scanner(in).useDelimiter("\\A");
442 return s.hasNext() ? s.next() : "";