Upstream version 7.36.149.0
[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   public MediaConstraints audioConstraints() {
135     return appRTCSignalingParameters.audioConstraints;
136   }
137
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;
148
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;
162     }
163   }
164
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> {
168     @Override
169     protected String doInBackground(String... urls) {
170       if (urls.length != 1) {
171         throw new RuntimeException("Must be called with a single URL");
172       }
173       try {
174         return followRedirect(urls[0]);
175       } catch (IOException e) {
176         throw new RuntimeException(e);
177       }
178     }
179
180     @Override
181     protected void onPostExecute(String url) {
182       connectToRoom(url);
183     }
184
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()));
193       }
194       int n = 0;
195       String name, value;
196       while ((name = connection.getHeaderFieldKey(n)) != null) {
197         value = connection.getHeaderField(n);
198         if (name.equals("Location")) {
199           return value;
200         }
201         ++n;
202       }
203       throw new IOException("Didn't find Location header!");
204     }
205   }
206
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> {
211     @Override
212     protected AppRTCSignalingParameters doInBackground(String... urls) {
213       if (urls.length != 1) {
214         throw new RuntimeException("Must be called with a single URL");
215       }
216       try {
217         return getParametersForRoomUrl(urls[0]);
218       } catch (IOException e) {
219         throw new RuntimeException(e);
220       }
221     }
222
223     @Override
224     protected void onPostExecute(AppRTCSignalingParameters params) {
225       channelClient =
226           new GAEChannelClient(activity, params.channelToken, gaeHandler);
227       synchronized (sendQueue) {
228         appRTCSignalingParameters = params;
229       }
230       requestQueueDrainInBackground();
231       iceServersObserver.onIceServers(appRTCSignalingParameters.iceServers);
232     }
233
234     // Fetches |url| and fishes the signaling parameters out of the HTML via
235     // regular expressions.
236     //
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)
241         throws IOException {
242       final Pattern fullRoomPattern = Pattern.compile(
243           ".*\n *Sorry, this room is full\\..*");
244
245       String roomHtml =
246           drainStream((new URL(url)).openConnection().getInputStream());
247
248       Matcher fullRoomMatcher = fullRoomPattern.matcher(roomHtml);
249       if (fullRoomMatcher.find()) {
250         throw new IOException("Room is full!");
251       }
252
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));
261
262       boolean isTurnPresent = false;
263       for (PeerConnection.IceServer server : iceServers) {
264         if (server.uri.startsWith("turn:")) {
265           isTurnPresent = true;
266           break;
267         }
268       }
269       if (!isTurnPresent) {
270         iceServers.add(
271             requestTurnServer(getVarValue(roomHtml, "turnUrl", true)));
272       }
273
274       MediaConstraints pcConstraints = constraintsFromJSON(
275           getVarValue(roomHtml, "pcConstraints", false));
276       addDTLSConstraintIfMissing(pcConstraints);
277       Log.d(TAG, "pcConstraints: " + pcConstraints);
278       MediaConstraints videoConstraints = constraintsFromJSON(
279           getAVConstraints("video",
280               getVarValue(roomHtml, "mediaConstraints", false)));
281       Log.d(TAG, "videoConstraints: " + videoConstraints);
282       MediaConstraints audioConstraints = constraintsFromJSON(
283           getAVConstraints("audio",
284               getVarValue(roomHtml, "mediaConstraints", false)));
285       Log.d(TAG, "audioConstraints: " + audioConstraints);
286
287       return new AppRTCSignalingParameters(
288           iceServers, gaeBaseHref, token, postMessageUrl, initiator,
289           pcConstraints, videoConstraints, audioConstraints);
290     }
291
292     // Mimic Chrome and set DtlsSrtpKeyAgreement to true if not set to false by
293     // the web-app.
294     private void addDTLSConstraintIfMissing(
295         MediaConstraints pcConstraints) {
296       for (MediaConstraints.KeyValuePair pair : pcConstraints.mandatory) {
297         if (pair.getKey().equals("DtlsSrtpKeyAgreement")) {
298           return;
299         }
300       }
301       for (MediaConstraints.KeyValuePair pair : pcConstraints.optional) {
302         if (pair.getKey().equals("DtlsSrtpKeyAgreement")) {
303           return;
304         }
305       }
306       // DTLS isn't being suppressed (e.g. for debug=loopback calls), so enable
307       // it by default.
308       pcConstraints.optional.add(
309           new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true"));
310     }
311
312     // Return the constraints specified for |type| of "audio" or "video" in
313     // |mediaConstraintsString|.
314     private String getAVConstraints(
315         String type, String mediaConstraintsString) {
316       try {
317         JSONObject json = new JSONObject(mediaConstraintsString);
318         // Tricksy handling of values that are allowed to be (boolean or
319         // MediaTrackConstraints) by the getUserMedia() spec.  There are three
320         // cases below.
321         if (!json.has(type) || !json.optBoolean(type, true)) {
322           // Case 1: "audio"/"video" is not present, or is an explicit "false"
323           // boolean.
324           return null;
325         }
326         if (json.optBoolean(type, false)) {
327           // Case 2: "audio"/"video" is an explicit "true" boolean.
328           return "{\"mandatory\": {}, \"optional\": []}";
329         }
330         // Case 3: "audio"/"video" is an object.
331         return json.getJSONObject(type).toString();
332       } catch (JSONException e) {
333         throw new RuntimeException(e);
334       }
335     }
336
337     private MediaConstraints constraintsFromJSON(String jsonString) {
338       if (jsonString == null) {
339         return null;
340       }
341       try {
342         MediaConstraints constraints = new MediaConstraints();
343         JSONObject json = new JSONObject(jsonString);
344         JSONObject mandatoryJSON = json.optJSONObject("mandatory");
345         if (mandatoryJSON != null) {
346           JSONArray mandatoryKeys = mandatoryJSON.names();
347           if (mandatoryKeys != null) {
348             for (int i = 0; i < mandatoryKeys.length(); ++i) {
349               String key = mandatoryKeys.getString(i);
350               String value = mandatoryJSON.getString(key);
351               constraints.mandatory.add(
352                   new MediaConstraints.KeyValuePair(key, value));
353             }
354           }
355         }
356         JSONArray optionalJSON = json.optJSONArray("optional");
357         if (optionalJSON != null) {
358           for (int i = 0; i < optionalJSON.length(); ++i) {
359             JSONObject keyValueDict = optionalJSON.getJSONObject(i);
360             String key = keyValueDict.names().getString(0);
361             String value = keyValueDict.getString(key);
362             constraints.optional.add(
363                 new MediaConstraints.KeyValuePair(key, value));
364           }
365         }
366         return constraints;
367       } catch (JSONException e) {
368         throw new RuntimeException(e);
369       }
370     }
371
372     // Scan |roomHtml| for declaration & assignment of |varName| and return its
373     // value, optionally stripping outside quotes if |stripQuotes| requests it.
374     private String getVarValue(
375         String roomHtml, String varName, boolean stripQuotes)
376         throws IOException {
377       final Pattern pattern = Pattern.compile(
378           ".*\n *var " + varName + " = ([^\n]*);\n.*");
379       Matcher matcher = pattern.matcher(roomHtml);
380       if (!matcher.find()) {
381         throw new IOException("Missing " + varName + " in HTML: " + roomHtml);
382       }
383       String varValue = matcher.group(1);
384       if (matcher.find()) {
385         throw new IOException("Too many " + varName + " in HTML: " + roomHtml);
386       }
387       if (stripQuotes) {
388         varValue = varValue.substring(1, varValue.length() - 1);
389       }
390       return varValue;
391     }
392
393     // Requests & returns a TURN ICE Server based on a request URL.  Must be run
394     // off the main thread!
395     private PeerConnection.IceServer requestTurnServer(String url) {
396       try {
397         URLConnection connection = (new URL(url)).openConnection();
398         connection.addRequestProperty("user-agent", "Mozilla/5.0");
399         connection.addRequestProperty("origin", "https://apprtc.appspot.com");
400         String response = drainStream(connection.getInputStream());
401         JSONObject responseJSON = new JSONObject(response);
402         String uri = responseJSON.getJSONArray("uris").getString(0);
403         String username = responseJSON.getString("username");
404         String password = responseJSON.getString("password");
405         return new PeerConnection.IceServer(uri, username, password);
406       } catch (JSONException e) {
407         throw new RuntimeException(e);
408       } catch (IOException e) {
409         throw new RuntimeException(e);
410       }
411     }
412   }
413
414   // Return the list of ICE servers described by a WebRTCPeerConnection
415   // configuration string.
416   private LinkedList<PeerConnection.IceServer> iceServersFromPCConfigJSON(
417       String pcConfig) {
418     try {
419       JSONObject json = new JSONObject(pcConfig);
420       JSONArray servers = json.getJSONArray("iceServers");
421       LinkedList<PeerConnection.IceServer> ret =
422           new LinkedList<PeerConnection.IceServer>();
423       for (int i = 0; i < servers.length(); ++i) {
424         JSONObject server = servers.getJSONObject(i);
425         String url = server.getString("urls");
426         String credential =
427             server.has("credential") ? server.getString("credential") : "";
428         ret.add(new PeerConnection.IceServer(url, "", credential));
429       }
430       return ret;
431     } catch (JSONException e) {
432       throw new RuntimeException(e);
433     }
434   }
435
436   // Request an attempt to drain the send queue, on a background thread.
437   private void requestQueueDrainInBackground() {
438     (new AsyncTask<Void, Void, Void>() {
439       public Void doInBackground(Void... unused) {
440         maybeDrainQueue();
441         return null;
442       }
443     }).execute();
444   }
445
446   // Send all queued messages if connected to the room.
447   private void maybeDrainQueue() {
448     synchronized (sendQueue) {
449       if (appRTCSignalingParameters == null) {
450         return;
451       }
452       try {
453         for (String msg : sendQueue) {
454           URLConnection connection = new URL(
455               appRTCSignalingParameters.gaeBaseHref +
456               appRTCSignalingParameters.postMessageUrl).openConnection();
457           connection.setDoOutput(true);
458           connection.getOutputStream().write(msg.getBytes("UTF-8"));
459           if (!connection.getHeaderField(null).startsWith("HTTP/1.1 200 ")) {
460             throw new IOException(
461                 "Non-200 response to POST: " + connection.getHeaderField(null) +
462                 " for msg: " + msg);
463           }
464         }
465       } catch (IOException e) {
466         throw new RuntimeException(e);
467       }
468       sendQueue.clear();
469     }
470   }
471
472   // Return the contents of an InputStream as a String.
473   private static String drainStream(InputStream in) {
474     Scanner s = new Scanner(in).useDelimiter("\\A");
475     return s.hasNext() ? s.next() : "";
476   }
477 }