Home | History | Annotate | Download | only in apprtc
      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