diff --git a/README.md b/README.md
index a05b3d5cdf12..b74570ebc238 100644
--- a/README.md
+++ b/README.md
@@ -23,11 +23,6 @@ You can also depend on the .jar through Maven:
Known Issues
------------
-The SPDY implementation is incomplete:
-
-* Settings frames are not honored. Flow control is not implemented.
-* It assumes a well-behaved peer. If the peer sends an invalid frame, OkHttp's SPDY client will not respond with the required `RST` frame.
-
OkHttp uses the platform's [ProxySelector][2]. Prior to Android 4.0, `ProxySelector` didn't honor the `proxyHost` and `proxyPort` system properties for HTTPS connections. Work around this by specifying the `https.proxyHost` and `https.proxyPort` system properties when using a proxy with HTTPS.
OkHttp's test suite creates an in-process HTTPS server. Prior to Android 2.3, SSL server sockets were broken, and so HTTPS tests will time out when run on such devices.
@@ -37,17 +32,10 @@ Building
--------
### On the Desktop
-Run OkHttp tests on the desktop with Maven.
+Run OkHttp tests on the desktop with Maven. Running SPDY tests on the desktop uses [Jetty-NPN](http://wiki.eclipse.org/Jetty/Feature/NPN) which requires OpenJDK 7+.
```
mvn clean test
```
-SPDY support uses a Deflater API that wasn't available in Java 6. For this reason SPDY tests will fail with this error: `Cannot SPDY; no SYNC_FLUSH available`. All other tests should run fine.
-
-### On the Desktop with NPN
-Using NPN on the desktop uses [Jetty-NPN](http://wiki.eclipse.org/Jetty/Feature/NPN) which requires OpenJDK 7+.
-```
-mvn clean test -Pspdy-tls
-```
### On a Device
Test on a USB-attached Android using [Vogar](https://code.google.com/p/vogar/). Unfortunately `dx` requires that you build with Java 6, otherwise the test class will be silently omitted from the `.dex` file.
diff --git a/pom.xml b/pom.xml
index 7b7ecba85f65..0afcaa61e7d3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,7 +38,7 @@
1.6
8.1.2.v20120308
- 20120905
+ 20130122
1.47
@@ -120,25 +120,15 @@
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 2.9
+
+ -Xbootclasspath/p:${settings.localRepository}/org/mortbay/jetty/npn/npn-boot/${npn.version}/npn-boot-${npn.version}.jar
+
+
-
-
-
- spdy-tls
-
-
-
- org.apache.maven.plugins
- maven-surefire-plugin
- 2.9
-
- -Xbootclasspath/p:${settings.localRepository}/org/mortbay/jetty/npn/npn-boot/${npn.version}/npn-boot-${npn.version}.jar
-
-
-
-
-
-
diff --git a/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java b/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java
index 0f10cf3bc32c..347be634fed8 100644
--- a/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java
+++ b/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java
@@ -21,7 +21,6 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
-import java.net.ProtocolException;
import java.net.Socket;
import java.util.HashMap;
import java.util.Iterator;
diff --git a/src/test/java/com/squareup/okhttp/internal/mockspdyserver/MockSpdyServer.java b/src/test/java/com/squareup/okhttp/internal/mockspdyserver/MockSpdyServer.java
new file mode 100644
index 000000000000..64ab5492650d
--- /dev/null
+++ b/src/test/java/com/squareup/okhttp/internal/mockspdyserver/MockSpdyServer.java
@@ -0,0 +1,267 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.squareup.okhttp.internal.mockspdyserver;
+
+import com.google.mockwebserver.MockResponse;
+import com.google.mockwebserver.QueueDispatcher;
+import com.google.mockwebserver.RecordedRequest;
+import com.squareup.okhttp.internal.spdy.IncomingStreamHandler;
+import com.squareup.okhttp.internal.spdy.SpdyConnection;
+import com.squareup.okhttp.internal.spdy.SpdyStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetAddress;
+import java.net.MalformedURLException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.SSLSocketFactory;
+import org.eclipse.jetty.npn.NextProtoNego;
+
+/**
+ * A scriptable spdy/3 + HTTP server.
+ */
+public final class MockSpdyServer {
+ private static final Logger logger = Logger.getLogger(MockSpdyServer.class.getName());
+ private SSLSocketFactory sslSocketFactory;
+ private QueueDispatcher dispatcher = new QueueDispatcher();
+ private ServerSocket serverSocket;
+ private final Set openClientSockets
+ = Collections.newSetFromMap(new ConcurrentHashMap());
+ private int port = -1;
+ private final BlockingQueue requestQueue
+ = new LinkedBlockingQueue();
+
+ public MockSpdyServer(SSLSocketFactory sslSocketFactory) {
+ this.sslSocketFactory = sslSocketFactory;
+ }
+
+ public String getHostName() {
+ try {
+ return InetAddress.getLocalHost().getHostName();
+ } catch (UnknownHostException e) {
+ throw new AssertionError();
+ }
+ }
+
+ public int getPort() {
+ if (port == -1) {
+ throw new IllegalStateException("Cannot retrieve port before calling play()");
+ }
+ return port;
+ }
+
+ public URL getUrl(String path) {
+ try {
+ return new URL("https://" + getHostName() + ":" + getPort() + path);
+ } catch (MalformedURLException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ /**
+ * Awaits the next HTTP request, removes it, and returns it. Callers should
+ * use this to verify the request sent was as intended.
+ */
+ public RecordedRequest takeRequest() throws InterruptedException {
+ return requestQueue.take();
+ }
+
+ public void play() throws IOException {
+ serverSocket = new ServerSocket(8888);
+ serverSocket.setReuseAddress(true);
+ port = serverSocket.getLocalPort();
+
+ Thread acceptThread = new Thread("MockSpdyServer-accept-" + port) {
+ @Override public void run() {
+ int sequenceNumber = 0;
+ try {
+ acceptConnections(sequenceNumber);
+ } catch (Throwable e) {
+ logger.log(Level.WARNING, "MockWebServer connection failed", e);
+ }
+
+ /*
+ * This gnarly block of code will release all sockets and
+ * all thread, even if any close fails.
+ */
+ try {
+ serverSocket.close();
+ } catch (Throwable e) {
+ logger.log(Level.WARNING, "MockWebServer server socket close failed", e);
+ }
+ for (Iterator s = openClientSockets.iterator(); s.hasNext(); ) {
+ try {
+ s.next().close();
+ s.remove();
+ } catch (Throwable e) {
+ logger.log(Level.WARNING, "MockWebServer socket close failed", e);
+ }
+ }
+ }
+ };
+ acceptThread.start();
+ }
+
+ public void enqueue(MockResponse response) {
+ dispatcher.enqueueResponse(response);
+ }
+
+ private void acceptConnections(int sequenceNumber) throws Exception {
+ while (true) {
+ Socket socket;
+ try {
+ socket = serverSocket.accept();
+ } catch (SocketException e) {
+ return;
+ }
+ openClientSockets.add(socket);
+ new SocketHandler(sequenceNumber++, socket).serve();
+ }
+ }
+
+ public void shutdown() throws IOException {
+ if (serverSocket != null) {
+ serverSocket.close(); // should cause acceptConnections() to break out
+ }
+ }
+
+ private class SocketHandler implements IncomingStreamHandler {
+ private final int sequenceNumber;
+ private Socket socket;
+
+ private SocketHandler(int sequenceNumber, Socket socket) throws IOException {
+ this.socket = socket;
+ this.sequenceNumber = sequenceNumber;
+ }
+
+ public void serve() throws IOException {
+ if (sslSocketFactory != null) {
+ socket = doSsl(socket);
+ }
+ new SpdyConnection.Builder(false, socket).handler(this).build();
+ }
+
+ private Socket doSsl(Socket socket) throws IOException {
+ SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(socket,
+ socket.getInetAddress().getHostAddress(), socket.getPort(), true);
+ sslSocket.setUseClientMode(false);
+ NextProtoNego.put(sslSocket, new NextProtoNego.ServerProvider() {
+ @Override public void unsupported() {
+ System.out.println("UNSUPPORTED");
+ }
+ @Override public List protocols() {
+ return Arrays.asList("spdy/3");
+ }
+ @Override public void protocolSelected(String protocol) {
+ System.out.println("PROTOCOL SELECTED: " + protocol);
+ }
+ });
+ return sslSocket;
+ }
+
+ @Override public void receive(final SpdyStream stream) throws IOException {
+ RecordedRequest request = readRequest(stream);
+ requestQueue.add(request);
+ MockResponse response;
+ try {
+ response = dispatcher.dispatch(request);
+ } catch (InterruptedException e) {
+ throw new AssertionError(e);
+ }
+ writeResponse(stream, response);
+ logger.info("Received request: " + request + " and responded: " + response);
+ }
+
+ private RecordedRequest readRequest(SpdyStream stream) throws IOException {
+ List spdyHeaders = stream.getRequestHeaders();
+ List httpHeaders = new ArrayList();
+ String method = "<:method omitted>";
+ String path = "<:path omitted>";
+ String version = "<:version omitted>";
+ for (Iterator i = spdyHeaders.iterator(); i.hasNext(); ) {
+ String name = i.next();
+ String value = i.next();
+ if (":method".equals(name)) {
+ method = value;
+ } else if (":path".equals(name)) {
+ path = value;
+ } else if (":version".equals(name)) {
+ version = value;
+ } else {
+ httpHeaders.add(name + ": " + value);
+ }
+ }
+
+ InputStream bodyIn = stream.getInputStream();
+ ByteArrayOutputStream bodyOut = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int count;
+ while ((count = bodyIn.read(buffer)) != -1) {
+ bodyOut.write(buffer, 0, count);
+ }
+ bodyIn.close();
+ String requestLine = method + ' ' + path + ' ' + version;
+ List chunkSizes = Collections.emptyList(); // No chunked encoding for SPDY.
+ return new RecordedRequest(requestLine, httpHeaders, chunkSizes, bodyOut.size(),
+ bodyOut.toByteArray(), sequenceNumber, socket);
+ }
+
+ private void writeResponse(SpdyStream stream, MockResponse response) throws IOException {
+ List spdyHeaders = new ArrayList();
+ String[] statusParts = response.getStatus().split(" ", 2);
+ if (statusParts.length != 2) {
+ throw new AssertionError("Unexpected status: " + response.getStatus());
+ }
+ spdyHeaders.add(":status");
+ spdyHeaders.add(statusParts[1]);
+ spdyHeaders.add(":version");
+ spdyHeaders.add(statusParts[0]);
+ for (String header : response.getHeaders()) {
+ String[] headerParts = header.split(":", 2);
+ if (headerParts.length != 2) {
+ throw new AssertionError("Unexpected header: " + header);
+ }
+ spdyHeaders.add(headerParts[0].toLowerCase(Locale.US));
+ spdyHeaders.add(headerParts[1]);
+ }
+ byte[] body = response.getBody();
+ stream.reply(spdyHeaders, body.length > 0);
+ if (body.length > 0) {
+ stream.getOutputStream().write(body);
+ stream.getOutputStream().close();
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/squareup/okhttp/internal/spdy/HttpOverSpdyTest.java b/src/test/java/com/squareup/okhttp/internal/spdy/HttpOverSpdyTest.java
new file mode 100644
index 000000000000..160f9fdc9cdd
--- /dev/null
+++ b/src/test/java/com/squareup/okhttp/internal/spdy/HttpOverSpdyTest.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp.internal.spdy;
+
+import com.google.mockwebserver.MockResponse;
+import com.google.mockwebserver.RecordedRequest;
+import com.squareup.okhttp.OkHttpClient;
+import com.squareup.okhttp.internal.SslContextBuilder;
+import com.squareup.okhttp.internal.mockspdyserver.MockSpdyServer;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.InetAddress;
+import java.net.URLConnection;
+import java.net.UnknownHostException;
+import java.security.GeneralSecurityException;
+import java.util.Collection;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSession;
+import org.junit.After;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Test how SPDY interacts with HTTP features.
+ */
+public final class HttpOverSpdyTest {
+ private static final HostnameVerifier NULL_HOSTNAME_VERIFIER = new HostnameVerifier() {
+ public boolean verify(String hostname, SSLSession session) {
+ return true;
+ }
+ };
+
+ private static final SSLContext sslContext;
+ static {
+ try {
+ sslContext = new SslContextBuilder(InetAddress.getLocalHost().getHostName()).build();
+ } catch (GeneralSecurityException e) {
+ throw new RuntimeException(e);
+ } catch (UnknownHostException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ private final MockSpdyServer server = new MockSpdyServer(sslContext.getSocketFactory());
+ private final String hostName = server.getHostName();
+ private final OkHttpClient client = new OkHttpClient();
+
+ @Before public void setUp() throws Exception {
+ client.setSSLSocketFactory(sslContext.getSocketFactory());
+ client.setHostnameVerifier(NULL_HOSTNAME_VERIFIER);
+ }
+
+ @After public void tearDown() throws Exception {
+ server.shutdown();
+ }
+
+ @Test public void get() throws Exception {
+ MockResponse response = new MockResponse().setBody("ABCDE");
+ server.enqueue(response);
+ server.play();
+
+ HttpURLConnection connection = client.open(server.getUrl("/foo"));
+ assertContent("ABCDE", connection, Integer.MAX_VALUE);
+
+ RecordedRequest request = server.takeRequest();
+ assertEquals("GET /foo HTTP/1.1", request.getRequestLine());
+ assertContains(request.getHeaders(), ":scheme: https");
+ assertContains(request.getHeaders(), ":host: " + hostName + ":" + server.getPort());
+ }
+
+ private void assertContains(Collection collection, T value) {
+ assertTrue(collection.toString(), collection.contains(value));
+ }
+
+ private void assertContent(String expected, URLConnection connection, int limit)
+ throws IOException {
+ connection.connect();
+ assertEquals(expected, readAscii(connection.getInputStream(), limit));
+ ((HttpURLConnection) connection).disconnect();
+ }
+
+ private String readAscii(InputStream in, int count) throws IOException {
+ StringBuilder result = new StringBuilder();
+ for (int i = 0; i < count; i++) {
+ int value = in.read();
+ if (value == -1) {
+ in.close();
+ break;
+ }
+ result.append((char) value);
+ }
+ return result.toString();
+ }
+}