/*
 * 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.webrtc;

import org.webrtc.PeerConnection.IceConnectionState;
import org.webrtc.PeerConnection.IceGatheringState;
import org.webrtc.PeerConnection.SignalingState;

import java.io.File;
import java.lang.ref.WeakReference;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.IdentityHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import static junit.framework.Assert.*;

/** End-to-end tests for PeerConnection.java. */
public class PeerConnectionTest {
  // Set to true to render video.
  private static final boolean RENDER_TO_GUI = false;
  private static final int TIMEOUT_SECONDS = 20;
  private TreeSet<String> threadsBeforeTest = null;

  private static class ObserverExpectations implements PeerConnection.Observer,
                                                       VideoRenderer.Callbacks,
                                                       DataChannel.Observer,
                                                       StatsObserver {
    private final String name;
    private int expectedIceCandidates = 0;
    private int expectedErrors = 0;
    private int expectedRenegotiations = 0;
    private int previouslySeenWidth = 0;
    private int previouslySeenHeight = 0;
    private int expectedFramesDelivered = 0;
    private LinkedList<SignalingState> expectedSignalingChanges =
        new LinkedList<SignalingState>();
    private LinkedList<IceConnectionState> expectedIceConnectionChanges =
        new LinkedList<IceConnectionState>();
    private LinkedList<IceGatheringState> expectedIceGatheringChanges =
        new LinkedList<IceGatheringState>();
    private LinkedList<String> expectedAddStreamLabels =
        new LinkedList<String>();
    private LinkedList<String> expectedRemoveStreamLabels =
        new LinkedList<String>();
    private final LinkedList<IceCandidate> gotIceCandidates =
        new LinkedList<IceCandidate>();
    private Map<MediaStream, WeakReference<VideoRenderer>> renderers =
        new IdentityHashMap<MediaStream, WeakReference<VideoRenderer>>();
    private DataChannel dataChannel;
    private LinkedList<DataChannel.Buffer> expectedBuffers =
        new LinkedList<DataChannel.Buffer>();
    private LinkedList<DataChannel.State> expectedStateChanges =
        new LinkedList<DataChannel.State>();
    private LinkedList<String> expectedRemoteDataChannelLabels =
        new LinkedList<String>();
    private int expectedStatsCallbacks = 0;
    private LinkedList<StatsReport[]> gotStatsReports =
        new LinkedList<StatsReport[]>();

    public ObserverExpectations(String name) {
      this.name = name;
    }

    public synchronized void setDataChannel(DataChannel dataChannel) {
      assertNull(this.dataChannel);
      this.dataChannel = dataChannel;
      this.dataChannel.registerObserver(this);
      assertNotNull(this.dataChannel);
    }

    public synchronized void expectIceCandidates(int count) {
      expectedIceCandidates += count;
    }

    @Override
    public synchronized void onIceCandidate(IceCandidate candidate) {
      --expectedIceCandidates;

      // We don't assert expectedIceCandidates >= 0 because it's hard to know
      // how many to expect, in general.  We only use expectIceCandidates to
      // assert a minimal count.
      synchronized (gotIceCandidates) {
        gotIceCandidates.add(candidate);
        gotIceCandidates.notifyAll();
      }
    }

    private synchronized void setSize(int width, int height) {
      assertFalse(RENDER_TO_GUI);
      // Because different camera devices (fake & physical) produce different
      // resolutions, we only sanity-check the set sizes,
      assertTrue(width > 0);
      assertTrue(height > 0);
      if (previouslySeenWidth > 0) {
        assertEquals(previouslySeenWidth, width);
        assertEquals(previouslySeenHeight, height);
      } else {
        previouslySeenWidth = width;
        previouslySeenHeight = height;
      }
    }

    public synchronized void expectFramesDelivered(int count) {
      assertFalse(RENDER_TO_GUI);
      expectedFramesDelivered += count;
    }

    @Override
    public synchronized void renderFrame(VideoRenderer.I420Frame frame) {
      setSize(frame.rotatedWidth(), frame.rotatedHeight());
      --expectedFramesDelivered;
      VideoRenderer.renderFrameDone(frame);
    }

    public synchronized void expectSignalingChange(SignalingState newState) {
      expectedSignalingChanges.add(newState);
    }

    @Override
    public synchronized void onSignalingChange(SignalingState newState) {
      assertEquals(expectedSignalingChanges.removeFirst(), newState);
    }

    public synchronized void expectIceConnectionChange(
        IceConnectionState newState) {
      expectedIceConnectionChanges.add(newState);
    }

    @Override
    public synchronized void onIceConnectionChange(
        IceConnectionState newState) {
      // TODO(bemasc): remove once delivery of ICECompleted is reliable
      // (https://code.google.com/p/webrtc/issues/detail?id=3021).
      if (newState.equals(IceConnectionState.COMPLETED)) {
        return;
      }

      if (expectedIceConnectionChanges.isEmpty()) {
        System.out.println(name + "Got an unexpected ice connection change " + newState);
        return;
      }

      assertEquals(expectedIceConnectionChanges.removeFirst(), newState);
    }

    @Override
    public synchronized void onIceConnectionReceivingChange(boolean receiving) {
      System.out.println(name + "Got an ice connection receiving change " + receiving);
    }

    public synchronized void expectIceGatheringChange(
        IceGatheringState newState) {
      expectedIceGatheringChanges.add(newState);
    }

    @Override
    public synchronized void onIceGatheringChange(IceGatheringState newState) {
      // It's fine to get a variable number of GATHERING messages before
      // COMPLETE fires (depending on how long the test runs) so we don't assert
      // any particular count.
      if (newState == IceGatheringState.GATHERING) {
        return;
      }
      assertEquals(expectedIceGatheringChanges.removeFirst(), newState);
    }

    public synchronized void expectAddStream(String label) {
      expectedAddStreamLabels.add(label);
    }

    @Override
    public synchronized void onAddStream(MediaStream stream) {
      assertEquals(expectedAddStreamLabels.removeFirst(), stream.label());
      assertEquals(1, stream.videoTracks.size());
      assertEquals(1, stream.audioTracks.size());
      assertTrue(stream.videoTracks.get(0).id().endsWith("VideoTrack"));
      assertTrue(stream.audioTracks.get(0).id().endsWith("AudioTrack"));
      assertEquals("video", stream.videoTracks.get(0).kind());
      assertEquals("audio", stream.audioTracks.get(0).kind());
      VideoRenderer renderer = createVideoRenderer(this);
      stream.videoTracks.get(0).addRenderer(renderer);
      assertNull(renderers.put(
          stream, new WeakReference<VideoRenderer>(renderer)));
    }

    public synchronized void expectRemoveStream(String label) {
      expectedRemoveStreamLabels.add(label);
    }

    @Override
    public synchronized void onRemoveStream(MediaStream stream) {
      assertEquals(expectedRemoveStreamLabels.removeFirst(), stream.label());
      WeakReference<VideoRenderer> renderer = renderers.remove(stream);
      assertNotNull(renderer);
      assertNotNull(renderer.get());
      assertEquals(1, stream.videoTracks.size());
      stream.videoTracks.get(0).removeRenderer(renderer.get());
    }

    public synchronized void expectDataChannel(String label) {
      expectedRemoteDataChannelLabels.add(label);
    }

    @Override
    public synchronized void onDataChannel(DataChannel remoteDataChannel) {
      assertEquals(expectedRemoteDataChannelLabels.removeFirst(),
                   remoteDataChannel.label());
      setDataChannel(remoteDataChannel);
      assertEquals(DataChannel.State.CONNECTING, dataChannel.state());
    }

    public synchronized void expectRenegotiationNeeded() {
      ++expectedRenegotiations;
    }

    @Override
    public synchronized void onRenegotiationNeeded() {
      assertTrue(--expectedRenegotiations >= 0);
    }

    public synchronized void expectMessage(ByteBuffer expectedBuffer,
                                           boolean expectedBinary) {
      expectedBuffers.add(
          new DataChannel.Buffer(expectedBuffer, expectedBinary));
    }

    @Override
    public synchronized void onMessage(DataChannel.Buffer buffer) {
      DataChannel.Buffer expected = expectedBuffers.removeFirst();
      assertEquals(expected.binary, buffer.binary);
      assertTrue(expected.data.equals(buffer.data));
    }

    @Override
    public synchronized void onBufferedAmountChange(long previousAmount) {
      assertFalse(previousAmount == dataChannel.bufferedAmount());
    }

    @Override
    public synchronized void onStateChange() {
      assertEquals(expectedStateChanges.removeFirst(), dataChannel.state());
    }

    public synchronized void expectStateChange(DataChannel.State state) {
      expectedStateChanges.add(state);
    }

    @Override
    public synchronized void onComplete(StatsReport[] reports) {
      if (--expectedStatsCallbacks < 0) {
        throw new RuntimeException("Unexpected stats report: " + reports);
      }
      gotStatsReports.add(reports);
    }

    public synchronized void expectStatsCallback() {
      ++expectedStatsCallbacks;
    }

    public synchronized LinkedList<StatsReport[]> takeStatsReports() {
      LinkedList<StatsReport[]> got = gotStatsReports;
      gotStatsReports = new LinkedList<StatsReport[]>();
      return got;
    }

    // Return a set of expectations that haven't been satisfied yet, possibly
    // empty if no such expectations exist.
    public synchronized TreeSet<String> unsatisfiedExpectations() {
      TreeSet<String> stillWaitingForExpectations = new TreeSet<String>();
      if (expectedIceCandidates > 0) {  // See comment in onIceCandidate.
        stillWaitingForExpectations.add("expectedIceCandidates");
      }
      if (expectedErrors != 0) {
        stillWaitingForExpectations.add("expectedErrors: " + expectedErrors);
      }
      if (expectedSignalingChanges.size() != 0) {
        stillWaitingForExpectations.add(
            "expectedSignalingChanges: " + expectedSignalingChanges.size());
      }
      if (expectedIceConnectionChanges.size() != 0) {
        stillWaitingForExpectations.add("expectedIceConnectionChanges: " +
                                        expectedIceConnectionChanges.size());
      }
      if (expectedIceGatheringChanges.size() != 0) {
        stillWaitingForExpectations.add("expectedIceGatheringChanges: " +
                                        expectedIceGatheringChanges.size());
      }
      if (expectedAddStreamLabels.size() != 0) {
        stillWaitingForExpectations.add(
            "expectedAddStreamLabels: " + expectedAddStreamLabels.size());
      }
      if (expectedRemoveStreamLabels.size() != 0) {
        stillWaitingForExpectations.add(
            "expectedRemoveStreamLabels: " + expectedRemoveStreamLabels.size());
      }
      if (expectedFramesDelivered > 0) {
        stillWaitingForExpectations.add(
            "expectedFramesDelivered: " + expectedFramesDelivered);
      }
      if (!expectedBuffers.isEmpty()) {
        stillWaitingForExpectations.add(
            "expectedBuffers: " + expectedBuffers.size());
      }
      if (!expectedStateChanges.isEmpty()) {
        stillWaitingForExpectations.add(
            "expectedStateChanges: " + expectedStateChanges.size());
      }
      if (!expectedRemoteDataChannelLabels.isEmpty()) {
        stillWaitingForExpectations.add("expectedRemoteDataChannelLabels: " +
                                        expectedRemoteDataChannelLabels.size());
      }
      if (expectedStatsCallbacks != 0) {
        stillWaitingForExpectations.add(
            "expectedStatsCallbacks: " + expectedStatsCallbacks);
      }
      return stillWaitingForExpectations;
    }

    public boolean waitForAllExpectationsToBeSatisfied(int timeoutSeconds) {
      // TODO(fischman): problems with this approach:
      // - come up with something better than a poll loop
      // - avoid serializing expectations explicitly; the test is not as robust
      //   as it could be because it must place expectations between wait
      //   statements very precisely (e.g. frame must not arrive before its
      //   expectation, and expectation must not be registered so early as to
      //   stall a wait).  Use callbacks to fire off dependent steps instead of
      //   explicitly waiting, so there can be just a single wait at the end of
      //   the test.
      long endTime = System.currentTimeMillis() + 1000 * timeoutSeconds;
      TreeSet<String> prev = null;
      TreeSet<String> stillWaitingForExpectations = unsatisfiedExpectations();
      while (!stillWaitingForExpectations.isEmpty()) {
        if (!stillWaitingForExpectations.equals(prev)) {
          System.out.println(
              name + " still waiting at\n    " +
              (new Throwable()).getStackTrace()[1] +
              "\n    for: " +
              Arrays.toString(stillWaitingForExpectations.toArray()));
        }
        if (endTime < System.currentTimeMillis()) {
          System.out.println(name + " timed out waiting for: "
              + Arrays.toString(stillWaitingForExpectations.toArray()));
          return false;
        }
        try {
          Thread.sleep(10);
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
        prev = stillWaitingForExpectations;
        stillWaitingForExpectations = unsatisfiedExpectations();
      }
      if (prev == null) {
        System.out.println(name + " didn't need to wait at\n    " +
                           (new Throwable()).getStackTrace()[1]);
      }
      return true;
    }

    // This methods return a list of all currently gathered ice candidates or waits until
    // 1 candidate have been gathered.
    public List<IceCandidate> getAtLeastOneIceCandidate() throws InterruptedException {
      synchronized (gotIceCandidates) {
        while (gotIceCandidates.isEmpty()) {
          gotIceCandidates.wait();
        }
        return new LinkedList<IceCandidate>(gotIceCandidates);
      }
    }
  }

  private static class SdpObserverLatch implements SdpObserver {
    private boolean success = false;
    private SessionDescription sdp = null;
    private String error = null;
    private CountDownLatch latch = new CountDownLatch(1);

    public SdpObserverLatch() {}

    @Override
    public void onCreateSuccess(SessionDescription sdp) {
      this.sdp = sdp;
      onSetSuccess();
    }

    @Override
    public void onSetSuccess() {
      success = true;
      latch.countDown();
    }

    @Override
    public void onCreateFailure(String error) {
      onSetFailure(error);
    }

    @Override
    public void onSetFailure(String error) {
      this.error = error;
      latch.countDown();
    }

    public boolean await() {
      try {
        assertTrue(latch.await(1000, TimeUnit.MILLISECONDS));
        return getSuccess();
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }

    public boolean getSuccess() {
      return success;
    }

    public SessionDescription getSdp() {
      return sdp;
    }

    public String getError() {
      return error;
    }
  }

  static int videoWindowsMapped = -1;

  private static VideoRenderer createVideoRenderer(
      VideoRenderer.Callbacks videoCallbacks) {
    if (!RENDER_TO_GUI) {
      return new VideoRenderer(videoCallbacks);
    }
    ++videoWindowsMapped;
    assertTrue(videoWindowsMapped < 4);
    int x = videoWindowsMapped % 2 != 0 ? 700 : 0;
    int y = videoWindowsMapped >= 2 ? 0 : 500;
    return VideoRenderer.createGui(x, y);
  }

  // Return a weak reference to test that ownership is correctly held by
  // PeerConnection, not by test code.
  private static WeakReference<MediaStream> addTracksToPC(
      PeerConnectionFactory factory, PeerConnection pc,
      VideoSource videoSource,
      String streamLabel, String videoTrackId, String audioTrackId,
      VideoRenderer.Callbacks videoCallbacks) {
    MediaStream lMS = factory.createLocalMediaStream(streamLabel);
    VideoTrack videoTrack =
        factory.createVideoTrack(videoTrackId, videoSource);
    assertNotNull(videoTrack);
    VideoRenderer videoRenderer = createVideoRenderer(videoCallbacks);
    assertNotNull(videoRenderer);
    videoTrack.addRenderer(videoRenderer);
    lMS.addTrack(videoTrack);
    // Just for fun, let's remove and re-add the track.
    lMS.removeTrack(videoTrack);
    lMS.addTrack(videoTrack);
    lMS.addTrack(factory.createAudioTrack(
        audioTrackId, factory.createAudioSource(new MediaConstraints())));
    pc.addStream(lMS);
    return new WeakReference<MediaStream>(lMS);
  }

  // Used for making sure thread handles are not leaked.
  // Call initializeThreadCheck before a test and finalizeThreadCheck after
  // a test.
  void initializeThreadCheck() {
    System.gc();  // Encourage any GC-related threads to start up.
    threadsBeforeTest = allThreads();
  }

  void finalizeThreadCheck() throws Exception {
    // TreeSet<String> threadsAfterTest = allThreads();

    // TODO(tommi): Figure out a more reliable way to do this test.  As is
    // we're seeing three possible 'normal' situations:
    // 1.  before and after sets are equal.
    // 2.  before contains 3 threads that do not exist in after.
    // 3.  after contains 3 threads that do not exist in before.
    //
    // Maybe it would be better to do the thread enumeration from C++ and get
    // the thread names as well, in order to determine what these 3 threads are.

    // assertEquals(threadsBeforeTest, threadsAfterTest);
    // Thread.sleep(100);
  }

  void doTest() throws Exception {
    PeerConnectionFactory factory = new PeerConnectionFactory();
    // Uncomment to get ALL WebRTC tracing and SENSITIVE libjingle logging.
    // NOTE: this _must_ happen while |factory| is alive!
    // Logging.enableTracing(
    //     "/tmp/PeerConnectionTest-log.txt",
    //     EnumSet.of(Logging.TraceLevel.TRACE_ALL),
    //     Logging.Severity.LS_SENSITIVE);

    // Allow loopback interfaces too since our Android devices often don't
    // have those.
    PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
    options.networkIgnoreMask = 0;
    factory.setOptions(options);

    MediaConstraints pcConstraints = new MediaConstraints();
    pcConstraints.mandatory.add(
        new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true"));

    LinkedList<PeerConnection.IceServer> iceServers =
        new LinkedList<PeerConnection.IceServer>();
    iceServers.add(new PeerConnection.IceServer(
        "stun:stun.l.google.com:19302"));
    iceServers.add(new PeerConnection.IceServer(
        "turn:fake.example.com", "fakeUsername", "fakePassword"));
    ObserverExpectations offeringExpectations =
        new ObserverExpectations("PCTest:offerer");
    PeerConnection offeringPC = factory.createPeerConnection(
        iceServers, pcConstraints, offeringExpectations);
    assertNotNull(offeringPC);

    ObserverExpectations answeringExpectations =
        new ObserverExpectations("PCTest:answerer");
    PeerConnection answeringPC = factory.createPeerConnection(
        iceServers, pcConstraints, answeringExpectations);
    assertNotNull(answeringPC);

    // We want to use the same camera for offerer & answerer, so create it here
    // instead of in addTracksToPC.
    VideoSource videoSource = factory.createVideoSource(
        VideoCapturer.create(""), new MediaConstraints());

    offeringExpectations.expectRenegotiationNeeded();
    WeakReference<MediaStream> oLMS = addTracksToPC(
        factory, offeringPC, videoSource, "offeredMediaStream",
        "offeredVideoTrack", "offeredAudioTrack", offeringExpectations);

    offeringExpectations.expectRenegotiationNeeded();
    DataChannel offeringDC = offeringPC.createDataChannel(
        "offeringDC", new DataChannel.Init());
    assertEquals("offeringDC", offeringDC.label());

    offeringExpectations.setDataChannel(offeringDC);
    SdpObserverLatch sdpLatch = new SdpObserverLatch();
    offeringPC.createOffer(sdpLatch, new MediaConstraints());
    assertTrue(sdpLatch.await());
    SessionDescription offerSdp = sdpLatch.getSdp();
    assertEquals(offerSdp.type, SessionDescription.Type.OFFER);
    assertFalse(offerSdp.description.isEmpty());

    sdpLatch = new SdpObserverLatch();
    answeringExpectations.expectSignalingChange(
        SignalingState.HAVE_REMOTE_OFFER);
    answeringExpectations.expectAddStream("offeredMediaStream");
    // SCTP DataChannels are announced via OPEN messages over the established
    // connection (not via SDP), so answeringExpectations can only register
    // expecting the channel during ICE, below.
    answeringPC.setRemoteDescription(sdpLatch, offerSdp);
    assertEquals(
        PeerConnection.SignalingState.STABLE, offeringPC.signalingState());
    assertTrue(sdpLatch.await());
    assertNull(sdpLatch.getSdp());

    answeringExpectations.expectRenegotiationNeeded();
    WeakReference<MediaStream> aLMS = addTracksToPC(
        factory, answeringPC, videoSource, "answeredMediaStream",
        "answeredVideoTrack", "answeredAudioTrack", answeringExpectations);

    sdpLatch = new SdpObserverLatch();
    answeringPC.createAnswer(sdpLatch, new MediaConstraints());
    assertTrue(sdpLatch.await());
    SessionDescription answerSdp = sdpLatch.getSdp();
    assertEquals(answerSdp.type, SessionDescription.Type.ANSWER);
    assertFalse(answerSdp.description.isEmpty());

    offeringExpectations.expectIceCandidates(2);
    answeringExpectations.expectIceCandidates(2);

    offeringExpectations.expectIceGatheringChange(IceGatheringState.COMPLETE);
    answeringExpectations.expectIceGatheringChange(IceGatheringState.COMPLETE);

    sdpLatch = new SdpObserverLatch();
    answeringExpectations.expectSignalingChange(SignalingState.STABLE);
    answeringPC.setLocalDescription(sdpLatch, answerSdp);
    assertTrue(sdpLatch.await());
    assertNull(sdpLatch.getSdp());

    sdpLatch = new SdpObserverLatch();
    offeringExpectations.expectSignalingChange(SignalingState.HAVE_LOCAL_OFFER);
    offeringPC.setLocalDescription(sdpLatch, offerSdp);
    assertTrue(sdpLatch.await());
    assertNull(sdpLatch.getSdp());
    sdpLatch = new SdpObserverLatch();
    offeringExpectations.expectSignalingChange(SignalingState.STABLE);
    offeringExpectations.expectAddStream("answeredMediaStream");

    offeringExpectations.expectIceConnectionChange(
        IceConnectionState.CHECKING);
    offeringExpectations.expectIceConnectionChange(
        IceConnectionState.CONNECTED);
    // TODO(bemasc): uncomment once delivery of ICECompleted is reliable
    // (https://code.google.com/p/webrtc/issues/detail?id=3021).
    //
    // offeringExpectations.expectIceConnectionChange(
    //     IceConnectionState.COMPLETED);
    answeringExpectations.expectIceConnectionChange(
        IceConnectionState.CHECKING);
    answeringExpectations.expectIceConnectionChange(
        IceConnectionState.CONNECTED);

    offeringPC.setRemoteDescription(sdpLatch, answerSdp);
    assertTrue(sdpLatch.await());
    assertNull(sdpLatch.getSdp());

    assertEquals(offeringPC.getLocalDescription().type, offerSdp.type);
    assertEquals(offeringPC.getRemoteDescription().type, answerSdp.type);
    assertEquals(answeringPC.getLocalDescription().type, answerSdp.type);
    assertEquals(answeringPC.getRemoteDescription().type, offerSdp.type);

    assertEquals(offeringPC.getSenders().size(), 2);
    assertEquals(offeringPC.getReceivers().size(), 2);
    assertEquals(answeringPC.getSenders().size(), 2);
    assertEquals(answeringPC.getReceivers().size(), 2);

    if (!RENDER_TO_GUI) {
      // Wait for at least some frames to be delivered at each end (number
      // chosen arbitrarily).
      offeringExpectations.expectFramesDelivered(10);
      answeringExpectations.expectFramesDelivered(10);
    }

    offeringExpectations.expectStateChange(DataChannel.State.OPEN);
    // See commentary about SCTP DataChannels above for why this is here.
    answeringExpectations.expectDataChannel("offeringDC");
    answeringExpectations.expectStateChange(DataChannel.State.OPEN);

    // Wait for at least one ice candidate from the offering PC and forward them to the answering
    // PC.
    for (IceCandidate candidate : offeringExpectations.getAtLeastOneIceCandidate()) {
      answeringPC.addIceCandidate(candidate);
    }

    // Wait for at least one ice candidate from the answering PC and forward them to the offering
    // PC.
    for (IceCandidate candidate : answeringExpectations.getAtLeastOneIceCandidate()) {
      offeringPC.addIceCandidate(candidate);
    }

    assertTrue(offeringExpectations.waitForAllExpectationsToBeSatisfied(TIMEOUT_SECONDS));
    assertTrue(answeringExpectations.waitForAllExpectationsToBeSatisfied(TIMEOUT_SECONDS));

    assertEquals(
        PeerConnection.SignalingState.STABLE, offeringPC.signalingState());
    assertEquals(
        PeerConnection.SignalingState.STABLE, answeringPC.signalingState());

    // Test send & receive UTF-8 text.
    answeringExpectations.expectMessage(
        ByteBuffer.wrap("hello!".getBytes(Charset.forName("UTF-8"))), false);
    DataChannel.Buffer buffer = new DataChannel.Buffer(
        ByteBuffer.wrap("hello!".getBytes(Charset.forName("UTF-8"))), false);
    assertTrue(offeringExpectations.dataChannel.send(buffer));
    assertTrue(answeringExpectations.waitForAllExpectationsToBeSatisfied(TIMEOUT_SECONDS));

    // Construct this binary message two different ways to ensure no
    // shortcuts are taken.
    ByteBuffer expectedBinaryMessage = ByteBuffer.allocateDirect(5);
    for (byte i = 1; i < 6; ++i) {
      expectedBinaryMessage.put(i);
    }
    expectedBinaryMessage.flip();
    offeringExpectations.expectMessage(expectedBinaryMessage, true);
    assertTrue(answeringExpectations.dataChannel.send(
        new DataChannel.Buffer(
            ByteBuffer.wrap(new byte[] { 1, 2, 3, 4, 5 }), true)));
    assertTrue(offeringExpectations.waitForAllExpectationsToBeSatisfied(TIMEOUT_SECONDS));

    offeringExpectations.expectStateChange(DataChannel.State.CLOSING);
    answeringExpectations.expectStateChange(DataChannel.State.CLOSING);
    offeringExpectations.expectStateChange(DataChannel.State.CLOSED);
    answeringExpectations.expectStateChange(DataChannel.State.CLOSED);
    answeringExpectations.dataChannel.close();
    offeringExpectations.dataChannel.close();

    if (RENDER_TO_GUI) {
      try {
        Thread.sleep(3000);
      } catch (Throwable t) {
        throw new RuntimeException(t);
      }
    }

    // TODO(fischman) MOAR test ideas:
    // - Test that PC.removeStream() works; requires a second
    //   createOffer/createAnswer dance.
    // - audit each place that uses |constraints| for specifying non-trivial
    //   constraints (and ensure they're honored).
    // - test error cases
    // - ensure reasonable coverage of _jni.cc is achieved.  Coverage is
    //   extra-important because of all the free-text (class/method names, etc)
    //   in JNI-style programming; make sure no typos!
    // - Test that shutdown mid-interaction is crash-free.

    // Free the Java-land objects, collect them, and sleep a bit to make sure we
    // don't get late-arrival crashes after the Java-land objects have been
    // freed.
    shutdownPC(offeringPC, offeringExpectations);
    offeringPC = null;
    shutdownPC(answeringPC, answeringExpectations);
    answeringPC = null;
    videoSource.dispose();
    factory.dispose();
    System.gc();
  }

  private static void shutdownPC(
      PeerConnection pc, ObserverExpectations expectations) {
    expectations.dataChannel.unregisterObserver();
    expectations.dataChannel.dispose();
    expectations.expectStatsCallback();
    assertTrue(pc.getStats(expectations, null));
    assertTrue(expectations.waitForAllExpectationsToBeSatisfied(TIMEOUT_SECONDS));
    expectations.expectIceConnectionChange(IceConnectionState.CLOSED);
    expectations.expectSignalingChange(SignalingState.CLOSED);
    pc.close();
    assertTrue(expectations.waitForAllExpectationsToBeSatisfied(TIMEOUT_SECONDS));
    expectations.expectStatsCallback();
    assertTrue(pc.getStats(expectations, null));
    assertTrue(expectations.waitForAllExpectationsToBeSatisfied(TIMEOUT_SECONDS));

    System.out.println("FYI stats: ");
    int reportIndex = -1;
    for (StatsReport[] reports : expectations.takeStatsReports()) {
      System.out.println(" Report #" + (++reportIndex));
      for (int i = 0; i < reports.length; ++i) {
        System.out.println("  " + reports[i].toString());
      }
    }
    assertEquals(1, reportIndex);
    System.out.println("End stats.");

    pc.dispose();
  }

  // Returns a set of thread IDs belonging to this process, as Strings.
  private static TreeSet<String> allThreads() {
    TreeSet<String> threads = new TreeSet<String>();
    // This pokes at /proc instead of using the Java APIs because we're also
    // looking for libjingle/webrtc native threads, most of which won't have
    // attached to the JVM.
    for (String threadId : (new File("/proc/self/task")).list()) {
      threads.add(threadId);
    }
    return threads;
  }
}
