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
28package org.appspot.apprtc;
29
30import android.app.Activity;
31import android.os.AsyncTask;
32import android.util.Log;
33
34import org.json.JSONArray;
35import org.json.JSONException;
36import org.json.JSONObject;
37import org.webrtc.MediaConstraints;
38import org.webrtc.PeerConnection;
39
40import java.io.IOException;
41import java.io.InputStream;
42import java.net.HttpURLConnection;
43import java.net.URL;
44import java.net.URLConnection;
45import java.util.LinkedList;
46import java.util.List;
47import java.util.Scanner;
48import java.util.regex.Matcher;
49import 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 */
59public 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