/* * libjingle * Copyright 2013, Google Inc. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. * 3. The name of the author may not be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO * EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ package org.appspot.apprtc; import android.app.Activity; import android.os.AsyncTask; import android.util.Log; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.webrtc.MediaConstraints; import org.webrtc.PeerConnection; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; import java.util.LinkedList; import java.util.List; import java.util.Scanner; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Negotiates signaling for chatting with apprtc.appspot.com "rooms". * Uses the client<->server specifics of the apprtc AppEngine webapp. * * To use: create an instance of this object (registering a message handler) and * call connectToRoom(). Once that's done call sendMessage() and wait for the * registered handler to be called with received messages. */ public class AppRTCClient { private static final String TAG = "AppRTCClient"; private GAEChannelClient channelClient; private final Activity activity; private final GAEChannelClient.MessageHandler gaeHandler; private final IceServersObserver iceServersObserver; // These members are only read/written under sendQueue's lock. private LinkedList sendQueue = new LinkedList(); private AppRTCSignalingParameters appRTCSignalingParameters; /** * Callback fired once the room's signaling parameters specify the set of * ICE servers to use. */ public static interface IceServersObserver { public void onIceServers(List iceServers); } public AppRTCClient( Activity activity, GAEChannelClient.MessageHandler gaeHandler, IceServersObserver iceServersObserver) { this.activity = activity; this.gaeHandler = gaeHandler; this.iceServersObserver = iceServersObserver; } /** * Asynchronously connect to an AppRTC room URL, e.g. * https://apprtc.appspot.com/?r=NNN and register message-handling callbacks * on its GAE Channel. */ public void connectToRoom(String url) { while (url.indexOf('?') < 0) { // Keep redirecting until we get a room number. (new RedirectResolver()).execute(url); return; // RedirectResolver above calls us back with the next URL. } (new RoomParameterGetter()).execute(url); } /** * Disconnect from the GAE Channel. */ public void disconnect() { if (channelClient != null) { channelClient.close(); channelClient = null; } } /** * Queue a message for sending to the room's channel and send it if already * connected (other wise queued messages are drained when the channel is eventually established). */ public synchronized void sendMessage(String msg) { synchronized (sendQueue) { sendQueue.add(msg); } requestQueueDrainInBackground(); } public boolean isInitiator() { return appRTCSignalingParameters.initiator; } public MediaConstraints pcConstraints() { return appRTCSignalingParameters.pcConstraints; } public MediaConstraints videoConstraints() { return appRTCSignalingParameters.videoConstraints; } // Struct holding the signaling parameters of an AppRTC room. private class AppRTCSignalingParameters { public final List iceServers; public final String gaeBaseHref; public final String channelToken; public final String postMessageUrl; public final boolean initiator; public final MediaConstraints pcConstraints; public final MediaConstraints videoConstraints; public AppRTCSignalingParameters( List iceServers, String gaeBaseHref, String channelToken, String postMessageUrl, boolean initiator, MediaConstraints pcConstraints, MediaConstraints videoConstraints) { this.iceServers = iceServers; this.gaeBaseHref = gaeBaseHref; this.channelToken = channelToken; this.postMessageUrl = postMessageUrl; this.initiator = initiator; this.pcConstraints = pcConstraints; this.videoConstraints = videoConstraints; } } // Load the given URL and return the value of the Location header of the // resulting 302 response. If the result is not a 302, throws. private class RedirectResolver extends AsyncTask { @Override protected String doInBackground(String... urls) { if (urls.length != 1) { throw new RuntimeException("Must be called with a single URL"); } try { return followRedirect(urls[0]); } catch (IOException e) { throw new RuntimeException(e); } } @Override protected void onPostExecute(String url) { connectToRoom(url); } private String followRedirect(String url) throws IOException { HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); connection.setInstanceFollowRedirects(false); int code = connection.getResponseCode(); if (code != HttpURLConnection.HTTP_MOVED_TEMP) { throw new IOException("Unexpected response: " + code + " for " + url + ", with contents: " + drainStream(connection.getInputStream())); } int n = 0; String name, value; while ((name = connection.getHeaderFieldKey(n)) != null) { value = connection.getHeaderField(n); if (name.equals("Location")) { return value; } ++n; } throw new IOException("Didn't find Location header!"); } } // AsyncTask that converts an AppRTC room URL into the set of signaling // parameters to use with that room. private class RoomParameterGetter extends AsyncTask { @Override protected AppRTCSignalingParameters doInBackground(String... urls) { if (urls.length != 1) { throw new RuntimeException("Must be called with a single URL"); } try { return getParametersForRoomUrl(urls[0]); } catch (IOException e) { throw new RuntimeException(e); } } @Override protected void onPostExecute(AppRTCSignalingParameters params) { channelClient = new GAEChannelClient(activity, params.channelToken, gaeHandler); synchronized (sendQueue) { appRTCSignalingParameters = params; } requestQueueDrainInBackground(); iceServersObserver.onIceServers(appRTCSignalingParameters.iceServers); } // Fetches |url| and fishes the signaling parameters out of the HTML via // regular expressions. // // TODO(fischman): replace this hackery with a dedicated JSON-serving URL in // apprtc so that this isn't necessary (here and in other future apps that // want to interop with apprtc). private AppRTCSignalingParameters getParametersForRoomUrl(String url) throws IOException { final Pattern fullRoomPattern = Pattern.compile( ".*\n *Sorry, this room is full\\..*"); String roomHtml = drainStream((new URL(url)).openConnection().getInputStream()); Matcher fullRoomMatcher = fullRoomPattern.matcher(roomHtml); if (fullRoomMatcher.find()) { throw new IOException("Room is full!"); } String gaeBaseHref = url.substring(0, url.indexOf('?')); String token = getVarValue(roomHtml, "channelToken", true); String postMessageUrl = "/message?r=" + getVarValue(roomHtml, "roomKey", true) + "&u=" + getVarValue(roomHtml, "me", true); boolean initiator = getVarValue(roomHtml, "initiator", false).equals("1"); LinkedList iceServers = iceServersFromPCConfigJSON(getVarValue(roomHtml, "pcConfig", false)); boolean isTurnPresent = false; for (PeerConnection.IceServer server : iceServers) { if (server.uri.startsWith("turn:")) { isTurnPresent = true; break; } } if (!isTurnPresent) { iceServers.add( requestTurnServer(getVarValue(roomHtml, "turnUrl", true))); } MediaConstraints pcConstraints = constraintsFromJSON( getVarValue(roomHtml, "pcConstraints", false)); Log.d(TAG, "pcConstraints: " + pcConstraints); MediaConstraints videoConstraints = constraintsFromJSON( getVideoConstraints( getVarValue(roomHtml, "mediaConstraints", false))); Log.d(TAG, "videoConstraints: " + videoConstraints); return new AppRTCSignalingParameters( iceServers, gaeBaseHref, token, postMessageUrl, initiator, pcConstraints, videoConstraints); } private String getVideoConstraints(String mediaConstraintsString) { try { JSONObject json = new JSONObject(mediaConstraintsString); JSONObject videoJson = json.optJSONObject("video"); if (videoJson == null) { return ""; } return videoJson.toString(); } catch (JSONException e) { throw new RuntimeException(e); } } private MediaConstraints constraintsFromJSON(String jsonString) { try { MediaConstraints constraints = new MediaConstraints(); JSONObject json = new JSONObject(jsonString); JSONObject mandatoryJSON = json.optJSONObject("mandatory"); if (mandatoryJSON != null) { JSONArray mandatoryKeys = mandatoryJSON.names(); if (mandatoryKeys != null) { for (int i = 0; i < mandatoryKeys.length(); ++i) { String key = (String) mandatoryKeys.getString(i); String value = mandatoryJSON.getString(key); constraints.mandatory.add( new MediaConstraints.KeyValuePair(key, value)); } } } JSONArray optionalJSON = json.optJSONArray("optional"); if (optionalJSON != null) { for (int i = 0; i < optionalJSON.length(); ++i) { JSONObject keyValueDict = optionalJSON.getJSONObject(i); String key = keyValueDict.names().getString(0); String value = keyValueDict.getString(key); constraints.optional.add( new MediaConstraints.KeyValuePair(key, value)); } } return constraints; } catch (JSONException e) { throw new RuntimeException(e); } } // Scan |roomHtml| for declaration & assignment of |varName| and return its // value, optionally stripping outside quotes if |stripQuotes| requests it. private String getVarValue( String roomHtml, String varName, boolean stripQuotes) throws IOException { final Pattern pattern = Pattern.compile( ".*\n *var " + varName + " = ([^\n]*);\n.*"); Matcher matcher = pattern.matcher(roomHtml); if (!matcher.find()) { throw new IOException("Missing " + varName + " in HTML: " + roomHtml); } String varValue = matcher.group(1); if (matcher.find()) { throw new IOException("Too many " + varName + " in HTML: " + roomHtml); } if (stripQuotes) { varValue = varValue.substring(1, varValue.length() - 1); } return varValue; } // Requests & returns a TURN ICE Server based on a request URL. Must be run // off the main thread! private PeerConnection.IceServer requestTurnServer(String url) { try { URLConnection connection = (new URL(url)).openConnection(); connection.addRequestProperty("user-agent", "Mozilla/5.0"); connection.addRequestProperty("origin", "https://apprtc.appspot.com"); String response = drainStream(connection.getInputStream()); JSONObject responseJSON = new JSONObject(response); String uri = responseJSON.getJSONArray("uris").getString(0); String username = responseJSON.getString("username"); String password = responseJSON.getString("password"); return new PeerConnection.IceServer(uri, username, password); } catch (JSONException e) { throw new RuntimeException(e); } catch (IOException e) { throw new RuntimeException(e); } } } // Return the list of ICE servers described by a WebRTCPeerConnection // configuration string. private LinkedList iceServersFromPCConfigJSON( String pcConfig) { try { JSONObject json = new JSONObject(pcConfig); JSONArray servers = json.getJSONArray("iceServers"); LinkedList ret = new LinkedList(); for (int i = 0; i < servers.length(); ++i) { JSONObject server = servers.getJSONObject(i); String url = server.getString("url"); String credential = server.has("credential") ? server.getString("credential") : ""; ret.add(new PeerConnection.IceServer(url, "", credential)); } return ret; } catch (JSONException e) { throw new RuntimeException(e); } } // Request an attempt to drain the send queue, on a background thread. private void requestQueueDrainInBackground() { (new AsyncTask() { public Void doInBackground(Void... unused) { maybeDrainQueue(); return null; } }).execute(); } // Send all queued messages if connected to the room. private void maybeDrainQueue() { synchronized (sendQueue) { if (appRTCSignalingParameters == null) { return; } try { for (String msg : sendQueue) { URLConnection connection = new URL( appRTCSignalingParameters.gaeBaseHref + appRTCSignalingParameters.postMessageUrl).openConnection(); connection.setDoOutput(true); connection.getOutputStream().write(msg.getBytes("UTF-8")); if (!connection.getHeaderField(null).startsWith("HTTP/1.1 200 ")) { throw new IOException( "Non-200 response to POST: " + connection.getHeaderField(null) + " for msg: " + msg); } } } catch (IOException e) { throw new RuntimeException(e); } sendQueue.clear(); } } // Return the contents of an InputStream as a String. private static String drainStream(InputStream in) { Scanner s = new Scanner(in).useDelimiter("\\A"); return s.hasNext() ? s.next() : ""; } }