Cleaning up Android AppRTCDemo.

- Move signaling code from Activity to a separate class
and add interface for AppRTC signaling. For now
only pure GAE signaling implements this interface.
- Move peer connection, video source and peer connection
and SDP observer code from Activity to a separate class.
- Main Activity class will do only high level calls and
event handling for peer connection and signaling classes.
- Also add video renderer position update and use full
screen for local preview until the connection is established.

BUG=
R=braveyao@webrtc.org, pthatcher@webrtc.org

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

git-svn-id: http://webrtc.googlecode.com/svn/trunk@7469 4adac7df-926f-26a2-2b94-8c16560cd09d
diff --git a/talk/app/webrtc/java/android/org/webrtc/VideoRendererGui.java b/talk/app/webrtc/java/android/org/webrtc/VideoRendererGui.java
index 48fbfce..e0542ed 100644
--- a/talk/app/webrtc/java/android/org/webrtc/VideoRendererGui.java
+++ b/talk/app/webrtc/java/android/org/webrtc/VideoRendererGui.java
@@ -490,6 +490,15 @@
       updateTextureProperties = true;
     }
 
+    public void setPosition(int x, int y, int width, int height,
+        ScalingType scalingType) {
+      texLeft = (x - 50) / 50.0f;
+      texTop = (50 - y) / 50.0f;
+      texRight = Math.min(1.0f, (x + width - 50) / 50.0f);
+      texBottom = Math.max(-1.0f, (50 - y - height) / 50.0f);
+      updateTextureProperties = true;
+    }
+
     @Override
     public void setSize(final int width, final int height) {
       Log.d(TAG, "ID: " + id + ". YuvImageRenderer.setSize: " +
@@ -636,6 +645,23 @@
     return yuvImageRenderer;
   }
 
+  public static void update(
+      VideoRenderer.Callbacks renderer,
+      int x, int y, int width, int height, ScalingType scalingType) {
+    Log.d(TAG, "VideoRendererGui.update");
+    if (instance == null) {
+      throw new RuntimeException(
+          "Attempt to update yuv renderer before setting GLSurfaceView");
+    }
+    synchronized (instance.yuvImageRenderers) {
+      for (YuvImageRenderer yuvImageRenderer : instance.yuvImageRenderers) {
+        if (yuvImageRenderer == renderer) {
+          yuvImageRenderer.setPosition(x, y, width, height, scalingType);
+        }
+      }
+    }
+  }
+
   @Override
   public void onSurfaceCreated(GL10 unused, EGLConfig config) {
     Log.d(TAG, "VideoRendererGui.onSurfaceCreated");
diff --git a/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java b/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java
index 5c571fa..4728e51 100644
--- a/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java
+++ b/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java
@@ -24,121 +24,44 @@
  * 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.IceCandidate;
 import org.webrtc.MediaConstraints;
 import org.webrtc.PeerConnection;
+import org.webrtc.SessionDescription;
 
-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;
 
-/**
- * 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<String> sendQueue = new LinkedList<String>();
-  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<PeerConnection.IceServer> iceServers);
-  }
-
-  public AppRTCClient(
-      Activity activity, GAEChannelClient.MessageHandler gaeHandler,
-      IceServersObserver iceServersObserver) {
-    this.activity = activity;
-    this.gaeHandler = gaeHandler;
-    this.iceServersObserver = iceServersObserver;
-  }
-
+public interface AppRTCClient {
   /**
    * Asynchronously connect to an AppRTC room URL, e.g.
-   * https://apprtc.appspot.com/?r=NNN and register message-handling callbacks
-   * on its GAE Channel.
+   * https://apprtc.appspot.com/?r=NNN. Once connection is established
+   * onConnectedToRoom() callback with room parameters is invoked.
    */
-  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);
-  }
+  public void connectToRoom(String url);
 
   /**
-   * Disconnect from the GAE Channel.
+   * Send local SDP (offer or answer, depending on role) to the
+   * other participant.
    */
-  public void disconnect() {
-    if (channelClient != null) {
-      channelClient.close();
-      channelClient = null;
-    }
-  }
+  public void sendLocalDescription(final SessionDescription sdp);
 
   /**
-   * 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).
+   * Send Ice candidate to the other participant.
    */
-  public synchronized void sendMessage(String msg) {
-    synchronized (sendQueue) {
-      sendQueue.add(msg);
-    }
-    requestQueueDrainInBackground();
-  }
+  public void sendLocalIceCandidate(final IceCandidate candidate);
 
-  public boolean isInitiator() {
-    return appRTCSignalingParameters.initiator;
-  }
+  /**
+   * Disconnect from the channel.
+   */
+  public void disconnect();
 
-  public MediaConstraints pcConstraints() {
-    return appRTCSignalingParameters.pcConstraints;
-  }
-
-  public MediaConstraints videoConstraints() {
-    return appRTCSignalingParameters.videoConstraints;
-  }
-
-  public MediaConstraints audioConstraints() {
-    return appRTCSignalingParameters.audioConstraints;
-  }
-
-  // Struct holding the signaling parameters of an AppRTC room.
-  private class AppRTCSignalingParameters {
+  /**
+   * Struct holding the signaling parameters of an AppRTC room.
+   */
+  public class AppRTCSignalingParameters {
     public final List<PeerConnection.IceServer> 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;
@@ -146,13 +69,9 @@
 
     public AppRTCSignalingParameters(
         List<PeerConnection.IceServer> iceServers,
-        String gaeBaseHref, String channelToken, String postMessageUrl,
         boolean initiator, MediaConstraints pcConstraints,
         MediaConstraints videoConstraints, MediaConstraints audioConstraints) {
       this.iceServers = iceServers;
-      this.gaeBaseHref = gaeBaseHref;
-      this.channelToken = channelToken;
-      this.postMessageUrl = postMessageUrl;
       this.initiator = initiator;
       this.pcConstraints = pcConstraints;
       this.videoConstraints = videoConstraints;
@@ -160,289 +79,40 @@
     }
   }
 
-  // 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<String, Void, String> {
-    @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);
-      }
-    }
+  /**
+   * Signaling callbacks.
+   */
+  public static interface AppRTCSignalingEvents {
+    /**
+     * Callback fired once the room's signaling parameters
+     * AppRTCSignalingParameters are extracted.
+     */
+    public void onConnectedToRoom(final AppRTCSignalingParameters params);
 
-    @Override
-    protected void onPostExecute(String url) {
-      connectToRoom(url);
-    }
+    /**
+     * Callback fired once channel for signaling messages is opened and
+     * ready to receive messages.
+     */
+    public void onChannelOpen();
 
-    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!");
-    }
-  }
+    /**
+     * Callback fired once remote SDP is received.
+     */
+    public void onRemoteDescription(final SessionDescription sdp);
 
-  // AsyncTask that converts an AppRTC room URL into the set of signaling
-  // parameters to use with that room.
-  private class RoomParameterGetter
-      extends AsyncTask<String, Void, AppRTCSignalingParameters> {
-    @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 (JSONException e) {
-        throw new RuntimeException(e);
-      } catch (IOException e) {
-        throw new RuntimeException(e);
-      }
-    }
+    /**
+     * Callback fired once remote Ice candidate is received.
+     */
+    public void onRemoteIceCandidate(final IceCandidate candidate);
 
-    @Override
-    protected void onPostExecute(AppRTCSignalingParameters params) {
-      channelClient =
-          new GAEChannelClient(activity, params.channelToken, gaeHandler);
-      synchronized (sendQueue) {
-        appRTCSignalingParameters = params;
-      }
-      requestQueueDrainInBackground();
-      iceServersObserver.onIceServers(appRTCSignalingParameters.iceServers);
-    }
+    /**
+     * Callback fired once channel is closed.
+     */
+    public void onChannelClose();
 
-    // Fetches |url| and fishes the signaling parameters out of the JSON.
-    private AppRTCSignalingParameters getParametersForRoomUrl(String url)
-        throws IOException, JSONException {
-      url = url + "&t=json";
-      JSONObject roomJson = new JSONObject(
-          drainStream((new URL(url)).openConnection().getInputStream()));
-
-      if (roomJson.has("error")) {
-        JSONArray errors = roomJson.getJSONArray("error_messages");
-        throw new IOException(errors.toString());
-      }
-
-      String gaeBaseHref = url.substring(0, url.indexOf('?'));
-      String token = roomJson.getString("token");
-      String postMessageUrl = "/message?r=" +
-          roomJson.getString("room_key") + "&u=" +
-          roomJson.getString("me");
-      boolean initiator = roomJson.getInt("initiator") == 1;
-      LinkedList<PeerConnection.IceServer> iceServers =
-          iceServersFromPCConfigJSON(roomJson.getString("pc_config"));
-
-      boolean isTurnPresent = false;
-      for (PeerConnection.IceServer server : iceServers) {
-        if (server.uri.startsWith("turn:")) {
-          isTurnPresent = true;
-          break;
-        }
-      }
-      if (!isTurnPresent) {
-        iceServers.add(requestTurnServer(roomJson.getString("turn_url")));
-      }
-
-      MediaConstraints pcConstraints = constraintsFromJSON(
-          roomJson.getString("pc_constraints"));
-      addDTLSConstraintIfMissing(pcConstraints);
-      Log.d(TAG, "pcConstraints: " + pcConstraints);
-      MediaConstraints videoConstraints = constraintsFromJSON(
-          getAVConstraints("video",
-              roomJson.getString("media_constraints")));
-      Log.d(TAG, "videoConstraints: " + videoConstraints);
-      MediaConstraints audioConstraints = constraintsFromJSON(
-          getAVConstraints("audio",
-              roomJson.getString("media_constraints")));
-      Log.d(TAG, "audioConstraints: " + audioConstraints);
-
-      return new AppRTCSignalingParameters(
-          iceServers, gaeBaseHref, token, postMessageUrl, initiator,
-          pcConstraints, videoConstraints, audioConstraints);
-    }
-
-    // Mimic Chrome and set DtlsSrtpKeyAgreement to true if not set to false by
-    // the web-app.
-    private void addDTLSConstraintIfMissing(
-        MediaConstraints pcConstraints) {
-      for (MediaConstraints.KeyValuePair pair : pcConstraints.mandatory) {
-        if (pair.getKey().equals("DtlsSrtpKeyAgreement")) {
-          return;
-        }
-      }
-      for (MediaConstraints.KeyValuePair pair : pcConstraints.optional) {
-        if (pair.getKey().equals("DtlsSrtpKeyAgreement")) {
-          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"));
-    }
-
-    // Return the constraints specified for |type| of "audio" or "video" in
-    // |mediaConstraintsString|.
-    private String getAVConstraints(
-        String type, String mediaConstraintsString) {
-      try {
-        JSONObject json = new JSONObject(mediaConstraintsString);
-        // Tricksy handling of values that are allowed to be (boolean or
-        // MediaTrackConstraints) by the getUserMedia() spec.  There are three
-        // cases below.
-        if (!json.has(type) || !json.optBoolean(type, true)) {
-          // Case 1: "audio"/"video" is not present, or is an explicit "false"
-          // boolean.
-          return null;
-        }
-        if (json.optBoolean(type, false)) {
-          // Case 2: "audio"/"video" is an explicit "true" boolean.
-          return "{\"mandatory\": {}, \"optional\": []}";
-        }
-        // Case 3: "audio"/"video" is an object.
-        return json.getJSONObject(type).toString();
-      } catch (JSONException e) {
-        throw new RuntimeException(e);
-      }
-    }
-
-    private MediaConstraints constraintsFromJSON(String jsonString) {
-      if (jsonString == null) {
-        return null;
-      }
-      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 = 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);
-      }
-    }
-
-    // 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<PeerConnection.IceServer> iceServersFromPCConfigJSON(
-      String pcConfig) {
-    try {
-      JSONObject json = new JSONObject(pcConfig);
-      JSONArray servers = json.getJSONArray("iceServers");
-      LinkedList<PeerConnection.IceServer> ret =
-          new LinkedList<PeerConnection.IceServer>();
-      for (int i = 0; i < servers.length(); ++i) {
-        JSONObject server = servers.getJSONObject(i);
-        String url = server.getString("urls");
-        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<Void, Void, Void>() {
-      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() : "";
+    /**
+     * Callback fired once channel error happened.
+     */
+    public void onChannelError(int code, String description);
   }
 }
diff --git a/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java b/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java
index 2925368..4718a01 100644
--- a/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java
+++ b/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java
@@ -41,33 +41,18 @@
 import android.view.View;
 import android.view.ViewGroup.LayoutParams;
 import android.view.WindowManager;
-import android.webkit.JavascriptInterface;
 import android.widget.EditText;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import org.json.JSONException;
-import org.json.JSONObject;
-import org.webrtc.DataChannel;
+import org.appspot.apprtc.AppRTCClient.AppRTCSignalingParameters;
 import org.webrtc.IceCandidate;
-import org.webrtc.MediaConstraints;
-import org.webrtc.MediaStream;
-import org.webrtc.PeerConnection;
 import org.webrtc.PeerConnectionFactory;
-import org.webrtc.SdpObserver;
 import org.webrtc.SessionDescription;
 import org.webrtc.StatsObserver;
 import org.webrtc.StatsReport;
-import org.webrtc.VideoCapturer;
 import org.webrtc.VideoRenderer;
 import org.webrtc.VideoRendererGui;
-import org.webrtc.VideoSource;
-import org.webrtc.VideoTrack;
-
-import java.util.LinkedList;
-import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 /**
  * Main Activity of the AppRTCDemo Android app demonstrating interoperability
@@ -75,16 +60,12 @@
  * apprtc.appspot.com demo webapp.
  */
 public class AppRTCDemoActivity extends Activity
-    implements AppRTCClient.IceServersObserver {
-  private static final String TAG = "AppRTCDemoActivity";
-  private PeerConnectionFactory factory;
-  private VideoSource videoSource;
-  private boolean videoSourceStopped;
-  private PeerConnection pc;
-  private final PCObserver pcObserver = new PCObserver();
-  private final SDPObserver sdpObserver = new SDPObserver();
-  private final GAEChannelClient.MessageHandler gaeHandler = new GAEHandler();
-  private AppRTCClient appRtcClient = new AppRTCClient(this, gaeHandler, this);
+    implements AppRTCClient.AppRTCSignalingEvents,
+      PeerConnectionClient.PeerConnectionEvents {
+  private static final String TAG = "AppRTCClient";
+  private PeerConnectionClient pc;
+  private AppRTCClient appRtcClient = new GAERTCClient(this, this);
+  private AppRTCSignalingParameters appRtcParameters;
   private AppRTCGLView vsv;
   private VideoRenderer.Callbacks localRender;
   private VideoRenderer.Callbacks remoteRender;
@@ -92,11 +73,8 @@
   private final LayoutParams hudLayout =
       new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
   private TextView hudView;
-  private LinkedList<IceCandidate> queuedRemoteCandidates =
-      new LinkedList<IceCandidate>();
   // Synchronize on quit[0] to avoid teardown-related crashes.
   private final Boolean[] quit = new Boolean[] { false };
-  private MediaConstraints sdpMediaConstraints;
 
   @Override
   public void onCreate(Bundle savedInstanceState) {
@@ -115,7 +93,7 @@
     VideoRendererGui.setView(vsv);
     remoteRender = VideoRendererGui.create(0, 0, 100, 100,
         VideoRendererGui.ScalingType.SCALE_ASPECT_FIT);
-    localRender = VideoRendererGui.create(70, 5, 25, 25,
+    localRender = VideoRendererGui.create(0, 0, 100, 100,
         VideoRendererGui.ScalingType.SCALE_ASPECT_FIT);
 
     vsv.setOnClickListener(new View.OnClickListener() {
@@ -144,12 +122,6 @@
         AudioManager.MODE_IN_CALL : AudioManager.MODE_IN_COMMUNICATION);
     audioManager.setSpeakerphoneOn(!isWiredHeadsetOn);
 
-    sdpMediaConstraints = new MediaConstraints();
-    sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair(
-        "OfferToReceiveAudio", "true"));
-    sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair(
-        "OfferToReceiveVideo", "true"));
-
     final Intent intent = getIntent();
     if ("android.intent.action.VIEW".equals(intent.getAction())) {
       connectToRoom(intent.getData().toString());
@@ -158,13 +130,46 @@
     showGetRoomUI();
   }
 
+  @Override
+  public void onPause() {
+    super.onPause();
+    vsv.onPause();
+    if (pc != null) {
+      pc.stopVideoSource();
+    }
+  }
+
+  @Override
+  public void onResume() {
+    super.onResume();
+    vsv.onResume();
+    if (pc != null) {
+      pc.startVideoSource();
+    }
+  }
+
+  @Override
+  public void onConfigurationChanged (Configuration newConfig) {
+    Point displaySize = new Point();
+    getWindowManager().getDefaultDisplay().getSize(displaySize);
+    vsv.updateDisplaySize(displaySize);
+    super.onConfigurationChanged(newConfig);
+  }
+
+  @Override
+  protected void onDestroy() {
+    disconnectAndExit();
+    super.onDestroy();
+  }
+
   private void showGetRoomUI() {
     final EditText roomInput = new EditText(this);
     roomInput.setText("https://apprtc.appspot.com/?r=");
     roomInput.setSelection(roomInput.getText().length());
     DialogInterface.OnClickListener listener =
         new DialogInterface.OnClickListener() {
-          @Override public void onClick(DialogInterface dialog, int which) {
+          @Override
+          public void onClick(DialogInterface dialog, int which) {
             abortUnless(which == DialogInterface.BUTTON_POSITIVE, "lolwat?");
             dialog.dismiss();
             connectToRoom(roomInput.getText().toString());
@@ -237,66 +242,56 @@
     return activeConnectionbuilder.toString();
   }
 
-  @Override
-  public void onPause() {
-    super.onPause();
-    vsv.onPause();
-    if (videoSource != null) {
-      videoSource.stop();
-      videoSourceStopped = true;
+  // Disconnect from remote resources, dispose of local resources, and exit.
+  private void disconnectAndExit() {
+    synchronized (quit[0]) {
+      if (quit[0]) {
+        return;
+      }
+      quit[0] = true;
+      if (pc != null) {
+        pc.close();
+        pc = null;
+      }
+      if (appRtcClient != null) {
+        appRtcClient.disconnect();
+        appRtcClient = null;
+      }
+      finish();
     }
   }
 
-  @Override
-  public void onResume() {
-    super.onResume();
-    vsv.onResume();
-    if (videoSource != null && videoSourceStopped) {
-      videoSource.restart();
+  // Poor-man's assert(): die with |msg| unless |condition| is true.
+  private static void abortUnless(boolean condition, String msg) {
+    if (!condition) {
+      throw new RuntimeException(msg);
     }
   }
 
-  @Override
-  public void onConfigurationChanged (Configuration newConfig) {
-    Point displaySize = new Point();
-    getWindowManager().getDefaultDisplay().getSize(displaySize);
-    vsv.updateDisplaySize(displaySize);
-    super.onConfigurationChanged(newConfig);
+  // Log |msg| and Toast about it.
+  private void logAndToast(String msg) {
+    Log.d(TAG, msg);
+    if (logToast != null) {
+      logToast.cancel();
+    }
+    logToast = Toast.makeText(this, msg, Toast.LENGTH_SHORT);
+    logToast.show();
   }
 
-  // Just for fun (and to regression-test bug 2302) make sure that DataChannels
-  // can be created, queried, and disposed.
-  private static void createDataChannelToRegressionTestBug2302(
-      PeerConnection pc) {
-    DataChannel dc = pc.createDataChannel("dcLabel", new DataChannel.Init());
-    abortUnless("dcLabel".equals(dc.label()), "Unexpected label corruption?");
-    dc.close();
-    dc.dispose();
-  }
-
+  // -----Implementation of AppRTCClient.AppRTCSignalingEvents ---------------
+  // All events are called from UI thread.
   @Override
-  public void onIceServers(List<PeerConnection.IceServer> iceServers) {
+  public void onConnectedToRoom(final AppRTCSignalingParameters params) {
+    appRtcParameters = params;
     abortUnless(PeerConnectionFactory.initializeAndroidGlobals(
       this, true, true, VideoRendererGui.getEGLContext()),
         "Failed to initializeAndroidGlobals");
-    factory = new PeerConnectionFactory();
-
-    MediaConstraints pcConstraints = appRtcClient.pcConstraints();
-    pcConstraints.optional.add(
-        new MediaConstraints.KeyValuePair("RtpDataChannels", "true"));
-    pc = factory.createPeerConnection(iceServers, pcConstraints, pcObserver);
-
-    createDataChannelToRegressionTestBug2302(pc);  // See method comment.
-
-    // Uncomment to get ALL WebRTC tracing and SENSITIVE libjingle logging.
-    // NOTE: this _must_ happen while |factory| is alive!
-    // Logging.enableTracing(
-    //     "logcat:",
-    //     EnumSet.of(Logging.TraceLevel.TRACE_ALL),
-    //     Logging.Severity.LS_SENSITIVE);
+    logAndToast("Creating peer connection...");
+    pc = new PeerConnectionClient(
+        this, localRender, remoteRender, appRtcParameters, this);
 
     {
-      final PeerConnection finalPC = pc;
+      final PeerConnectionClient finalPC = pc;
       final Runnable repeatedStatsLogger = new Runnable() {
           public void run() {
             synchronized (quit[0]) {
@@ -330,367 +325,66 @@
       vsv.postDelayed(repeatedStatsLogger, 1000);
     }
 
-    {
-      logAndToast("Creating local video source...");
-      MediaStream lMS = factory.createLocalMediaStream("ARDAMS");
-      if (appRtcClient.videoConstraints() != null) {
-        VideoCapturer capturer = getVideoCapturer();
-        videoSource = factory.createVideoSource(
-            capturer, appRtcClient.videoConstraints());
-        VideoTrack videoTrack =
-            factory.createVideoTrack("ARDAMSv0", videoSource);
-        videoTrack.addRenderer(new VideoRenderer(localRender));
-        lMS.addTrack(videoTrack);
-      }
-      if (appRtcClient.audioConstraints() != null) {
-        lMS.addTrack(factory.createAudioTrack(
-            "ARDAMSa0",
-            factory.createAudioSource(appRtcClient.audioConstraints())));
-      }
-      pc.addStream(lMS, new MediaConstraints());
-    }
-    logAndToast("Waiting for ICE candidates...");
-  }
-
-  // Cycle through likely device names for the camera and return the first
-  // capturer that works, or crash if none do.
-  private VideoCapturer getVideoCapturer() {
-    String[] cameraFacing = { "front", "back" };
-    int[] cameraIndex = { 0, 1 };
-    int[] cameraOrientation = { 0, 90, 180, 270 };
-    for (String facing : cameraFacing) {
-      for (int index : cameraIndex) {
-        for (int orientation : cameraOrientation) {
-          String name = "Camera " + index + ", Facing " + facing +
-              ", Orientation " + orientation;
-          VideoCapturer capturer = VideoCapturer.create(name);
-          if (capturer != null) {
-            logAndToast("Using camera: " + name);
-            return capturer;
-          }
-        }
-      }
-    }
-    throw new RuntimeException("Failed to open capturer");
+    logAndToast("Waiting for remote connection...");
   }
 
   @Override
-  protected void onDestroy() {
+  public void onChannelOpen() {
+    if (appRtcParameters.initiator) {
+      logAndToast("Creating OFFER...");
+      // Create offer. Offer SDP will be sent to answering client in
+      // PeerConnectionEvents.onLocalDescription event.
+      pc.createOffer();
+    }
+  }
+
+  @Override
+  public void onRemoteDescription(final SessionDescription sdp) {
+    logAndToast("Received remote " + sdp.type + " ...");
+    pc.setRemoteDescription(sdp);
+    if (!appRtcParameters.initiator) {
+      logAndToast("Creating ANSWER...");
+      // Create answer. Answer SDP will be sent to offering client in
+      // PeerConnectionEvents.onLocalDescription event.
+      pc.createAnswer();
+    }
+  }
+
+  @Override
+  public void onRemoteIceCandidate(final IceCandidate candidate) {
+    pc.addRemoteIceCandidate(candidate);
+  }
+
+  @Override
+  public void onChannelClose() {
+    logAndToast("Remote end hung up; dropping PeerConnection");
     disconnectAndExit();
-    super.onDestroy();
   }
 
-  // Poor-man's assert(): die with |msg| unless |condition| is true.
-  private static void abortUnless(boolean condition, String msg) {
-    if (!condition) {
-      throw new RuntimeException(msg);
-    }
+  @Override
+  public void onChannelError(int code, String description) {
+    logAndToast("Channel error: " + code + ". " + description);
+    disconnectAndExit();
   }
 
-  // Log |msg| and Toast about it.
-  private void logAndToast(String msg) {
-    Log.d(TAG, msg);
-    if (logToast != null) {
-      logToast.cancel();
-    }
-    logToast = Toast.makeText(this, msg, Toast.LENGTH_SHORT);
-    logToast.show();
+  // -----Implementation of PeerConnectionClient.PeerConnectionEvents.---------
+  // Send local peer connection SDP and ICE candidates to remote party.
+  // All callbacks are invoked from UI thread.
+  @Override
+  public void onLocalDescription(final SessionDescription sdp) {
+    logAndToast("Sending " + sdp.type + " ...");
+    appRtcClient.sendLocalDescription(sdp);
   }
 
-  // Send |json| to the underlying AppEngine Channel.
-  private void sendMessage(JSONObject json) {
-    appRtcClient.sendMessage(json.toString());
+  @Override
+  public void onIceCandidate(final IceCandidate candidate) {
+    appRtcClient.sendLocalIceCandidate(candidate);
   }
 
-  // Put a |key|->|value| mapping in |json|.
-  private static void jsonPut(JSONObject json, String key, Object value) {
-    try {
-      json.put(key, value);
-    } catch (JSONException e) {
-      throw new RuntimeException(e);
-    }
+  @Override
+  public void onIceConnected() {
+    logAndToast("ICE connected");
+    VideoRendererGui.update(localRender, 70, 70, 28, 28,
+        VideoRendererGui.ScalingType.SCALE_ASPECT_FIT);
   }
-
-  // Mangle SDP to prefer ISAC/16000 over any other audio codec.
-  private static String preferISAC(String sdpDescription) {
-    String[] lines = sdpDescription.split("\r\n");
-    int mLineIndex = -1;
-    String isac16kRtpMap = null;
-    Pattern isac16kPattern =
-        Pattern.compile("^a=rtpmap:(\\d+) ISAC/16000[\r]?$");
-    for (int i = 0;
-         (i < lines.length) && (mLineIndex == -1 || isac16kRtpMap == null);
-         ++i) {
-      if (lines[i].startsWith("m=audio ")) {
-        mLineIndex = i;
-        continue;
-      }
-      Matcher isac16kMatcher = isac16kPattern.matcher(lines[i]);
-      if (isac16kMatcher.matches()) {
-        isac16kRtpMap = isac16kMatcher.group(1);
-        continue;
-      }
-    }
-    if (mLineIndex == -1) {
-      Log.d(TAG, "No m=audio line, so can't prefer iSAC");
-      return sdpDescription;
-    }
-    if (isac16kRtpMap == null) {
-      Log.d(TAG, "No ISAC/16000 line, so can't prefer iSAC");
-      return sdpDescription;
-    }
-    String[] origMLineParts = lines[mLineIndex].split(" ");
-    StringBuilder newMLine = new StringBuilder();
-    int origPartIndex = 0;
-    // Format is: m=<media> <port> <proto> <fmt> ...
-    newMLine.append(origMLineParts[origPartIndex++]).append(" ");
-    newMLine.append(origMLineParts[origPartIndex++]).append(" ");
-    newMLine.append(origMLineParts[origPartIndex++]).append(" ");
-    newMLine.append(isac16kRtpMap);
-    for (; origPartIndex < origMLineParts.length; ++origPartIndex) {
-      if (!origMLineParts[origPartIndex].equals(isac16kRtpMap)) {
-        newMLine.append(" ").append(origMLineParts[origPartIndex]);
-      }
-    }
-    lines[mLineIndex] = newMLine.toString();
-    StringBuilder newSdpDescription = new StringBuilder();
-    for (String line : lines) {
-      newSdpDescription.append(line).append("\r\n");
-    }
-    return newSdpDescription.toString();
-  }
-
-  // Implementation detail: observe ICE & stream changes and react accordingly.
-  private class PCObserver implements PeerConnection.Observer {
-    @Override public void onIceCandidate(final IceCandidate candidate){
-      runOnUiThread(new Runnable() {
-          public void run() {
-            JSONObject json = new JSONObject();
-            jsonPut(json, "type", "candidate");
-            jsonPut(json, "label", candidate.sdpMLineIndex);
-            jsonPut(json, "id", candidate.sdpMid);
-            jsonPut(json, "candidate", candidate.sdp);
-            sendMessage(json);
-          }
-        });
-    }
-
-    @Override public void onError(){
-      runOnUiThread(new Runnable() {
-          public void run() {
-            throw new RuntimeException("PeerConnection error!");
-          }
-        });
-    }
-
-    @Override public void onSignalingChange(
-        PeerConnection.SignalingState newState) {
-    }
-
-    @Override public void onIceConnectionChange(
-        PeerConnection.IceConnectionState newState) {
-    }
-
-    @Override public void onIceGatheringChange(
-        PeerConnection.IceGatheringState newState) {
-    }
-
-    @Override public void onAddStream(final MediaStream stream){
-      runOnUiThread(new Runnable() {
-          public void run() {
-            abortUnless(stream.audioTracks.size() <= 1 &&
-                stream.videoTracks.size() <= 1,
-                "Weird-looking stream: " + stream);
-            if (stream.videoTracks.size() == 1) {
-              stream.videoTracks.get(0).addRenderer(
-                  new VideoRenderer(remoteRender));
-            }
-          }
-        });
-    }
-
-    @Override public void onRemoveStream(final MediaStream stream){
-      runOnUiThread(new Runnable() {
-          public void run() {
-            stream.videoTracks.get(0).dispose();
-          }
-        });
-    }
-
-    @Override public void onDataChannel(final DataChannel dc) {
-      runOnUiThread(new Runnable() {
-          public void run() {
-            throw new RuntimeException(
-                "AppRTC doesn't use data channels, but got: " + dc.label() +
-                " anyway!");
-          }
-        });
-    }
-
-    @Override public void onRenegotiationNeeded() {
-      // No need to do anything; AppRTC follows a pre-agreed-upon
-      // signaling/negotiation protocol.
-    }
-  }
-
-  // Implementation detail: handle offer creation/signaling and answer setting,
-  // as well as adding remote ICE candidates once the answer SDP is set.
-  private class SDPObserver implements SdpObserver {
-    private SessionDescription localSdp;
-
-    @Override public void onCreateSuccess(final SessionDescription origSdp) {
-      abortUnless(localSdp == null, "multiple SDP create?!?");
-      final SessionDescription sdp = new SessionDescription(
-          origSdp.type, preferISAC(origSdp.description));
-      localSdp = sdp;
-      runOnUiThread(new Runnable() {
-          public void run() {
-            pc.setLocalDescription(sdpObserver, sdp);
-          }
-        });
-    }
-
-    // Helper for sending local SDP (offer or answer, depending on role) to the
-    // other participant.  Note that it is important to send the output of
-    // create{Offer,Answer} and not merely the current value of
-    // getLocalDescription() because the latter may include ICE candidates that
-    // we might want to filter elsewhere.
-    private void sendLocalDescription() {
-      logAndToast("Sending " + localSdp.type);
-      JSONObject json = new JSONObject();
-      jsonPut(json, "type", localSdp.type.canonicalForm());
-      jsonPut(json, "sdp", localSdp.description);
-      sendMessage(json);
-    }
-
-    @Override public void onSetSuccess() {
-      runOnUiThread(new Runnable() {
-          public void run() {
-            if (appRtcClient.isInitiator()) {
-              if (pc.getRemoteDescription() != null) {
-                // We've set our local offer and received & set the remote
-                // answer, so drain candidates.
-                drainRemoteCandidates();
-              } else {
-                // We've just set our local description so time to send it.
-                sendLocalDescription();
-              }
-            } else {
-              if (pc.getLocalDescription() == null) {
-                // We just set the remote offer, time to create our answer.
-                logAndToast("Creating answer");
-                pc.createAnswer(SDPObserver.this, sdpMediaConstraints);
-              } else {
-                // Answer now set as local description; send it and drain
-                // candidates.
-                sendLocalDescription();
-                drainRemoteCandidates();
-              }
-            }
-          }
-        });
-    }
-
-    @Override public void onCreateFailure(final String error) {
-      runOnUiThread(new Runnable() {
-          public void run() {
-            throw new RuntimeException("createSDP error: " + error);
-          }
-        });
-    }
-
-    @Override public void onSetFailure(final String error) {
-      runOnUiThread(new Runnable() {
-          public void run() {
-            throw new RuntimeException("setSDP error: " + error);
-          }
-        });
-    }
-
-    private void drainRemoteCandidates() {
-      for (IceCandidate candidate : queuedRemoteCandidates) {
-        pc.addIceCandidate(candidate);
-      }
-      queuedRemoteCandidates = null;
-    }
-  }
-
-  // Implementation detail: handler for receiving GAE messages and dispatching
-  // them appropriately.
-  private class GAEHandler implements GAEChannelClient.MessageHandler {
-    @JavascriptInterface public void onOpen() {
-      if (!appRtcClient.isInitiator()) {
-        return;
-      }
-      logAndToast("Creating offer...");
-      pc.createOffer(sdpObserver, sdpMediaConstraints);
-    }
-
-    @JavascriptInterface public void onMessage(String data) {
-      try {
-        JSONObject json = new JSONObject(data);
-        String type = (String) json.get("type");
-        if (type.equals("candidate")) {
-          IceCandidate candidate = new IceCandidate(
-              (String) json.get("id"),
-              json.getInt("label"),
-              (String) json.get("candidate"));
-          if (queuedRemoteCandidates != null) {
-            queuedRemoteCandidates.add(candidate);
-          } else {
-            pc.addIceCandidate(candidate);
-          }
-        } else if (type.equals("answer") || type.equals("offer")) {
-          SessionDescription sdp = new SessionDescription(
-              SessionDescription.Type.fromCanonicalForm(type),
-              preferISAC((String) json.get("sdp")));
-          pc.setRemoteDescription(sdpObserver, sdp);
-        } else if (type.equals("bye")) {
-          logAndToast("Remote end hung up; dropping PeerConnection");
-          disconnectAndExit();
-        } else {
-          throw new RuntimeException("Unexpected message: " + data);
-        }
-      } catch (JSONException e) {
-        throw new RuntimeException(e);
-      }
-    }
-
-    @JavascriptInterface public void onClose() {
-      disconnectAndExit();
-    }
-
-    @JavascriptInterface public void onError(int code, String description) {
-      disconnectAndExit();
-    }
-  }
-
-  // Disconnect from remote resources, dispose of local resources, and exit.
-  private void disconnectAndExit() {
-    synchronized (quit[0]) {
-      if (quit[0]) {
-        return;
-      }
-      quit[0] = true;
-      if (pc != null) {
-        pc.dispose();
-        pc = null;
-      }
-      if (appRtcClient != null) {
-        appRtcClient.sendMessage("{\"type\": \"bye\"}");
-        appRtcClient.disconnect();
-        appRtcClient = null;
-      }
-      if (videoSource != null) {
-        videoSource.dispose();
-        videoSource = null;
-      }
-      if (factory != null) {
-        factory.dispose();
-        factory = null;
-      }
-      finish();
-    }
-  }
-
 }
diff --git a/talk/examples/android/src/org/appspot/apprtc/GAEChannelClient.java b/talk/examples/android/src/org/appspot/apprtc/GAEChannelClient.java
index 29a9113..bcc06ab 100644
--- a/talk/examples/android/src/org/appspot/apprtc/GAEChannelClient.java
+++ b/talk/examples/android/src/org/appspot/apprtc/GAEChannelClient.java
@@ -45,7 +45,7 @@
  * "androidMessageHandler".
  */
 public class GAEChannelClient {
-  private static final String TAG = "GAEChannelClient";
+  private static final String TAG = "GAERTCClient";
   private WebView webView;
   private final ProxyingMessageHandler proxyingMessageHandler;
 
@@ -55,7 +55,7 @@
    * Methods are guaranteed to be invoked on the UI thread of |activity| passed
    * to GAEChannelClient's constructor.
    */
-  public interface MessageHandler {
+  public interface GAEMessageHandler {
     public void onOpen();
     public void onMessage(String data);
     public void onClose();
@@ -65,7 +65,7 @@
   /** Asynchronously open an AppEngine channel. */
   @SuppressLint("SetJavaScriptEnabled")
   public GAEChannelClient(
-      Activity activity, String token, MessageHandler handler) {
+      Activity activity, String token, GAEMessageHandler handler) {
     webView = new WebView(activity);
     webView.getSettings().setJavaScriptEnabled(true);
     webView.setWebChromeClient(new WebChromeClient() {  // Purely for debugging.
@@ -105,12 +105,12 @@
   // (private, background) thread to the Activity's UI thread.
   private static class ProxyingMessageHandler {
     private final Activity activity;
-    private final MessageHandler handler;
+    private final GAEMessageHandler handler;
     private final boolean[] disconnected = { false };
     private final String token;
 
     public
-     ProxyingMessageHandler(Activity activity, MessageHandler handler,
+     ProxyingMessageHandler(Activity activity, GAEMessageHandler handler,
                             String token) {
       this.activity = activity;
       this.handler = handler;
diff --git a/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java b/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java
new file mode 100644
index 0000000..1d1f817
--- /dev/null
+++ b/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java
@@ -0,0 +1,487 @@
+/*
+ * 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 android.webkit.JavascriptInterface;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.webrtc.IceCandidate;
+import org.webrtc.MediaConstraints;
+import org.webrtc.PeerConnection;
+import org.webrtc.SessionDescription;
+
+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.Scanner;
+
+/**
+ * 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 room connection is established
+ * onConnectedToRoom() callback with room parameters is invoked.
+ * Messages to other party (with local Ice candidates and SDP) can
+ * be sent after GAE channel is opened and onChannelOpen() callback is invoked.
+ */
+public class GAERTCClient implements AppRTCClient {
+  private static final String TAG = "GAERTCClient";
+  private GAEChannelClient channelClient;
+  private final Activity activity;
+  private AppRTCClient.AppRTCSignalingEvents events;
+  private final GAEChannelClient.GAEMessageHandler gaeHandler =
+      new GAEHandler();
+  private AppRTCClient.AppRTCSignalingParameters appRTCSignalingParameters;
+  private String gaeBaseHref;
+  private String channelToken;
+  private String postMessageUrl;
+  private LinkedList<String> sendQueue = new LinkedList<String>();
+
+  public GAERTCClient(Activity activity,
+      AppRTCClient.AppRTCSignalingEvents events) {
+    this.activity = activity;
+    this.events = events;
+  }
+
+  /**
+   * Asynchronously connect to an AppRTC room URL, e.g.
+   * https://apprtc.appspot.com/?r=NNN and register message-handling callbacks
+   * on its GAE Channel.
+   */
+  @Override
+  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.
+   */
+  @Override
+  public void disconnect() {
+    if (channelClient != null) {
+      sendMessage("{\"type\": \"bye\"}");
+      channelClient.close();
+      channelClient = null;
+    }
+  }
+
+  /**
+   * Send local SDP (offer or answer, depending on role) to the
+   * other participant.  Note that it is important to send the output of
+   * create{Offer,Answer} and not merely the current value of
+   * getLocalDescription() because the latter may include ICE candidates that
+   * we might want to filter elsewhere.
+   */
+  @Override
+  public void sendLocalDescription(final SessionDescription sdp) {
+    JSONObject json = new JSONObject();
+    jsonPut(json, "type", sdp.type.canonicalForm());
+    jsonPut(json, "sdp", sdp.description);
+    sendMessage(json.toString());
+  }
+
+  /**
+   * Send Ice candidate to the other participant.
+   */
+  @Override
+  public void sendLocalIceCandidate(final IceCandidate candidate) {
+    JSONObject json = new JSONObject();
+    jsonPut(json, "type", "candidate");
+    jsonPut(json, "label", candidate.sdpMLineIndex);
+    jsonPut(json, "id", candidate.sdpMid);
+    jsonPut(json, "candidate", candidate.sdp);
+    sendMessage(json.toString());
+  }
+
+
+  // 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).
+  private synchronized void sendMessage(String msg) {
+    synchronized (sendQueue) {
+      sendQueue.add(msg);
+    }
+    requestQueueDrainInBackground();
+  }
+
+  // Put a |key|->|value| mapping in |json|.
+  private static void jsonPut(JSONObject json, String key, Object value) {
+    try {
+      json.put(key, value);
+    } catch (JSONException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  // 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<String, Void, String> {
+    @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<String, Void, AppRTCSignalingParameters> {
+    @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 (JSONException e) {
+        throw new RuntimeException(e);
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    @Override
+    protected void onPostExecute(AppRTCSignalingParameters params) {
+      channelClient =
+          new GAEChannelClient(activity, channelToken, gaeHandler);
+      synchronized (sendQueue) {
+        appRTCSignalingParameters = params;
+      }
+      requestQueueDrainInBackground();
+      events.onConnectedToRoom(appRTCSignalingParameters);
+    }
+
+    // Fetches |url| and fishes the signaling parameters out of the JSON.
+    private AppRTCSignalingParameters getParametersForRoomUrl(String url)
+        throws IOException, JSONException {
+      url = url + "&t=json";
+      String response = drainStream((new URL(url)).openConnection().getInputStream());
+      Log.d(TAG, "Room response: " + response);
+      JSONObject roomJson = new JSONObject(response);
+
+      if (roomJson.has("error")) {
+        JSONArray errors = roomJson.getJSONArray("error_messages");
+        throw new IOException(errors.toString());
+      }
+
+      gaeBaseHref = url.substring(0, url.indexOf('?'));
+      channelToken = roomJson.getString("token");
+      postMessageUrl = "/message?r=" +
+          roomJson.getString("room_key") + "&u=" +
+          roomJson.getString("me");
+      boolean initiator = roomJson.getInt("initiator") == 1;
+      LinkedList<PeerConnection.IceServer> iceServers =
+          iceServersFromPCConfigJSON(roomJson.getString("pc_config"));
+
+      boolean isTurnPresent = false;
+      for (PeerConnection.IceServer server : iceServers) {
+        Log.d(TAG, "IceServer: " + server);
+        if (server.uri.startsWith("turn:")) {
+          isTurnPresent = true;
+          break;
+        }
+      }
+      if (!isTurnPresent) {
+        PeerConnection.IceServer server =
+            requestTurnServer(roomJson.getString("turn_url"));
+        Log.d(TAG, "TurnServer: " + server);
+        iceServers.add(server);
+      }
+
+      MediaConstraints pcConstraints = constraintsFromJSON(
+          roomJson.getString("pc_constraints"));
+      addDTLSConstraintIfMissing(pcConstraints);
+      Log.d(TAG, "pcConstraints: " + pcConstraints);
+      MediaConstraints videoConstraints = constraintsFromJSON(
+          getAVConstraints("video",
+              roomJson.getString("media_constraints")));
+      Log.d(TAG, "videoConstraints: " + videoConstraints);
+      MediaConstraints audioConstraints = constraintsFromJSON(
+          getAVConstraints("audio",
+              roomJson.getString("media_constraints")));
+      Log.d(TAG, "audioConstraints: " + audioConstraints);
+
+      return new AppRTCSignalingParameters(
+          iceServers, initiator,
+          pcConstraints, videoConstraints, audioConstraints);
+    }
+
+    // Mimic Chrome and set DtlsSrtpKeyAgreement to true if not set to false by
+    // the web-app.
+    private void addDTLSConstraintIfMissing(
+        MediaConstraints pcConstraints) {
+      for (MediaConstraints.KeyValuePair pair : pcConstraints.mandatory) {
+        if (pair.getKey().equals("DtlsSrtpKeyAgreement")) {
+          return;
+        }
+      }
+      for (MediaConstraints.KeyValuePair pair : pcConstraints.optional) {
+        if (pair.getKey().equals("DtlsSrtpKeyAgreement")) {
+          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"));
+    }
+
+    // Return the constraints specified for |type| of "audio" or "video" in
+    // |mediaConstraintsString|.
+    private String getAVConstraints(
+        String type, String mediaConstraintsString) {
+      try {
+        JSONObject json = new JSONObject(mediaConstraintsString);
+        // Tricksy handling of values that are allowed to be (boolean or
+        // MediaTrackConstraints) by the getUserMedia() spec.  There are three
+        // cases below.
+        if (!json.has(type) || !json.optBoolean(type, true)) {
+          // Case 1: "audio"/"video" is not present, or is an explicit "false"
+          // boolean.
+          return null;
+        }
+        if (json.optBoolean(type, false)) {
+          // Case 2: "audio"/"video" is an explicit "true" boolean.
+          return "{\"mandatory\": {}, \"optional\": []}";
+        }
+        // Case 3: "audio"/"video" is an object.
+        return json.getJSONObject(type).toString();
+      } catch (JSONException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    private MediaConstraints constraintsFromJSON(String jsonString) {
+      if (jsonString == null) {
+        return null;
+      }
+      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 = 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);
+      }
+    }
+
+    // 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<PeerConnection.IceServer> iceServersFromPCConfigJSON(
+      String pcConfig) {
+    try {
+      JSONObject json = new JSONObject(pcConfig);
+      JSONArray servers = json.getJSONArray("iceServers");
+      LinkedList<PeerConnection.IceServer> ret =
+          new LinkedList<PeerConnection.IceServer>();
+      for (int i = 0; i < servers.length(); ++i) {
+        JSONObject server = servers.getJSONObject(i);
+        String url = server.getString("urls");
+        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<Void, Void, Void>() {
+      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) {
+          Log.d(TAG, "SEND: " + msg);
+          URLConnection connection =
+              new URL(gaeBaseHref + 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() : "";
+  }
+
+  // Implementation detail: handler for receiving GAE messages and dispatching
+  // them appropriately.
+  private class GAEHandler implements GAEChannelClient.GAEMessageHandler {
+    @JavascriptInterface public void onOpen() {
+      events.onChannelOpen();
+    }
+
+    @JavascriptInterface public void onMessage(String msg) {
+      Log.d(TAG, "RECEIVE: " + msg);
+      try {
+        JSONObject json = new JSONObject(msg);
+        String type = (String) json.get("type");
+        if (type.equals("candidate")) {
+          IceCandidate candidate = new IceCandidate(
+              (String) json.get("id"),
+              json.getInt("label"),
+              (String) json.get("candidate"));
+          events.onRemoteIceCandidate(candidate);
+        } else if (type.equals("answer") || type.equals("offer")) {
+          SessionDescription sdp = new SessionDescription(
+              SessionDescription.Type.fromCanonicalForm(type),
+              (String)json.get("sdp"));
+          events.onRemoteDescription(sdp);
+        } else if (type.equals("bye")) {
+          events.onChannelClose();
+        } else {
+          throw new RuntimeException("Unexpected message: " + msg);
+        }
+      } catch (JSONException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    @JavascriptInterface public void onClose() {
+      events.onChannelClose();
+    }
+
+    @JavascriptInterface public void onError(int code, String description) {
+      events.onChannelError(code, description);
+    }
+  }
+
+}
diff --git a/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java b/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java
new file mode 100644
index 0000000..0376000
--- /dev/null
+++ b/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java
@@ -0,0 +1,445 @@
+/*
+ * libjingle
+ * Copyright 2014, 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.util.Log;
+
+import org.appspot.apprtc.AppRTCClient.AppRTCSignalingParameters;
+import org.webrtc.DataChannel;
+import org.webrtc.IceCandidate;
+import org.webrtc.MediaConstraints;
+import org.webrtc.MediaStream;
+import org.webrtc.MediaStreamTrack;
+import org.webrtc.PeerConnection;
+import org.webrtc.PeerConnection.IceConnectionState;
+import org.webrtc.PeerConnectionFactory;
+import org.webrtc.SdpObserver;
+import org.webrtc.SessionDescription;
+import org.webrtc.StatsObserver;
+import org.webrtc.VideoCapturer;
+import org.webrtc.VideoRenderer;
+import org.webrtc.VideoSource;
+import org.webrtc.VideoTrack;
+
+import java.util.LinkedList;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class PeerConnectionClient {
+  private static final String TAG = "RTCClient";
+  private final Activity activity;
+  private PeerConnectionFactory factory;
+  private PeerConnection pc;
+  private VideoSource videoSource;
+  private boolean videoSourceStopped;
+  private final PCObserver pcObserver = new PCObserver();
+  private final SDPObserver sdpObserver = new SDPObserver();
+  private final VideoRenderer.Callbacks remoteRender;
+  private LinkedList<IceCandidate> queuedRemoteCandidates =
+      new LinkedList<IceCandidate>();
+  private MediaConstraints sdpMediaConstraints;
+  private PeerConnectionEvents events;
+  private boolean isInitiator;
+  private SessionDescription localSdp = null; // either offer or answer SDP
+
+  public PeerConnectionClient(
+      Activity activity,
+      VideoRenderer.Callbacks localRender,
+      VideoRenderer.Callbacks remoteRender,
+      AppRTCSignalingParameters appRtcParameters,
+      PeerConnectionEvents events) {
+    this.activity = activity;
+    this.remoteRender = remoteRender;
+    this.events = events;
+    isInitiator = appRtcParameters.initiator;
+
+    sdpMediaConstraints = new MediaConstraints();
+    sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair(
+        "OfferToReceiveAudio", "true"));
+    sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair(
+        "OfferToReceiveVideo", "true"));
+
+    factory = new PeerConnectionFactory();
+
+    MediaConstraints pcConstraints = appRtcParameters.pcConstraints;
+    pcConstraints.optional.add(
+        new MediaConstraints.KeyValuePair("RtpDataChannels", "true"));
+    pc = factory.createPeerConnection(appRtcParameters.iceServers,
+        pcConstraints, pcObserver);
+    isInitiator = false;
+
+    // Uncomment to get ALL WebRTC tracing and SENSITIVE libjingle logging.
+    // NOTE: this _must_ happen while |factory| is alive!
+    // Logging.enableTracing(
+    //     "logcat:",
+    //     EnumSet.of(Logging.TraceLevel.TRACE_ALL),
+    //     Logging.Severity.LS_SENSITIVE);
+
+    Log.d(TAG, "Creating local video source");
+    MediaStream lMS = factory.createLocalMediaStream("ARDAMS");
+    if (appRtcParameters.videoConstraints != null) {
+      VideoCapturer capturer = getVideoCapturer();
+      videoSource = factory.createVideoSource(
+          capturer, appRtcParameters.videoConstraints);
+      VideoTrack videoTrack =
+          factory.createVideoTrack("ARDAMSv0", videoSource);
+      videoTrack.addRenderer(new VideoRenderer(localRender));
+      lMS.addTrack(videoTrack);
+    }
+    if (appRtcParameters.audioConstraints != null) {
+      lMS.addTrack(factory.createAudioTrack(
+          "ARDAMSa0",
+          factory.createAudioSource(appRtcParameters.audioConstraints)));
+    }
+    pc.addStream(lMS, new MediaConstraints());
+  }
+
+  public boolean getStats(StatsObserver observer, MediaStreamTrack track) {
+    return pc.getStats(observer, track);
+  }
+
+  public void createOffer() {
+    isInitiator = true;
+    pc.createOffer(sdpObserver, sdpMediaConstraints);
+  }
+
+  public void createAnswer() {
+    isInitiator = false;
+    pc.createAnswer(sdpObserver, sdpMediaConstraints);
+  }
+
+
+  public void addRemoteIceCandidate(IceCandidate candidate) {
+    if (queuedRemoteCandidates != null) {
+      queuedRemoteCandidates.add(candidate);
+    } else {
+      pc.addIceCandidate(candidate);
+    }
+  }
+
+  public void setRemoteDescription(SessionDescription sdp) {
+    SessionDescription sdpISAC = new SessionDescription(
+        sdp.type, preferISAC(sdp.description));
+    Log.d(TAG, "Set remote SDP");
+    pc.setRemoteDescription(sdpObserver, sdpISAC);
+  }
+
+  public void stopVideoSource() {
+    if (videoSource != null) {
+      videoSource.stop();
+      videoSourceStopped = true;
+    }
+  }
+
+  public void startVideoSource() {
+    if (videoSource != null && videoSourceStopped) {
+      videoSource.restart();
+      videoSourceStopped = false;
+    }
+  }
+
+  public void close() {
+    if (pc != null) {
+      pc.dispose();
+      pc = null;
+    }
+    if (videoSource != null) {
+      videoSource.dispose();
+      videoSource = null;
+    }
+    if (factory != null) {
+      factory.dispose();
+      factory = null;
+    }
+  }
+
+  /**
+   * SDP/ICE ready callbacks.
+   */
+  public static interface PeerConnectionEvents {
+    /**
+     * Callback fired once offer is created and local SDP is set.
+     */
+    public void onLocalDescription(final SessionDescription sdp);
+
+    /**
+     * Callback fired once local Ice candidate is generated.
+     */
+    public void onIceCandidate(final IceCandidate candidate);
+
+    /**
+     * Callback fired once connection is established (IceConnectionState is
+     * CONNECTED).
+     */
+    public void onIceConnected();
+  }
+
+  // Cycle through likely device names for the camera and return the first
+  // capturer that works, or crash if none do.
+  private VideoCapturer getVideoCapturer() {
+    String[] cameraFacing = { "front", "back" };
+    int[] cameraIndex = { 0, 1 };
+    int[] cameraOrientation = { 0, 90, 180, 270 };
+    for (String facing : cameraFacing) {
+      for (int index : cameraIndex) {
+        for (int orientation : cameraOrientation) {
+          String name = "Camera " + index + ", Facing " + facing +
+              ", Orientation " + orientation;
+          VideoCapturer capturer = VideoCapturer.create(name);
+          if (capturer != null) {
+            Log.d(TAG, "Using camera: " + name);
+            return capturer;
+          }
+        }
+      }
+    }
+    throw new RuntimeException("Failed to open capturer");
+  }
+
+  // Poor-man's assert(): die with |msg| unless |condition| is true.
+  private static void abortUnless(boolean condition, String msg) {
+    if (!condition) {
+      throw new RuntimeException(msg);
+    }
+  }
+
+  // Mangle SDP to prefer ISAC/16000 over any other audio codec.
+  private static String preferISAC(String sdpDescription) {
+    String[] lines = sdpDescription.split("\r\n");
+    int mLineIndex = -1;
+    String isac16kRtpMap = null;
+    Pattern isac16kPattern =
+        Pattern.compile("^a=rtpmap:(\\d+) ISAC/16000[\r]?$");
+    for (int i = 0;
+         (i < lines.length) && (mLineIndex == -1 || isac16kRtpMap == null);
+         ++i) {
+      if (lines[i].startsWith("m=audio ")) {
+        mLineIndex = i;
+        continue;
+      }
+      Matcher isac16kMatcher = isac16kPattern.matcher(lines[i]);
+      if (isac16kMatcher.matches()) {
+        isac16kRtpMap = isac16kMatcher.group(1);
+        continue;
+      }
+    }
+    if (mLineIndex == -1) {
+      Log.d(TAG, "No m=audio line, so can't prefer iSAC");
+      return sdpDescription;
+    }
+    if (isac16kRtpMap == null) {
+      Log.d(TAG, "No ISAC/16000 line, so can't prefer iSAC");
+      return sdpDescription;
+    }
+    String[] origMLineParts = lines[mLineIndex].split(" ");
+    StringBuilder newMLine = new StringBuilder();
+    int origPartIndex = 0;
+    // Format is: m=<media> <port> <proto> <fmt> ...
+    newMLine.append(origMLineParts[origPartIndex++]).append(" ");
+    newMLine.append(origMLineParts[origPartIndex++]).append(" ");
+    newMLine.append(origMLineParts[origPartIndex++]).append(" ");
+    newMLine.append(isac16kRtpMap);
+    for (; origPartIndex < origMLineParts.length; ++origPartIndex) {
+      if (!origMLineParts[origPartIndex].equals(isac16kRtpMap)) {
+        newMLine.append(" ").append(origMLineParts[origPartIndex]);
+      }
+    }
+    lines[mLineIndex] = newMLine.toString();
+    StringBuilder newSdpDescription = new StringBuilder();
+    for (String line : lines) {
+      newSdpDescription.append(line).append("\r\n");
+    }
+    return newSdpDescription.toString();
+  }
+
+  private void drainRemoteCandidates() {
+    for (IceCandidate candidate : queuedRemoteCandidates) {
+      pc.addIceCandidate(candidate);
+    }
+    queuedRemoteCandidates = null;
+  }
+
+  // Implementation detail: observe ICE & stream changes and react accordingly.
+  private class PCObserver implements PeerConnection.Observer {
+    @Override
+    public void onIceCandidate(final IceCandidate candidate){
+      activity.runOnUiThread(new Runnable() {
+          public void run() {
+            events.onIceCandidate(candidate);
+          }
+        });
+    }
+
+    @Override
+    public void onError(){
+      activity.runOnUiThread(new Runnable() {
+          public void run() {
+            throw new RuntimeException("PeerConnection error!");
+          }
+        });
+    }
+
+    @Override
+    public void onSignalingChange(
+        PeerConnection.SignalingState newState) {
+      Log.d(TAG, "SignalingState: " + newState);
+    }
+
+    @Override
+    public void onIceConnectionChange(
+        PeerConnection.IceConnectionState newState) {
+      Log.d(TAG, "IceConnectionState: " + newState);
+      if (newState == IceConnectionState.CONNECTED) {
+        activity.runOnUiThread(new Runnable() {
+          public void run() {
+            events.onIceConnected();
+          }
+        });
+      }
+    }
+
+    @Override
+    public void onIceGatheringChange(
+        PeerConnection.IceGatheringState newState) {
+    }
+
+    @Override
+    public void onAddStream(final MediaStream stream){
+      activity.runOnUiThread(new Runnable() {
+          public void run() {
+            abortUnless(stream.audioTracks.size() <= 1 &&
+                stream.videoTracks.size() <= 1,
+                "Weird-looking stream: " + stream);
+            if (stream.videoTracks.size() == 1) {
+              stream.videoTracks.get(0).addRenderer(
+                  new VideoRenderer(remoteRender));
+            }
+          }
+        });
+    }
+
+    @Override
+    public void onRemoveStream(final MediaStream stream){
+      activity.runOnUiThread(new Runnable() {
+          public void run() {
+            stream.videoTracks.get(0).dispose();
+          }
+        });
+    }
+
+    @Override
+    public void onDataChannel(final DataChannel dc) {
+      activity.runOnUiThread(new Runnable() {
+          public void run() {
+            throw new RuntimeException(
+                "AppRTC doesn't use data channels, but got: " + dc.label() +
+                " anyway!");
+          }
+        });
+    }
+
+    @Override
+    public void onRenegotiationNeeded() {
+      // No need to do anything; AppRTC follows a pre-agreed-upon
+      // signaling/negotiation protocol.
+    }
+  }
+
+  // Implementation detail: handle offer creation/signaling and answer setting,
+  // as well as adding remote ICE candidates once the answer SDP is set.
+  private class SDPObserver implements SdpObserver {
+    @Override
+    public void onCreateSuccess(final SessionDescription origSdp) {
+      abortUnless(localSdp == null, "multiple SDP create?!?");
+      final SessionDescription sdp = new SessionDescription(
+          origSdp.type, preferISAC(origSdp.description));
+      localSdp = sdp;
+      activity.runOnUiThread(new Runnable() {
+          public void run() {
+            Log.d(TAG, "Set local SDP from " + sdp.type);
+            pc.setLocalDescription(sdpObserver, sdp);
+          }
+        });
+    }
+
+    @Override
+    public void onSetSuccess() {
+      activity.runOnUiThread(new Runnable() {
+          public void run() {
+            if (isInitiator) {
+              // For offering peer connection we first create offer and set
+              // local SDP, then after receiving answer set remote SDP.
+              if (pc.getRemoteDescription() == null) {
+                // We've just set our local SDP so time to send it.
+                Log.d(TAG, "Local SDP set succesfully");
+                events.onLocalDescription(localSdp);
+              } else {
+                // We've just set remote description,
+                // so drain remote ICE candidates.
+                Log.d(TAG, "Remote SDP set succesfully");
+                drainRemoteCandidates();
+              }
+            } else {
+              // For answering peer connection we set remote SDP and then
+              // create answer and set local SDP.
+              if (pc.getLocalDescription() != null) {
+                // We've just set our local SDP so time to send it and drain
+                // remote ICE candidates.
+                Log.d(TAG, "Local SDP set succesfully");
+                events.onLocalDescription(localSdp);
+                drainRemoteCandidates();
+              } else {
+                // We've just set remote SDP - do nothing for now -
+                // answer will be created soon.
+                Log.d(TAG, "Remote SDP set succesfully");
+              }
+            }
+          }
+        });
+    }
+
+    @Override
+    public void onCreateFailure(final String error) {
+      activity.runOnUiThread(new Runnable() {
+          public void run() {
+            throw new RuntimeException("createSDP error: " + error);
+          }
+        });
+    }
+
+    @Override
+    public void onSetFailure(final String error) {
+      activity.runOnUiThread(new Runnable() {
+          public void run() {
+            throw new RuntimeException("setSDP error: " + error);
+          }
+        });
+    }
+  }
+}
diff --git a/talk/libjingle_examples.gyp b/talk/libjingle_examples.gyp
index b13ac1f..9493b6d 100755
--- a/talk/libjingle_examples.gyp
+++ b/talk/libjingle_examples.gyp
@@ -325,8 +325,10 @@
                 'examples/android/src/org/appspot/apprtc/AppRTCClient.java',
                 'examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java',
                 'examples/android/src/org/appspot/apprtc/AppRTCGLView.java',
-                'examples/android/src/org/appspot/apprtc/UnhandledExceptionHandler.java',
                 'examples/android/src/org/appspot/apprtc/GAEChannelClient.java',
+                'examples/android/src/org/appspot/apprtc/GAERTCClient.java',
+                'examples/android/src/org/appspot/apprtc/PeerConnectionClient.java',
+                'examples/android/src/org/appspot/apprtc/UnhandledExceptionHandler.java',
               ],
               'outputs': [
                 '<(PRODUCT_DIR)/AppRTCDemo-debug.apk',