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 public MediaConstraints audioConstraints() {
135 return appRTCSignalingParameters.audioConstraints;
138 // Struct holding the signaling parameters of an AppRTC room.
139 private class AppRTCSignalingParameters {
140 public final List<PeerConnection.IceServer> iceServers;
141 public final String gaeBaseHref;
142 public final String channelToken;
143 public final String postMessageUrl;
144 public final boolean initiator;
145 public final MediaConstraints pcConstraints;
146 public final MediaConstraints videoConstraints;
147 public final MediaConstraints audioConstraints;
149 public AppRTCSignalingParameters(
150 List<PeerConnection.IceServer> iceServers,
151 String gaeBaseHref, String channelToken, String postMessageUrl,
152 boolean initiator, MediaConstraints pcConstraints,
153 MediaConstraints videoConstraints, MediaConstraints audioConstraints) {
154 this.iceServers = iceServers;
155 this.gaeBaseHref = gaeBaseHref;
156 this.channelToken = channelToken;
157 this.postMessageUrl = postMessageUrl;
158 this.initiator = initiator;
159 this.pcConstraints = pcConstraints;
160 this.videoConstraints = videoConstraints;
161 this.audioConstraints = audioConstraints;
165 // Load the given URL and return the value of the Location header of the
166 // resulting 302 response. If the result is not a 302, throws.
167 private class RedirectResolver extends AsyncTask<String, Void, String> {
169 protected String doInBackground(String... urls) {
170 if (urls.length != 1) {
171 throw new RuntimeException("Must be called with a single URL");
174 return followRedirect(urls[0]);
175 } catch (IOException e) {
176 throw new RuntimeException(e);
181 protected void onPostExecute(String url) {
185 private String followRedirect(String url) throws IOException {
186 HttpURLConnection connection = (HttpURLConnection)
187 new URL(url).openConnection();
188 connection.setInstanceFollowRedirects(false);
189 int code = connection.getResponseCode();
190 if (code != HttpURLConnection.HTTP_MOVED_TEMP) {
191 throw new IOException("Unexpected response: " + code + " for " + url +
192 ", with contents: " + drainStream(connection.getInputStream()));
196 while ((name = connection.getHeaderFieldKey(n)) != null) {
197 value = connection.getHeaderField(n);
198 if (name.equals("Location")) {
203 throw new IOException("Didn't find Location header!");
207 // AsyncTask that converts an AppRTC room URL into the set of signaling
208 // parameters to use with that room.
209 private class RoomParameterGetter
210 extends AsyncTask<String, Void, AppRTCSignalingParameters> {
212 protected AppRTCSignalingParameters doInBackground(String... urls) {
213 if (urls.length != 1) {
214 throw new RuntimeException("Must be called with a single URL");
217 return getParametersForRoomUrl(urls[0]);
218 } catch (IOException e) {
219 throw new RuntimeException(e);
224 protected void onPostExecute(AppRTCSignalingParameters params) {
226 new GAEChannelClient(activity, params.channelToken, gaeHandler);
227 synchronized (sendQueue) {
228 appRTCSignalingParameters = params;
230 requestQueueDrainInBackground();
231 iceServersObserver.onIceServers(appRTCSignalingParameters.iceServers);
234 // Fetches |url| and fishes the signaling parameters out of the HTML via
235 // regular expressions.
237 // TODO(fischman): replace this hackery with a dedicated JSON-serving URL in
238 // apprtc so that this isn't necessary (here and in other future apps that
239 // want to interop with apprtc).
240 private AppRTCSignalingParameters getParametersForRoomUrl(String url)
242 final Pattern fullRoomPattern = Pattern.compile(
243 ".*\n *Sorry, this room is full\\..*");
246 drainStream((new URL(url)).openConnection().getInputStream());
248 Matcher fullRoomMatcher = fullRoomPattern.matcher(roomHtml);
249 if (fullRoomMatcher.find()) {
250 throw new IOException("Room is full!");
253 String gaeBaseHref = url.substring(0, url.indexOf('?'));
254 String token = getVarValue(roomHtml, "channelToken", true);
255 String postMessageUrl = "/message?r=" +
256 getVarValue(roomHtml, "roomKey", true) + "&u=" +
257 getVarValue(roomHtml, "me", true);
258 boolean initiator = getVarValue(roomHtml, "initiator", false).equals("1");
259 LinkedList<PeerConnection.IceServer> iceServers =
260 iceServersFromPCConfigJSON(getVarValue(roomHtml, "pcConfig", false));
262 boolean isTurnPresent = false;
263 for (PeerConnection.IceServer server : iceServers) {
264 if (server.uri.startsWith("turn:")) {
265 isTurnPresent = true;
269 if (!isTurnPresent) {
271 requestTurnServer(getVarValue(roomHtml, "turnUrl", true)));
274 MediaConstraints pcConstraints = constraintsFromJSON(
275 getVarValue(roomHtml, "pcConstraints", false));
276 Log.d(TAG, "pcConstraints: " + pcConstraints);
277 MediaConstraints videoConstraints = constraintsFromJSON(
278 getAVConstraints("video",
279 getVarValue(roomHtml, "mediaConstraints", false)));
280 Log.d(TAG, "videoConstraints: " + videoConstraints);
281 MediaConstraints audioConstraints = constraintsFromJSON(
282 getAVConstraints("audio",
283 getVarValue(roomHtml, "mediaConstraints", false)));
284 Log.d(TAG, "audioConstraints: " + audioConstraints);
286 return new AppRTCSignalingParameters(
287 iceServers, gaeBaseHref, token, postMessageUrl, initiator,
288 pcConstraints, videoConstraints, audioConstraints);
291 // Return the constraints specified for |type| of "audio" or "video" in
292 // |mediaConstraintsString|.
293 private String getAVConstraints(
294 String type, String mediaConstraintsString) {
296 JSONObject json = new JSONObject(mediaConstraintsString);
297 // Tricksy handling of values that are allowed to be (boolean or
298 // MediaTrackConstraints) by the getUserMedia() spec. There are three
300 if (!json.has(type) || !json.optBoolean(type, true)) {
301 // Case 1: "audio"/"video" is not present, or is an explicit "false"
305 if (json.optBoolean(type, false)) {
306 // Case 2: "audio"/"video" is an explicit "true" boolean.
307 return "{\"mandatory\": {}, \"optional\": []}";
309 // Case 3: "audio"/"video" is an object.
310 return json.getJSONObject(type).toString();
311 } catch (JSONException e) {
312 throw new RuntimeException(e);
316 private MediaConstraints constraintsFromJSON(String jsonString) {
317 if (jsonString == null) {
321 MediaConstraints constraints = new MediaConstraints();
322 JSONObject json = new JSONObject(jsonString);
323 JSONObject mandatoryJSON = json.optJSONObject("mandatory");
324 if (mandatoryJSON != null) {
325 JSONArray mandatoryKeys = mandatoryJSON.names();
326 if (mandatoryKeys != null) {
327 for (int i = 0; i < mandatoryKeys.length(); ++i) {
328 String key = mandatoryKeys.getString(i);
329 String value = mandatoryJSON.getString(key);
330 constraints.mandatory.add(
331 new MediaConstraints.KeyValuePair(key, value));
335 JSONArray optionalJSON = json.optJSONArray("optional");
336 if (optionalJSON != null) {
337 for (int i = 0; i < optionalJSON.length(); ++i) {
338 JSONObject keyValueDict = optionalJSON.getJSONObject(i);
339 String key = keyValueDict.names().getString(0);
340 String value = keyValueDict.getString(key);
341 constraints.optional.add(
342 new MediaConstraints.KeyValuePair(key, value));
346 } catch (JSONException e) {
347 throw new RuntimeException(e);
351 // Scan |roomHtml| for declaration & assignment of |varName| and return its
352 // value, optionally stripping outside quotes if |stripQuotes| requests it.
353 private String getVarValue(
354 String roomHtml, String varName, boolean stripQuotes)
356 final Pattern pattern = Pattern.compile(
357 ".*\n *var " + varName + " = ([^\n]*);\n.*");
358 Matcher matcher = pattern.matcher(roomHtml);
359 if (!matcher.find()) {
360 throw new IOException("Missing " + varName + " in HTML: " + roomHtml);
362 String varValue = matcher.group(1);
363 if (matcher.find()) {
364 throw new IOException("Too many " + varName + " in HTML: " + roomHtml);
367 varValue = varValue.substring(1, varValue.length() - 1);
372 // Requests & returns a TURN ICE Server based on a request URL. Must be run
373 // off the main thread!
374 private PeerConnection.IceServer requestTurnServer(String url) {
376 URLConnection connection = (new URL(url)).openConnection();
377 connection.addRequestProperty("user-agent", "Mozilla/5.0");
378 connection.addRequestProperty("origin", "https://apprtc.appspot.com");
379 String response = drainStream(connection.getInputStream());
380 JSONObject responseJSON = new JSONObject(response);
381 String uri = responseJSON.getJSONArray("uris").getString(0);
382 String username = responseJSON.getString("username");
383 String password = responseJSON.getString("password");
384 return new PeerConnection.IceServer(uri, username, password);
385 } catch (JSONException e) {
386 throw new RuntimeException(e);
387 } catch (IOException e) {
388 throw new RuntimeException(e);
393 // Return the list of ICE servers described by a WebRTCPeerConnection
394 // configuration string.
395 private LinkedList<PeerConnection.IceServer> iceServersFromPCConfigJSON(
398 JSONObject json = new JSONObject(pcConfig);
399 JSONArray servers = json.getJSONArray("iceServers");
400 LinkedList<PeerConnection.IceServer> ret =
401 new LinkedList<PeerConnection.IceServer>();
402 for (int i = 0; i < servers.length(); ++i) {
403 JSONObject server = servers.getJSONObject(i);
404 String url = server.getString("url");
406 server.has("credential") ? server.getString("credential") : "";
407 ret.add(new PeerConnection.IceServer(url, "", credential));
410 } catch (JSONException e) {
411 throw new RuntimeException(e);
415 // Request an attempt to drain the send queue, on a background thread.
416 private void requestQueueDrainInBackground() {
417 (new AsyncTask<Void, Void, Void>() {
418 public Void doInBackground(Void... unused) {
425 // Send all queued messages if connected to the room.
426 private void maybeDrainQueue() {
427 synchronized (sendQueue) {
428 if (appRTCSignalingParameters == null) {
432 for (String msg : sendQueue) {
433 URLConnection connection = new URL(
434 appRTCSignalingParameters.gaeBaseHref +
435 appRTCSignalingParameters.postMessageUrl).openConnection();
436 connection.setDoOutput(true);
437 connection.getOutputStream().write(msg.getBytes("UTF-8"));
438 if (!connection.getHeaderField(null).startsWith("HTTP/1.1 200 ")) {
439 throw new IOException(
440 "Non-200 response to POST: " + connection.getHeaderField(null) +
444 } catch (IOException e) {
445 throw new RuntimeException(e);
451 // Return the contents of an InputStream as a String.
452 private static String drainStream(InputStream in) {
453 Scanner s = new Scanner(in).useDelimiter("\\A");
454 return s.hasNext() ? s.next() : "";