Upstream version 5.34.104.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       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);
285
286       return new AppRTCSignalingParameters(
287           iceServers, gaeBaseHref, token, postMessageUrl, initiator,
288           pcConstraints, videoConstraints, audioConstraints);
289     }
290
291     // Return the constraints specified for |type| of "audio" or "video" in
292     // |mediaConstraintsString|.
293     private String getAVConstraints(
294         String type, String mediaConstraintsString) {
295       try {
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
299         // cases below.
300         if (!json.has(type) || !json.optBoolean(type, true)) {
301           // Case 1: "audio"/"video" is not present, or is an explicit "false"
302           // boolean.
303           return null;
304         }
305         if (json.optBoolean(type, false)) {
306           // Case 2: "audio"/"video" is an explicit "true" boolean.
307           return "{\"mandatory\": {}, \"optional\": []}";
308         }
309         // Case 3: "audio"/"video" is an object.
310         return json.getJSONObject(type).toString();
311       } catch (JSONException e) {
312         throw new RuntimeException(e);
313       }
314     }
315
316     private MediaConstraints constraintsFromJSON(String jsonString) {
317       if (jsonString == null) {
318         return null;
319       }
320       try {
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));
332             }
333           }
334         }
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));
343           }
344         }
345         return constraints;
346       } catch (JSONException e) {
347         throw new RuntimeException(e);
348       }
349     }
350
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)
355         throws IOException {
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);
361       }
362       String varValue = matcher.group(1);
363       if (matcher.find()) {
364         throw new IOException("Too many " + varName + " in HTML: " + roomHtml);
365       }
366       if (stripQuotes) {
367         varValue = varValue.substring(1, varValue.length() - 1);
368       }
369       return varValue;
370     }
371
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) {
375       try {
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);
389       }
390     }
391   }
392
393   // Return the list of ICE servers described by a WebRTCPeerConnection
394   // configuration string.
395   private LinkedList<PeerConnection.IceServer> iceServersFromPCConfigJSON(
396       String pcConfig) {
397     try {
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");
405         String credential =
406             server.has("credential") ? server.getString("credential") : "";
407         ret.add(new PeerConnection.IceServer(url, "", credential));
408       }
409       return ret;
410     } catch (JSONException e) {
411       throw new RuntimeException(e);
412     }
413   }
414
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) {
419         maybeDrainQueue();
420         return null;
421       }
422     }).execute();
423   }
424
425   // Send all queued messages if connected to the room.
426   private void maybeDrainQueue() {
427     synchronized (sendQueue) {
428       if (appRTCSignalingParameters == null) {
429         return;
430       }
431       try {
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) +
441                 " for msg: " + msg);
442           }
443         }
444       } catch (IOException e) {
445         throw new RuntimeException(e);
446       }
447       sendQueue.clear();
448     }
449   }
450
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() : "";
455   }
456 }