Support loopback mode and command line execution
for Android AppRTCDemo when using WebSocket signaling.

- Add loopback support for new signaling. In loopback mode
only room connection is established, WebSocket connection is
not opened and all candidate/sdp messages are automatically
routed back.
- Fix command line support both for channek and new signaling.
Exit from application when room connection is closed and add
an option to run application for certain time period and exit.
- Plus some fixes for WebSocket signaling - support
POST (not used for now) and DELETE request to WebSocket server
and making sure that all available TURN server are used by
peer connection client.

BUG=3995,3937
R=jiayl@webrtc.org

Review URL: https://webrtc-codereview.appspot.com/24339004

git-svn-id: http://webrtc.googlecode.com/svn/trunk@7725 4adac7df-926f-26a2-2b94-8c16560cd09d
diff --git a/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java b/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java
index ddabcd6..96816a7 100644
--- a/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java
+++ b/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java
@@ -39,7 +39,7 @@
    * https://apprtc.appspot.com/?r=NNN. Once connection is established
    * onConnectedToRoom() callback with room parameters is invoked.
    */
-  public void connectToRoom(String url);
+  public void connectToRoom(String url, boolean loopback);
 
   /**
    * Send offer SDP to the other participant.
@@ -71,7 +71,7 @@
     public final MediaConstraints pcConstraints;
     public final MediaConstraints videoConstraints;
     public final MediaConstraints audioConstraints;
-    public final String postMessageUrl;
+    public final String roomUrl;
     public final String roomId;
     public final String clientId;
     public final String channelToken;
@@ -81,14 +81,14 @@
         List<PeerConnection.IceServer> iceServers,
         boolean initiator, MediaConstraints pcConstraints,
         MediaConstraints videoConstraints, MediaConstraints audioConstraints,
-        String postMessageUrl, String roomId, String clientId,
+        String roomUrl, String roomId, String clientId,
         String channelToken, String offerSdp ) {
       this.iceServers = iceServers;
       this.initiator = initiator;
       this.pcConstraints = pcConstraints;
       this.videoConstraints = videoConstraints;
       this.audioConstraints = audioConstraints;
-      this.postMessageUrl = postMessageUrl;
+      this.roomUrl = roomUrl;
       this.roomId = roomId;
       this.clientId = clientId;
       this.channelToken = channelToken;
diff --git a/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java b/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java
index 7facd3c..9093b23 100644
--- a/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java
+++ b/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java
@@ -85,7 +85,10 @@
   private TextView hudView;
   private TextView roomName;
   private ImageButton videoScalingButton;
+  private boolean commandLineRun;
+  private int runTimeMs;
   private boolean iceConnected;
+  private boolean isError;
 
   @Override
   public void onCreate(Bundle savedInstanceState) {
@@ -194,30 +197,44 @@
 
     final Intent intent = getIntent();
     Uri url = intent.getData();
+    boolean loopback = intent.getBooleanExtra(
+        ConnectActivity.EXTRA_LOOPBACK, false);
+    commandLineRun = intent.getBooleanExtra(
+        ConnectActivity.EXTRA_CMDLINE, false);
+    runTimeMs = intent.getIntExtra(
+        ConnectActivity.EXTRA_RUNTIME, 0);
     if (url != null) {
       String room = url.getQueryParameter("r");
-      String loopback = url.getQueryParameter("debug");
-      if ((room != null && !room.equals("")) ||
-          (loopback != null && loopback.equals("loopback"))) {
+      if (loopback || (room != null && !room.equals(""))) {
         logAndToast(getString(R.string.connecting_to, url));
         if (USE_WEBSOCKETS) {
           appRtcClient = new WebSocketRTCClient(this);
         } else {
           appRtcClient = new GAERTCClient(this, this);
         }
-        appRtcClient.connectToRoom(url.toString());
-        if (room != null && !room.equals("")) {
-          roomName.setText(room);
-        } else {
+        appRtcClient.connectToRoom(url.toString(), loopback);
+        if (loopback) {
           roomName.setText("loopback");
+        } else {
+          roomName.setText(room);
+        }
+        if (commandLineRun && runTimeMs > 0) {
+          // For command line execution run connection for <runTimeMs> and exit.
+          videoView.postDelayed(new Runnable() {
+            public void run() {
+              disconnect();
+            }
+          }, runTimeMs);
         }
       } else {
         logAndToast("Empty or missing room name!");
+        setResult(RESULT_CANCELED);
         finish();
       }
     } else {
       logAndToast(getString(R.string.missing_url));
-      Log.wtf(TAG, "Didn't get any URL in intent!");
+      Log.e(TAG, "Didn't get any URL in intent!");
+      setResult(RESULT_CANCELED);
       finish();
     }
   }
@@ -253,10 +270,6 @@
   @Override
   protected void onDestroy() {
     disconnect();
-    if (audioManager != null) {
-      audioManager.close();
-      audioManager = null;
-    }
     super.onDestroy();
   }
 
@@ -327,20 +340,34 @@
       pc.close();
       pc = null;
     }
+    if (audioManager != null) {
+      audioManager.close();
+      audioManager = null;
+    }
+    if (iceConnected && !isError) {
+      setResult(RESULT_OK);
+    } else {
+      setResult(RESULT_CANCELED);
+    }
     finish();
   }
 
-  private void disconnectWithMessage(final String errorMessage) {
-    new AlertDialog.Builder(this)
-    .setTitle(getText(R.string.channel_error_title))
-    .setMessage(errorMessage)
-    .setCancelable(false)
-    .setNeutralButton(R.string.ok, new DialogInterface.OnClickListener() {
-        public void onClick(DialogInterface dialog, int id) {
-          dialog.cancel();
-          disconnect();
-        }
-      }).create().show();
+  private void disconnectWithErrorMessage(final String errorMessage) {
+    if (commandLineRun) {
+      Log.e(TAG, "Critical error: " + errorMessage);
+      disconnect();
+    } else {
+      new AlertDialog.Builder(this)
+      .setTitle(getText(R.string.channel_error_title))
+      .setMessage(errorMessage)
+      .setCancelable(false)
+      .setNeutralButton(R.string.ok, new DialogInterface.OnClickListener() {
+          public void onClick(DialogInterface dialog, int id) {
+            dialog.cancel();
+            disconnect();
+          }
+        }).create().show();
+    }
   }
 
   // Poor-man's assert(): die with |msg| unless |condition| is true.
@@ -461,7 +488,10 @@
 
   @Override
   public void onChannelError(final String description) {
-    disconnectWithMessage(description);
+    if (!isError) {
+      isError = true;
+      disconnectWithErrorMessage(description);
+    }
   }
 
   // -----Implementation of PeerConnectionClient.PeerConnectionEvents.---------
@@ -501,7 +531,10 @@
 
   @Override
   public void onPeerConnectionError(String description) {
-    disconnectWithMessage(description);
+    if (!isError) {
+      isError = true;
+      disconnectWithErrorMessage(description);
+    }
   }
 
 }
diff --git a/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java b/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java
index ff78ebc..903ecf7 100644
--- a/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java
+++ b/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java
@@ -62,10 +62,14 @@
  */
 public class ConnectActivity extends Activity {
 
+  public static final String EXTRA_LOOPBACK = "org.appspot.apprtc.LOOPBACK";
+  public static final String EXTRA_CMDLINE = "org.appspot.apprtc.CMDLINE";
+  public static final String EXTRA_RUNTIME = "org.appspot.apprtc.RUNTIME";
   private static final String TAG = "ConnectActivity";
   private final boolean USE_WEBSOCKETS = false;
   private final String APPRTC_SERVER = "https://apprtc.appspot.com";
   private final String APPRTC_WS_SERVER = "https://8-dot-apprtc.appspot.com";
+  private final int CONNECTION_REQUEST = 1;
 
   private ImageButton addRoomButton;
   private ImageButton removeRoomButton;
@@ -81,6 +85,8 @@
   private String keyprefRoomList;
   private ArrayList<String> roomList;
   private ArrayAdapter<String> adapter;
+  private boolean commandLineRun;
+  private int runTimeMs;
 
   @Override
   public void onCreate(Bundle savedInstanceState) {
@@ -95,27 +101,20 @@
     keyprefRoom = getString(R.string.pref_room_key);
     keyprefRoomList = getString(R.string.pref_room_list_key);
 
-    // If an implicit VIEW intent is launching the app, go directly to that URL.
-    final Intent intent = getIntent();
-    if ("android.intent.action.VIEW".equals(intent.getAction())) {
-      connectToRoom(intent.getData().toString());
-      return;
-    }
-
     setContentView(R.layout.activity_connect);
 
     roomEditText = (EditText) findViewById(R.id.room_edittext);
     roomEditText.setOnEditorActionListener(
-        new TextView.OnEditorActionListener() {
-          @Override
-          public boolean onEditorAction(
-              TextView textView, int i, KeyEvent keyEvent) {
-            if (i == EditorInfo.IME_ACTION_DONE) {
-              addRoomButton.performClick();
-              return true;
-            }
-            return false;
+      new TextView.OnEditorActionListener() {
+        @Override
+        public boolean onEditorAction(
+            TextView textView, int i, KeyEvent keyEvent) {
+          if (i == EditorInfo.IME_ACTION_DONE) {
+            addRoomButton.performClick();
+            return true;
           }
+          return false;
+        }
     });
     roomEditText.requestFocus();
 
@@ -131,6 +130,21 @@
     connectLoopbackButton =
         (ImageButton) findViewById(R.id.connect_loopback_button);
     connectLoopbackButton.setOnClickListener(connectListener);
+
+    // If an implicit VIEW intent is launching the app, go directly to that URL.
+    commandLineRun = false;
+    final Intent intent = getIntent();
+    if ("android.intent.action.VIEW".equals(intent.getAction())) {
+      commandLineRun = true;
+      boolean loopback = intent.getBooleanExtra(EXTRA_LOOPBACK, false);
+      runTimeMs = intent.getIntExtra(EXTRA_RUNTIME, 0);
+      String url = intent.getData().toString();
+      if (loopback && !url.contains("debug=loopback")) {
+        url += "/?debug=loopback";
+      }
+      connectToRoom(url, loopback);
+      return;
+    }
   }
 
   @Override
@@ -188,6 +202,16 @@
     }
   }
 
+  @Override
+  protected void onActivityResult(
+      int requestCode, int resultCode, Intent data) {
+    if (requestCode == CONNECTION_REQUEST && commandLineRun) {
+      Log.d(TAG, "Return: " + resultCode);
+      setResult(resultCode);
+      finish();
+    }
+  }
+
   private final OnClickListener connectListener = new OnClickListener() {
     @Override
     public void onClick(View view) {
@@ -195,11 +219,13 @@
       if (view.getId() == R.id.connect_loopback_button) {
         loopback = true;
       }
-      String url = APPRTC_SERVER;
+      String url;
       if (USE_WEBSOCKETS) {
         url = APPRTC_WS_SERVER;
+      } else {
+        url = APPRTC_SERVER;
       }
-      if (loopback && !USE_WEBSOCKETS) {
+      if (loopback) {
         url += "/?debug=loopback";
       } else {
         String roomName = getSelectedItem();
@@ -267,16 +293,19 @@
         url += "&googCpuOveruseDetection=false";
       }
       // TODO(kjellander): Add support for custom parameters to the URL.
-      connectToRoom(url);
+      connectToRoom(url, loopback);
     }
   };
 
-  private void connectToRoom(String roomUrl) {
+  private void connectToRoom(String roomUrl, boolean loopback) {
     if (validateUrl(roomUrl)) {
       Uri url = Uri.parse(roomUrl);
       Intent intent = new Intent(this, AppRTCDemoActivity.class);
       intent.setData(url);
-      startActivity(intent);
+      intent.putExtra(EXTRA_LOOPBACK, loopback);
+      intent.putExtra(EXTRA_CMDLINE, commandLineRun);
+      intent.putExtra(EXTRA_RUNTIME, runTimeMs);
+      startActivityForResult(intent, CONNECTION_REQUEST);
     }
   }
 
diff --git a/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java b/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java
index 2a1e8a1..fb0f9f0 100644
--- a/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java
+++ b/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java
@@ -60,6 +60,7 @@
   private SignalingParameters signalingParameters;
   private RoomParametersFetcher fetcher;
   private LinkedList<String> sendQueue = new LinkedList<String>();
+  private String postMessageUrl;
 
   public GAERTCClient(Activity activity, SignalingEvents events) {
     this.activity = activity;
@@ -74,8 +75,8 @@
    * on its GAE Channel.
    */
   @Override
-  public void connectToRoom(String url) {
-    fetcher = new RoomParametersFetcher(this);
+  public void connectToRoom(String url, boolean loopback) {
+    fetcher = new RoomParametersFetcher(this, loopback);
     fetcher.execute(url);
   }
 
@@ -167,8 +168,7 @@
       try {
         for (String msg : sendQueue) {
           Log.d(TAG, "SEND: " + msg);
-          URLConnection connection = new URL(
-              signalingParameters.postMessageUrl).openConnection();
+          URLConnection connection = new URL(postMessageUrl).openConnection();
           connection.setDoOutput(true);
           connection.getOutputStream().write(msg.getBytes("UTF-8"));
           if (!connection.getHeaderField(null).startsWith("HTTP/1.1 200 ")) {
@@ -203,6 +203,8 @@
       reportChannelError("Room does not support GAE channel signaling.");
       return;
     }
+    postMessageUrl = params.roomUrl + "/message?r=" +
+        params.roomId + "&u=" + params.clientId;
     gaeHandler = new GAEHandler();
     channelClient =
         new GAEChannelClient(activity, params.channelToken, gaeHandler);
diff --git a/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java b/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java
index b005de7..247b0aa 100644
--- a/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java
+++ b/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java
@@ -67,7 +67,6 @@
   // remote descriptions are set. Similarly local ICE candidates are sent to
   // remote peer after both local and remote description are set.
   private LinkedList<IceCandidate> queuedRemoteCandidates = null;
-  private LinkedList<IceCandidate> queuedLocalCandidates = null;
   private MediaConstraints sdpMediaConstraints;
   private MediaConstraints videoConstraints;
   private PeerConnectionEvents events;
@@ -88,7 +87,6 @@
     this.events = events;
     isInitiator = signalingParameters.initiator;
     queuedRemoteCandidates = new LinkedList<IceCandidate>();
-    queuedLocalCandidates = new LinkedList<IceCandidate>();
 
     sdpMediaConstraints = new MediaConstraints();
     sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair(
@@ -384,13 +382,6 @@
   }
 
   private void drainCandidates() {
-    if (queuedLocalCandidates != null) {
-      Log.d(TAG, "Send " + queuedLocalCandidates.size() + " local candidates");
-      for (IceCandidate candidate : queuedLocalCandidates) {
-        events.onIceCandidate(candidate);
-      }
-      queuedLocalCandidates = null;
-    }
     if (queuedRemoteCandidates != null) {
       Log.d(TAG, "Add " + queuedRemoteCandidates.size() + " remote candidates");
       for (IceCandidate candidate : queuedRemoteCandidates) {
@@ -449,11 +440,7 @@
     public void onIceCandidate(final IceCandidate candidate){
       activity.runOnUiThread(new Runnable() {
         public void run() {
-          if (queuedLocalCandidates != null) {
-            queuedLocalCandidates.add(candidate);
-          } else {
-            events.onIceCandidate(candidate);
-          }
+          events.onIceCandidate(candidate);
         }
       });
     }
diff --git a/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java b/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java
index 99981f3..f49a873 100644
--- a/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java
+++ b/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java
@@ -51,6 +51,7 @@
   private static final String TAG = "RoomRTCClient";
   private Exception exception = null;
   private RoomParametersFetcherEvents events = null;
+  private boolean loopback;
 
   /**
    * Room parameters fetcher callbacks.
@@ -68,9 +69,11 @@
     public void onSignalingParametersError(final String description);
   }
 
-  public RoomParametersFetcher(RoomParametersFetcherEvents events) {
+  public RoomParametersFetcher(
+      RoomParametersFetcherEvents events, boolean loopback) {
     super();
     this.events = events;
+    this.loopback = loopback;
   }
 
   @Override
@@ -138,11 +141,18 @@
       offerSdp = null;
     }
 
-    String postMessageUrl = url.substring(0, url.indexOf('?'));
-    postMessageUrl += "/message?r=" + roomId + "&u=" + clientId;
-    Log.d(TAG, "Post url: " + postMessageUrl);
+    String roomUrl = url.substring(0, url.indexOf('?'));
+    Log.d(TAG, "Room url: " + roomUrl);
 
-    boolean initiator = roomJson.getInt("initiator") == 1;
+    boolean initiator;
+    if (loopback) {
+      // In loopback mode caller should always be call initiator.
+      // TODO(glaznev): remove this once 8-dot-apprtc server will set initiator
+      // flag to true for loopback calls.
+      initiator = true;
+    } else {
+      initiator = roomJson.getInt("initiator") == 1;
+    }
     Log.d(TAG, "Initiator: " + initiator);
 
     LinkedList<PeerConnection.IceServer> iceServers =
@@ -156,15 +166,17 @@
       }
     }
     if (!isTurnPresent) {
-      PeerConnection.IceServer server =
-          requestTurnServer(roomJson.getString("turn_url"));
-      Log.d(TAG, "TurnServer: " + server);
-      iceServers.add(server);
+      LinkedList<PeerConnection.IceServer> turnServers =
+          requestTurnServers(roomJson.getString("turn_url"));
+      for (PeerConnection.IceServer turnServer : turnServers) {
+        Log.d(TAG, "TurnServer: " + turnServer);
+        iceServers.add(turnServer);
+      }
     }
 
     MediaConstraints pcConstraints = constraintsFromJSON(
         roomJson.getString("pc_constraints"));
-    addDTLSConstraintIfMissing(pcConstraints);
+    addDTLSConstraintIfMissing(pcConstraints, loopback);
     Log.d(TAG, "pcConstraints: " + pcConstraints);
     MediaConstraints videoConstraints = constraintsFromJSON(
         getAVConstraints("video",
@@ -178,13 +190,14 @@
     return new SignalingParameters(
         iceServers, initiator,
         pcConstraints, videoConstraints, audioConstraints,
-        postMessageUrl, roomId, clientId,
+        roomUrl, roomId, clientId,
         channelToken, offerSdp);
   }
 
   // Mimic Chrome and set DtlsSrtpKeyAgreement to true if not set to false by
   // the web-app.
-  private void addDTLSConstraintIfMissing(MediaConstraints pcConstraints) {
+  private void addDTLSConstraintIfMissing(
+      MediaConstraints pcConstraints, boolean loopback) {
     for (MediaConstraints.KeyValuePair pair : pcConstraints.mandatory) {
       if (pair.getKey().equals("DtlsSrtpKeyAgreement")) {
         return;
@@ -195,10 +208,15 @@
         return;
       }
     }
-    // DTLS isn't being suppressed (e.g. for debug=loopback calls), so enable
-    // it by default.
-    pcConstraints.optional.add(
-        new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true"));
+    // DTLS isn't being specified (e.g. for debug=loopback calls), so enable
+    // it for normal calls and disable for loopback calls.
+    if (loopback) {
+      pcConstraints.optional.add(
+          new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "false"));
+    } else {
+      pcConstraints.optional.add(
+          new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true"));
+    }
   }
 
   // Return the constraints specified for |type| of "audio" or "video" in
@@ -256,17 +274,25 @@
 
   // Requests & returns a TURN ICE Server based on a request URL.  Must be run
   // off the main thread!
-  private PeerConnection.IceServer requestTurnServer(String url)
+  private LinkedList<PeerConnection.IceServer> requestTurnServers(String url)
       throws IOException, JSONException {
+    LinkedList<PeerConnection.IceServer> turnServers =
+        new LinkedList<PeerConnection.IceServer>();
+    Log.d(TAG, "Request TURN from: " + url);
     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());
+    Log.d(TAG, "TURN response: " + response);
     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);
+    JSONArray turnUris = responseJSON.getJSONArray("uris");
+    for (int i = 0; i < turnUris.length(); i++) {
+      String uri = turnUris.getString(i);
+      turnServers.add(new PeerConnection.IceServer(uri, username, password));
+    }
+    return turnServers;
   }
 
   // Return the list of ICE servers described by a WebRTCPeerConnection
diff --git a/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java b/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java
index 373480d..170c807 100644
--- a/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java
+++ b/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java
@@ -35,8 +35,14 @@
 import de.tavendo.autobahn.WebSocketException;
 import de.tavendo.autobahn.WebSocket.WebSocketConnectionObserver;
 
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.HttpURLConnection;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.LinkedList;
 
 import org.json.JSONException;
 import org.json.JSONObject;
@@ -53,8 +59,18 @@
   private final Handler uiHandler;
   private WebSocketConnection ws;
   private WebSocketObserver wsObserver;
-  private URI serverURI;
+  private String wsServerUrl;
+  private String postServerUrl;
+  private String roomID;
+  private String clientID;
   private WebSocketConnectionState state;
+  // Http post/delete message queue. Messages are added from UI thread in
+  // post() and disconnect() calls. Messages are consumed by AsyncTask's
+  // background thread.
+  private LinkedList<WsHttpMessage> wsHttpQueue;
+  // WebSocket send queue. Messages are added to the queue when WebSocket
+  // client is not registered and are consumed in register() call.
+  private LinkedList<String> wsSendQueue;
 
   public enum WebSocketConnectionState {
     NEW, CONNECTED, REGISTERED, CLOSED, ERROR
@@ -74,6 +90,10 @@
   public WebSocketChannelClient(WebSocketChannelEvents events) {
     this.events = events;
     uiHandler = new Handler(Looper.getMainLooper());
+    roomID = null;
+    clientID = null;
+    wsHttpQueue = new LinkedList<WsHttpMessage>();
+    wsSendQueue = new LinkedList<String>();
     state = WebSocketConnectionState.NEW;
   }
 
@@ -81,18 +101,19 @@
     return state;
   }
 
-  public void connect(String url) {
+  public void connect(String wsUrl, String postUrl) {
     if (state != WebSocketConnectionState.NEW) {
       Log.e(TAG, "WebSocket is already connected.");
       return;
     }
-    Log.d(TAG, "Connecting WebSocket to: " + url);
+    Log.d(TAG, "Connecting WebSocket to: " + wsUrl + ". Post URL: " + postUrl);
 
     ws = new WebSocketConnection();
     wsObserver = new WebSocketObserver();
     try {
-      serverURI = new URI(url);
-      ws.connect(serverURI, wsObserver);
+      wsServerUrl = wsUrl;
+      postServerUrl = postUrl;
+      ws.connect(new URI(wsServerUrl), wsObserver);
     } catch (URISyntaxException e) {
       reportError("URI error: " + e.getMessage());
     } catch (WebSocketException e) {
@@ -100,39 +121,81 @@
     }
   }
 
-  public void register(String roomId, String clientId) {
+  public void setClientParameters(String roomID, String clientID) {
+    this.roomID = roomID;
+    this.clientID = clientID;
+  }
+
+  public void register() {
     if (state != WebSocketConnectionState.CONNECTED) {
       Log.w(TAG, "WebSocket register() in state " + state);
       return;
     }
+    if (roomID == null || clientID == null) {
+      Log.w(TAG, "Call WebSocket register() without setting client ID");
+      return;
+    }
     JSONObject json = new JSONObject();
     try {
       json.put("cmd", "register");
-      json.put("roomid", roomId);
-      json.put("clientid", clientId);
+      json.put("roomid", roomID);
+      json.put("clientid", clientID);
       Log.d(TAG, "WS SEND: " + json.toString());
       ws.sendTextMessage(json.toString());
       state = WebSocketConnectionState.REGISTERED;
+      // Send any previously accumulated messages.
+      synchronized(wsSendQueue) {
+        for (String sendMessage : wsSendQueue) {
+          send(sendMessage);
+        }
+        wsSendQueue.clear();
+      }
     } catch (JSONException e) {
       reportError("WebSocket register JSON error: " + e.getMessage());
     }
   }
 
   public void send(String message) {
-    if (state != WebSocketConnectionState.REGISTERED) {
-      Log.e(TAG, "WebSocket send() in non registered state : " + message);
-      return;
+    switch (state) {
+      case NEW:
+      case CONNECTED:
+        // Store outgoing messages and send them after websocket client
+        // is registered.
+        Log.d(TAG, "WS ACC: " + message);
+        synchronized(wsSendQueue) {
+          wsSendQueue.add(message);
+          return;
+        }
+      case ERROR:
+      case CLOSED:
+        Log.e(TAG, "WebSocket send() in error or closed state : " + message);
+        return;
+      case REGISTERED:
+        JSONObject json = new JSONObject();
+        try {
+          json.put("cmd", "send");
+          json.put("msg", message);
+          message = json.toString();
+          Log.d(TAG, "WS SEND: " + message);
+          ws.sendTextMessage(message);
+        } catch (JSONException e) {
+          reportError("WebSocket send JSON error: " + e.getMessage());
+        }
+        break;
     }
-    JSONObject json = new JSONObject();
-    try {
-      json.put("cmd", "send");
-      json.put("msg", message);
-      message = json.toString();
-      Log.d(TAG, "WS SEND: " + message);
-      ws.sendTextMessage(message);
-    } catch (JSONException e) {
-      reportError("WebSocket send JSON error: " + e.getMessage());
+    return;
+  }
+
+  // This call can be used to send WebSocket messages before WebSocket
+  // connection is opened. However for now this way of sending messages
+  // is not used until possible race condition of arriving ice candidates
+  // send through websocket before SDP answer sent through http post will be
+  // resolved.
+  public void post(String message) {
+    synchronized (wsHttpQueue) {
+      wsHttpQueue.add(new WsHttpMessage("POST", message));
     }
+    requestHttpQueueDrainInBackground();
   }
 
   public void disconnect() {
@@ -141,14 +204,19 @@
       send("{\"type\": \"bye\"}");
       state = WebSocketConnectionState.CONNECTED;
     }
-    // TODO(glaznev): send DELETE to http WebSocket server once send()
-    // will switch to http POST.
-
     // Close WebSocket in CONNECTED or ERROR states only.
     if (state == WebSocketConnectionState.CONNECTED ||
         state == WebSocketConnectionState.ERROR) {
-      state = WebSocketConnectionState.CLOSED;
       ws.disconnect();
+
+      // Send DELETE to http WebSocket server.
+      synchronized (wsHttpQueue) {
+        wsHttpQueue.clear();
+        wsHttpQueue.add(new WsHttpMessage("DELETE", ""));
+      }
+      requestHttpQueueDrainInBackground();
+
+      state = WebSocketConnectionState.CLOSED;
     }
   }
 
@@ -164,10 +232,83 @@
     });
   }
 
+  private class WsHttpMessage {
+    WsHttpMessage(String method, String message) {
+      this.method = method;
+      this.message = message;
+    }
+    public final String method;
+    public final String message;
+  }
+
+  // TODO(glaznev): This is not good implementation due to discrepancy
+  // between JS encodeURIComponent() and Java URLEncoder.encode().
+  // Remove this once WebSocket server will switch to a different encoding.
+  private String encodeURIComponent(String s) {
+    String result = null;
+    try {
+      result = URLEncoder.encode(s, "UTF-8")
+         .replaceAll("\\+", "%20")
+         .replaceAll("\\%21", "!")
+         .replaceAll("\\%27", "'")
+         .replaceAll("\\%28", "(")
+         .replaceAll("\\%29", ")")
+         .replaceAll("\\%7E", "~");
+    } catch (UnsupportedEncodingException e) {
+      result = s;
+    }
+    return result;
+  }
+
+  // Request an attempt to drain the send queue, on a background thread.
+  private void requestHttpQueueDrainInBackground() {
+    (new AsyncTask<Void, Void, Void>() {
+      public Void doInBackground(Void... unused) {
+        maybeDrainWsHttpQueue();
+        return null;
+      }
+    }).execute();
+  }
+
+  // Send all queued websocket messages.
+  private void maybeDrainWsHttpQueue() {
+    synchronized (wsHttpQueue) {
+      if (roomID == null || clientID == null) {
+        return;
+      }
+      try {
+        for (WsHttpMessage wsHttpMessage : wsHttpQueue) {
+          // Send POST request.
+          Log.d(TAG, "WS " + wsHttpMessage.method + " : " +
+              wsHttpMessage.message);
+          String postUrl = postServerUrl + roomID + "/" + clientID;
+          HttpURLConnection connection =
+              (HttpURLConnection) new URL(postUrl).openConnection();
+          connection.setDoOutput(true);
+          connection.setRequestProperty(
+              "Content-type", "application/x-www-form-urlencoded");
+          connection.setRequestMethod(wsHttpMessage.method);
+          if (wsHttpMessage.message.length() > 0) {
+            String message = "msg=" + encodeURIComponent(wsHttpMessage.message);
+            connection.getOutputStream().write(message.getBytes("UTF-8"));
+          }
+          String replyHeader = connection.getHeaderField(null);
+          if (!replyHeader.startsWith("HTTP/1.1 200 ")) {
+            reportError("Non-200 response to " + wsHttpMessage.method + " : " +
+                connection.getHeaderField(null));
+          }
+        }
+      } catch (IOException e) {
+        reportError("WS POST error: " + e.getMessage());
+      }
+      wsHttpQueue.clear();
+    }
+  }
+
   private class WebSocketObserver implements WebSocketConnectionObserver {
     @Override
     public void onOpen() {
-      Log.d(TAG, "WebSocket connection opened to: " + serverURI.toString());
+      Log.d(TAG, "WebSocket connection opened to: " + wsServerUrl);
       uiHandler.post(new Runnable() {
         public void run() {
           state = WebSocketConnectionState.CONNECTED;
diff --git a/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java b/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java
index aaef09b..ef6ef28 100644
--- a/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java
+++ b/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java
@@ -32,8 +32,8 @@
 import android.util.Log;
 
 import java.io.IOException;
+import java.net.HttpURLConnection;
 import java.net.URL;
-import java.net.URLConnection;
 import java.util.LinkedList;
 
 import org.appspot.apprtc.RoomParametersFetcher.RoomParametersFetcherEvents;
@@ -57,23 +57,31 @@
 public class WebSocketRTCClient implements AppRTCClient,
     RoomParametersFetcherEvents, WebSocketChannelEvents {
   private static final String TAG = "WSRTCClient";
-  private static final String WSS_SERVER = "wss://apprtc-ws.webrtc.org:8089/ws";
+  private static final String WSS_SERVER =
+      "wss://apprtc-ws.webrtc.org:8089/ws";
+  // TODO(glaznev): remove this hard-coded URL and instead get WebSocket http
+  // server URL from room response once it will be supported by 8-dot-apprtc.
+  private static final String WSS_POST_URL =
+      "https://apprtc-ws.webrtc.org:8089/";
 
   private enum ConnectionState {
     NEW, CONNECTED, CLOSED, ERROR
   };
   private final Handler uiHandler;
+  private boolean loopback;
   private SignalingEvents events;
   private SignalingParameters signalingParameters;
   private WebSocketChannelClient wsClient;
   private RoomParametersFetcher fetcher;
   private ConnectionState roomState;
-  private LinkedList<String> gaePostQueue;
+  private LinkedList<GAEMessage> gaePostQueue;
+  private String postMessageUrl;
+  private String byeMessageUrl;
 
   public WebSocketRTCClient(SignalingEvents events) {
     this.events = events;
     uiHandler = new Handler(Looper.getMainLooper());
-    gaePostQueue = new LinkedList<String>();
+    gaePostQueue = new LinkedList<GAEMessage>();
   }
 
   // --------------------------------------------------------------------
@@ -82,14 +90,24 @@
   @Override
   public void onSignalingParametersReady(final SignalingParameters params) {
     Log.d(TAG, "Room connection completed.");
-    if (!params.initiator && params.offerSdp == null) {
-      reportError("Offer SDP is not available");
+    if (!loopback && !params.initiator && params.offerSdp == null) {
+      reportError("Offer SDP is not available.");
+      return;
+    }
+    if (loopback && params.offerSdp != null) {
+      reportError("Loopback room is busy.");
       return;
     }
     signalingParameters = params;
+    postMessageUrl = params.roomUrl + "message?r=" +
+        params.roomId + "&u=" + params.clientId;
+    byeMessageUrl = params.roomUrl + "bye/" +
+        params.roomId + "/" + params.clientId;
     roomState = ConnectionState.CONNECTED;
+    wsClient.setClientParameters(
+        signalingParameters.roomId, signalingParameters.clientId);
+    wsClient.register();
     events.onConnectedToRoom(signalingParameters);
-    wsClient.register(signalingParameters.roomId, signalingParameters.clientId);
     events.onChannelOpen();
     if (!signalingParameters.initiator) {
       // For call receiver get sdp offer from room parameters.
@@ -112,8 +130,7 @@
   public void onWebSocketOpen() {
     Log.d(TAG, "Websocket connection completed.");
     if (roomState == ConnectionState.CONNECTED) {
-      wsClient.register(
-          signalingParameters.roomId, signalingParameters.clientId);
+      wsClient.register();
     }
   }
 
@@ -175,24 +192,30 @@
   // https://apprtc.appspot.com/?r=NNN, retrieve room parameters
   // and connect to WebSocket server.
   @Override
-  public void connectToRoom(String url) {
+  public void connectToRoom(String url, boolean loopback) {
+    this.loopback = loopback;
     // Get room parameters.
     roomState = ConnectionState.NEW;
-    fetcher = new RoomParametersFetcher(this);
+    fetcher = new RoomParametersFetcher(this, loopback);
     fetcher.execute(url);
     // Connect to WebSocket server.
     wsClient = new WebSocketChannelClient(this);
-    wsClient.connect(WSS_SERVER);
+    if (!loopback) {
+      wsClient.connect(WSS_SERVER, WSS_POST_URL);
+    }
   }
 
   @Override
   public void disconnect() {
     Log.d(TAG, "Disconnect. Room state: " + roomState);
+    wsClient.disconnect();
     if (roomState == ConnectionState.CONNECTED) {
       Log.d(TAG, "Closing room.");
-      sendGAEMessage("{\"type\": \"bye\"}");
+      // TODO(glaznev): Remove json bye message sending once new bye will
+      // be supported on 8-dot.
+      //sendGAEMessage(byeMessageUrl, "");
+      sendGAEMessage(postMessageUrl, "{\"type\": \"bye\"}");
     }
-    wsClient.disconnect();
   }
 
   // Send local SDP (offer or answer, depending on role) to the
@@ -202,14 +225,26 @@
   // we might want to filter elsewhere.
   @Override
   public void sendOfferSdp(final SessionDescription sdp) {
-    JSONObject json = new JSONObject();
-    jsonPut(json, "sdp", sdp.description);
-    jsonPut(json, "type", "offer");
-    sendGAEMessage(json.toString());
+    if (loopback) {
+      // In loopback mode rename this offer to answer and send it back.
+      SessionDescription sdpAnswer = new SessionDescription(
+          SessionDescription.Type.fromCanonicalForm("answer"),
+          sdp.description);
+      events.onRemoteDescription(sdpAnswer);
+    } else {
+      JSONObject json = new JSONObject();
+      jsonPut(json, "sdp", sdp.description);
+      jsonPut(json, "type", "offer");
+      sendGAEMessage(postMessageUrl, json.toString());
+    }
   }
 
   @Override
   public void sendAnswerSdp(final SessionDescription sdp) {
+    if (loopback) {
+      Log.e(TAG, "Sending answer in loopback mode.");
+      return;
+    }
     if (wsClient.getState() != WebSocketConnectionState.REGISTERED) {
       reportError("Sending answer SDP in non registered state.");
       return;
@@ -223,16 +258,20 @@
   // Send Ice candidate to the other participant.
   @Override
   public void sendLocalIceCandidate(final IceCandidate candidate) {
-    if (wsClient.getState() != WebSocketConnectionState.REGISTERED) {
-      reportError("Sending ICE candidate in non registered state.");
-      return;
+    if (loopback) {
+      events.onRemoteIceCandidate(candidate);
+    } else {
+      if (wsClient.getState() != WebSocketConnectionState.REGISTERED) {
+        reportError("Sending ICE candidate in non registered state.");
+        return;
+      }
+      JSONObject json = new JSONObject();
+      jsonPut(json, "type", "candidate");
+      jsonPut(json, "label", candidate.sdpMLineIndex);
+      jsonPut(json, "id", candidate.sdpMid);
+      jsonPut(json, "candidate", candidate.sdp);
+      wsClient.send(json.toString());
     }
-    JSONObject json = new JSONObject();
-    jsonPut(json, "type", "candidate");
-    jsonPut(json, "label", candidate.sdpMLineIndex);
-    jsonPut(json, "id", candidate.sdpMid);
-    jsonPut(json, "candidate", candidate.sdp);
-    wsClient.send(json.toString());
   }
 
   // --------------------------------------------------------------------
@@ -258,10 +297,19 @@
     }
   }
 
+  private class GAEMessage {
+    GAEMessage(String postUrl, String message) {
+      this.postUrl = postUrl;
+      this.message = message;
+    }
+    public final String postUrl;
+    public final String message;
+  }
+
   // Queue a message for sending to the room  and send it if already connected.
-  private synchronized void sendGAEMessage(String msg) {
+  private synchronized void sendGAEMessage(String url, String message) {
     synchronized (gaePostQueue) {
-      gaePostQueue.add(msg);
+      gaePostQueue.add(new GAEMessage(url, message));
     }
     (new AsyncTask<Void, Void, Void>() {
       public Void doInBackground(Void... unused) {
@@ -278,27 +326,32 @@
         return;
       }
       try {
-        for (String msg : gaePostQueue) {
-          Log.d(TAG, "ROOM SEND: " + msg);
+        for (GAEMessage gaeMessage : gaePostQueue) {
+          Log.d(TAG, "ROOM SEND to " + gaeMessage.postUrl +
+              ". Message: " + gaeMessage.message);
           // Check if this is 'bye' message and update room connection state.
-          // TODO(glaznev): change this to new bye message format:
-          // https://apprtc.appspot.com/bye/{roomid}/{clientid}
-          JSONObject json = new JSONObject(msg);
+          // TODO(glaznev): Uncomment this check and remove check below
+          // once new bye message will be supported by 8-dot.
+          //if (gaeMessage.postUrl.contains("bye")) {
+          //  roomState = ConnectionState.CLOSED;
+          //}
+          JSONObject json = new JSONObject(gaeMessage.message);
           String type = json.optString("type");
           if (type != null && type.equals("bye")) {
             roomState = ConnectionState.CLOSED;
           }
           // Send POST request.
-          URLConnection connection = new URL(
-              signalingParameters.postMessageUrl).openConnection();
+          HttpURLConnection connection =
+              (HttpURLConnection) new URL(gaeMessage.postUrl).openConnection();
           connection.setDoOutput(true);
           connection.setRequestProperty(
               "content-type", "text/plain; charset=utf-8");
-          connection.getOutputStream().write(msg.getBytes("UTF-8"));
+          connection.getOutputStream().write(
+              gaeMessage.message.getBytes("UTF-8"));
           String replyHeader = connection.getHeaderField(null);
           if (!replyHeader.startsWith("HTTP/1.1 200 ")) {
             reportError("Non-200 response to POST: " +
-                connection.getHeaderField(null) + " for msg: " + msg);
+                connection.getHeaderField(null));
           }
         }
       } catch (IOException e) {
@@ -306,6 +359,7 @@
       } catch (JSONException e) {
         reportError("GAE POST JSON error: " + e.getMessage());
       }
+
       gaePostQueue.clear();
     }
   }
diff --git a/talk/examples/android/third_party/autobanh/NOTICE b/talk/examples/android/third_party/autobanh/NOTICE
new file mode 100644
index 0000000..91ed7df
--- /dev/null
+++ b/talk/examples/android/third_party/autobanh/NOTICE
@@ -0,0 +1,3 @@
+AutobahnAndroid
+Copyright 2011,2012 Tavendo GmbH. Licensed under Apache 2.0
+This product includes software developed at Tavendo GmbH http://www.tavendo.de