- add third_party src.
[platform/framework/web/crosswalk.git] / src / third_party / libjingle / source / talk / examples / android / src / org / appspot / apprtc / AppRTCClient.java
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
276       Log.d(TAG, "videoConstraints: " + videoConstraints);
277
278       return new AppRTCSignalingParameters(
279           iceServers, gaeBaseHref, token, postMessageUrl, initiator,
280           pcConstraints, videoConstraints);
281     }
282
283     private String getVideoConstraints(String mediaConstraintsString) {
284       try {
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
288         // cases below.
289         if (!json.has("video") || !json.optBoolean("video", true)) {
290           // Case 1: "video" is not present, or is an explicit "false" boolean.
291           return null;
292         }
293         if (json.optBoolean("video", false)) {
294           // Case 2: "video" is an explicit "true" boolean.
295           return "{\"mandatory\": {}, \"optional\": []}";
296         }
297         // Case 3: "video" is an object.
298         return json.getJSONObject("video").toString();
299       } catch (JSONException e) {
300         throw new RuntimeException(e);
301       }
302     }
303
304     private MediaConstraints constraintsFromJSON(String jsonString) {
305       if (jsonString == null) {
306         return null;
307       }
308       try {
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));
320             }
321           }
322         }
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));
331           }
332         }
333         return constraints;
334       } catch (JSONException e) {
335         throw new RuntimeException(e);
336       }
337     }
338
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)
343         throws IOException {
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);
349       }
350       String varValue = matcher.group(1);
351       if (matcher.find()) {
352         throw new IOException("Too many " + varName + " in HTML: " + roomHtml);
353       }
354       if (stripQuotes) {
355         varValue = varValue.substring(1, varValue.length() - 1);
356       }
357       return varValue;
358     }
359
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) {
363       try {
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);
377       }
378     }
379   }
380
381   // Return the list of ICE servers described by a WebRTCPeerConnection
382   // configuration string.
383   private LinkedList<PeerConnection.IceServer> iceServersFromPCConfigJSON(
384       String pcConfig) {
385     try {
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");
393         String credential =
394             server.has("credential") ? server.getString("credential") : "";
395         ret.add(new PeerConnection.IceServer(url, "", credential));
396       }
397       return ret;
398     } catch (JSONException e) {
399       throw new RuntimeException(e);
400     }
401   }
402
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) {
407         maybeDrainQueue();
408         return null;
409       }
410     }).execute();
411   }
412
413   // Send all queued messages if connected to the room.
414   private void maybeDrainQueue() {
415     synchronized (sendQueue) {
416       if (appRTCSignalingParameters == null) {
417         return;
418       }
419       try {
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) +
429                 " for msg: " + msg);
430           }
431         }
432       } catch (IOException e) {
433         throw new RuntimeException(e);
434       }
435       sendQueue.clear();
436     }
437   }
438
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() : "";
443   }
444 }