From 9a1da9b4209955072d97caeb9cbcdeefb02841eb Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Tue, 13 Nov 2012 00:09:16 -0800 Subject: [PATCH] New API to break platform-specific dependencies. In particular: - Don't require OkHttp to depend on Jetty's non-boot package for Android. This was causing ugly, confidence-smashing dalvik errors in Android apps. - Don't prevent HTTP on Java6 just because SPDY isn't available. This uses gross reflection and dynamic proxies! That's sad, but it's encapsulated so users won't have to know it exists. --- README.md | 30 ++ pom.xml | 58 ++-- src/main/java/libcore/Platform.java | 315 ++++++++++++++++++ .../java/libcore/net/http/HttpConnection.java | 9 +- .../java/libcore/net/spdy/SpdyWriter.java | 4 +- src/main/java/libcore/util/Libcore.java | 144 -------- 6 files changed, 372 insertions(+), 188 deletions(-) create mode 100644 src/main/java/libcore/Platform.java diff --git a/README.md b/README.md index 9793fcf90..ed8d161cb 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,36 @@ OkHttp uses the platform's [ProxySelector][2]. Prior to Android 4.0, `ProxySelec 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. +Building +-------- + +### On the Desktop +Run OkHttp tests on the desktop with Maven. +``` +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. +``` +mvn clean +mvn package -DskipTests +vogar \ + --classpath ~/.m2/repository/org/bouncycastle/bcprov-jdk15on/1.47/bcprov-jdk15on-1.47.jar \ + --classpath ~/.m2/repository/com/google/mockwebserver/mockwebserver/20121111/mockwebserver-20121111.jar \ + --classpath target/okhttp-0.8-SNAPSHOT.jar \ + ./src/test/java/libcore/net/http/URLConnectionTest.java +``` +Because the OkHttp uses `jarjar` to repackage classes in `libcore`, OkHttp tests that use those classes directly cannot be run on a device. + + License ------- diff --git a/pom.xml b/pom.xml index b95912d27..c7381d549 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,7 @@ org.mortbay.jetty.npn npn-boot ${npn.version} + true com.google.mockwebserver @@ -125,45 +126,6 @@ - - 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 - - - - - org.apache.maven.plugins - maven-enforcer-plugin - 1.1 - - - enforce-java - - enforce - - - - - [1.7.0,) - - - - - - org.apache.maven.plugins maven-checkstyle-plugin @@ -185,5 +147,23 @@ + + + + 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/libcore/Platform.java b/src/main/java/libcore/Platform.java new file mode 100644 index 000000000..5b5ae924c --- /dev/null +++ b/src/main/java/libcore/Platform.java @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2012 Square, Inc. + * Copyright (C) 2012 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 libcore; + +import com.squareup.okhttp.OkHttpsConnection; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import javax.net.ssl.SSLSocket; + +/** + * Access to Platform-specific features necessary for SPDY and advanced TLS. + * + *

SPDY

+ * SPDY requires a TLS extension called NPN (Next Protocol Negotiation) that's + * available in Android 4.1+ and OpenJDK 7+ (with the npn-boot extension). It + * also requires a recent version of {@code DeflaterOutputStream} that is + * public API in Java 7 and callable via reflection in Android 4.1+. + */ +public class Platform { + private static final Platform platform = findPlatform(); + + private Constructor deflaterConstructor; + + public static Platform get() { + return platform; + } + + public void makeTlsTolerant(SSLSocket socket, String uriHost, boolean tlsTolerant) { + if (!tlsTolerant) { + socket.setEnabledProtocols(new String[]{"SSLv3"}); + } + } + + /** + * Returns the negotiated protocol, or null if no protocol was negotiated. + */ + public byte[] getNpnSelectedProtocol(SSLSocket socket) { + return null; + } + + /** + * Sets client-supported protocols on a socket to send to a server. The + * protocols are only sent if the socket implementation supports NPN. + */ + public void setNpnProtocols(SSLSocket socket, byte[] npnProtocols) { + } + + /** + * Returns a deflater output stream that supports SYNC_FLUSH for SPDY name + * value blocks. This throws an {@link UnsupportedOperationException} on + * Java 6 and earlier where there is no built-in API to do SYNC_FLUSH. + */ + public OutputStream newDeflaterOutputStream( + OutputStream out, Deflater deflater, boolean syncFlush) { + try { + Constructor constructor = deflaterConstructor; + if (constructor == null) { + constructor = deflaterConstructor = DeflaterOutputStream.class.getConstructor( + OutputStream.class, Deflater.class, boolean.class); + } + return constructor.newInstance(out, deflater, syncFlush); + } catch (NoSuchMethodException e) { + throw new UnsupportedOperationException("Cannot SPDY; no SYNC_FLUSH available"); + } catch (InvocationTargetException e) { + throw e.getCause() instanceof RuntimeException + ? (RuntimeException) e.getCause() + : new RuntimeException(e.getCause()); + } catch (InstantiationException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new AssertionError(); + } + } + + /** + * Attempt to match the host runtime to a capable Platform implementation. + */ + private static Platform findPlatform() { + // Attempt to find Android 2.3+ APIs. + Class openSslSocketClass; + Method setUseSessionTickets; + Method setHostname; + try { + openSslSocketClass = Class.forName( + "org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl"); + setUseSessionTickets = openSslSocketClass.getMethod( + "setUseSessionTickets", boolean.class); + setHostname = openSslSocketClass.getMethod("setHostname", String.class); + + // Attempt to find Android 4.1+ APIs. + try { + Method setNpnProtocols = openSslSocketClass.getMethod( + "setNpnProtocols", byte[].class); + Method getNpnSelectedProtocol = openSslSocketClass.getMethod( + "getNpnSelectedProtocol"); + return new Android41(openSslSocketClass, setUseSessionTickets, setHostname, + setNpnProtocols, getNpnSelectedProtocol); + } catch (NoSuchMethodException ignored) { + return new Android23(openSslSocketClass, setUseSessionTickets, setHostname); + } + } catch (ClassNotFoundException ignored) { + // This isn't an Android runtime. + } catch (NoSuchMethodException ignored) { + // This isn't Android 2.3 or better. + } + + // Attempt to find the Jetty's NPN extension for OpenJDK. + try { + String npnClassName = "org.eclipse.jetty.npn.NextProtoNego"; + Class nextProtoNegoClass = Class.forName(npnClassName); + Class providerClass = Class.forName(npnClassName + "$Provider"); + Class clientProviderClass = Class.forName(npnClassName + "$ClientProvider"); + Method putMethod = nextProtoNegoClass.getMethod("put", SSLSocket.class, providerClass); + Method getMethod = nextProtoNegoClass.getMethod("get", SSLSocket.class); + return new JdkWithJettyNpnPlatform(putMethod, getMethod, clientProviderClass); + } catch (ClassNotFoundException ignored) { + return new Platform(); // NPN isn't on the classpath. + } catch (NoSuchMethodException ignored) { + return new Platform(); // The NPN version isn't what we expect. + } + } + + /** + * Android version 2.3 and newer support TLS session tickets and server name + * indication (SNI). + */ + private static class Android23 extends Platform { + protected final Class openSslSocketClass; + private final Method setUseSessionTickets; + private final Method setHostname; + + private Android23(Class openSslSocketClass, Method setUseSessionTickets, + Method setHostname) { + this.openSslSocketClass = openSslSocketClass; + this.setUseSessionTickets = setUseSessionTickets; + this.setHostname = setHostname; + } + + @Override public void makeTlsTolerant( + SSLSocket socket, String uriHost, boolean tlsTolerant) { + super.makeTlsTolerant(socket, uriHost, tlsTolerant); + if (tlsTolerant && openSslSocketClass.isInstance(socket)) { + // This is Android: use reflection on OpenSslSocketImpl. + try { + setUseSessionTickets.invoke(socket, true); + setHostname.invoke(socket, uriHost); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } + } + } + } + + /** + * Android version 4.1 and newer support NPN. + */ + private static class Android41 extends Android23 { + private final Method setNpnProtocols; + private final Method getNpnSelectedProtocol; + + private Android41(Class openSslSocketClass, Method setUseSessionTickets, + Method setHostname, Method setNpnProtocols, Method getNpnSelectedProtocol) { + super(openSslSocketClass, setUseSessionTickets, setHostname); + this.setNpnProtocols = setNpnProtocols; + this.getNpnSelectedProtocol = getNpnSelectedProtocol; + } + + @Override public void setNpnProtocols(SSLSocket socket, byte[] npnProtocols) { + if (!openSslSocketClass.isInstance(socket)) { + return; + } + try { + setNpnProtocols.invoke(socket, new Object[] {npnProtocols}); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } + } + + @Override public byte[] getNpnSelectedProtocol(SSLSocket socket) { + if (!openSslSocketClass.isInstance(socket)) { + return null; + } + try { + return (byte[]) getNpnSelectedProtocol.invoke(socket); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } + } + } + + /** + * OpenJDK 7 plus {@code org.mortbay.jetty.npn/npn-boot} on the boot class + * path. + */ + private static class JdkWithJettyNpnPlatform extends Platform { + private final Method getMethod; + private final Method putMethod; + private final Class clientProviderClass; + + public JdkWithJettyNpnPlatform( + Method putMethod, Method getMethod, Class clientProviderClass) { + this.putMethod = putMethod; + this.getMethod = getMethod; + this.clientProviderClass = clientProviderClass; + } + + @Override public void setNpnProtocols(SSLSocket socket, byte[] npnProtocols) { + try { + List strings = new ArrayList(); + for (int i = 0; i < npnProtocols.length;) { + int length = npnProtocols[i++]; + strings.add(new String(npnProtocols, i, length, "US-ASCII")); + i += length; + } + Object provider = Proxy.newProxyInstance(Platform.class.getClassLoader(), + new Class[] { clientProviderClass }, new JettyNpnProvider(strings)); + putMethod.invoke(null, socket, provider); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } catch (InvocationTargetException e) { + throw new AssertionError(e); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } + } + + @Override public byte[] getNpnSelectedProtocol(SSLSocket socket) { + try { + JettyNpnProvider provider = (JettyNpnProvider) Proxy.getInvocationHandler( + getMethod.invoke(null, socket)); + if (!provider.unsupported && provider.selected == null) { + Logger logger = Logger.getLogger(OkHttpsConnection.class.getName()); + logger.log(Level.INFO, "NPN callback dropped so SPDY is disabled. " + + "Is npn-boot on the boot class path?"); + return null; + } + return provider.unsupported + ? null + : provider.selected.getBytes("US-ASCII"); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(); + } catch (InvocationTargetException e) { + throw new AssertionError(); + } catch (IllegalAccessException e) { + throw new AssertionError(); + } + } + } + + /** + * Handle the methods of NextProtoNego's ClientProvider and ServerProvider + * without a compile-time dependency on those interfaces. + */ + private static class JettyNpnProvider implements InvocationHandler { + private final List clientProtocols; + private boolean unsupported; + private String selected; + + public JettyNpnProvider(List clientProtocols) { + this.clientProtocols = clientProtocols; + } + + @Override public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable { + String methodName = method.getName(); + Class returnType = method.getReturnType(); + if (methodName.equals("supports") && boolean.class == returnType) { + return true; + } else if (methodName.equals("unsupported") && void.class == returnType) { + this.unsupported = true; + return null; + } else if (methodName.equals("selectProtocol") && String.class == returnType + && args.length == 1 && (args[0] == null || args[0] instanceof List)) { + // TODO: use OpenSSL's algorithm which uses both lists + List serverProtocols = (List) args[0]; + System.out.println("CLIENT PROTOCOLS: " + clientProtocols + " SERVER PROTOCOLS: " + serverProtocols); + this.selected = clientProtocols.get(0); + return selected; + } else { + return method.invoke(this, args); + } + } + } +} diff --git a/src/main/java/libcore/net/http/HttpConnection.java b/src/main/java/libcore/net/http/HttpConnection.java index dd8b7e79e..2fd27935d 100644 --- a/src/main/java/libcore/net/http/HttpConnection.java +++ b/src/main/java/libcore/net/http/HttpConnection.java @@ -33,6 +33,7 @@ import java.util.Arrays; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; +import libcore.Platform; import libcore.io.IoUtils; import libcore.net.spdy.SpdyConnection; import libcore.util.Libcore; @@ -131,6 +132,8 @@ final class HttpConnection { * validation. */ private void upgradeToTls(TunnelConfig tunnelConfig) throws IOException { + Platform platform = Platform.get(); + // Make an SSL Tunnel on the first message pair of each SSL + proxy connection. if (requiresTunnel()) { makeTunnel(tunnelConfig); @@ -140,10 +143,10 @@ final class HttpConnection { socket = address.sslSocketFactory.createSocket( socket, address.uriHost, address.uriPort, true /* autoClose */); SSLSocket sslSocket = (SSLSocket) socket; - Libcore.makeTlsTolerant(sslSocket, address.uriHost, tlsMode == TLS_MODE_AGGRESSIVE); + platform.makeTlsTolerant(sslSocket, address.uriHost, tlsMode == TLS_MODE_AGGRESSIVE); if (tlsMode == TLS_MODE_AGGRESSIVE) { - Libcore.setNpnProtocols(sslSocket, NPN_PROTOCOLS); + platform.setNpnProtocols(sslSocket, NPN_PROTOCOLS); } // Force handshake. This can throw! @@ -159,7 +162,7 @@ final class HttpConnection { byte[] selectedProtocol; if (tlsMode == TLS_MODE_AGGRESSIVE - && (selectedProtocol = Libcore.getNpnSelectedProtocol(sslSocket)) != null) { + && (selectedProtocol = platform.getNpnSelectedProtocol(sslSocket)) != null) { if (Arrays.equals(selectedProtocol, SPDY2)) { spdyConnection = new SpdyConnection.Builder(true, in, out).build(); HttpConnectionPool.INSTANCE.share(this); diff --git a/src/main/java/libcore/net/spdy/SpdyWriter.java b/src/main/java/libcore/net/spdy/SpdyWriter.java index b62011cec..cc0f66ec0 100644 --- a/src/main/java/libcore/net/spdy/SpdyWriter.java +++ b/src/main/java/libcore/net/spdy/SpdyWriter.java @@ -22,7 +22,7 @@ import java.io.IOException; import java.io.OutputStream; import java.util.List; import java.util.zip.Deflater; -import java.util.zip.DeflaterOutputStream; +import libcore.Platform; /** * Write version 2 SPDY frames. @@ -39,7 +39,7 @@ final class SpdyWriter { deflater.setDictionary(SpdyReader.DICTIONARY); nameValueBlockBuffer = new ByteArrayOutputStream(); nameValueBlockOut = new DataOutputStream( - new DeflaterOutputStream(nameValueBlockBuffer, deflater, true)); + Platform.get().newDeflaterOutputStream(nameValueBlockBuffer, deflater, true)); } public void synStream(int flags, int streamId, int associatedStreamId, int priority, diff --git a/src/main/java/libcore/util/Libcore.java b/src/main/java/libcore/util/Libcore.java index 70531878b..808933bab 100644 --- a/src/main/java/libcore/util/Libcore.java +++ b/src/main/java/libcore/util/Libcore.java @@ -18,19 +18,12 @@ package libcore.util; import java.io.File; import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.net.Socket; import java.net.SocketException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.ByteOrder; -import java.util.ArrayList; -import java.util.List; -import javax.net.ssl.SSLSocket; -import org.eclipse.jetty.npn.NextProtoNego; /** * APIs for interacting with Android's core library. This mostly emulates the @@ -41,143 +34,6 @@ public final class Libcore { private Libcore() { } - private static boolean useAndroidTlsApis; - private static Class openSslSocketClass; - private static Method setUseSessionTickets; - private static Method setHostname; - private static boolean android23TlsOptionsAvailable; - private static Method setNpnProtocols; - private static Method getNpnSelectedProtocol; - private static boolean android41TlsOptionsAvailable; - - static { - try { - openSslSocketClass = Class.forName( - "org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl"); - useAndroidTlsApis = true; - setUseSessionTickets = openSslSocketClass.getMethod( - "setUseSessionTickets", boolean.class); - setHostname = openSslSocketClass.getMethod("setHostname", String.class); - android23TlsOptionsAvailable = true; - setNpnProtocols = openSslSocketClass.getMethod("setNpnProtocols", byte[].class); - getNpnSelectedProtocol = openSslSocketClass.getMethod("getNpnSelectedProtocol"); - android41TlsOptionsAvailable = true; - } catch (ClassNotFoundException ignored) { - // This isn't an Android runtime. - } catch (NoSuchMethodException ignored) { - // This Android runtime is missing some optional TLS options. - } - } - - public static void makeTlsTolerant(SSLSocket socket, String uriHost, boolean tlsTolerant) { - if (!tlsTolerant) { - socket.setEnabledProtocols(new String[] {"SSLv3"}); - return; - } - - if (android23TlsOptionsAvailable && openSslSocketClass.isInstance(socket)) { - // This is Android: use reflection on OpenSslSocketImpl. - try { - setUseSessionTickets.invoke(socket, true); - setHostname.invoke(socket, uriHost); - } catch (InvocationTargetException e) { - throw new RuntimeException(e); - } catch (IllegalAccessException e) { - throw new AssertionError(e); - } - } - } - - /** - * Returns the negotiated protocol, or null if no protocol was negotiated. - */ - public static byte[] getNpnSelectedProtocol(SSLSocket socket) { - if (useAndroidTlsApis) { - // This is Android: use reflection on OpenSslSocketImpl. - if (android41TlsOptionsAvailable && openSslSocketClass.isInstance(socket)) { - try { - return (byte[]) getNpnSelectedProtocol.invoke(socket); - } catch (InvocationTargetException e) { - throw new RuntimeException(e); - } catch (IllegalAccessException e) { - throw new AssertionError(e); - } - } - return null; - } else { - // This is OpenJDK: use JettyNpnProvider. - JettyNpnProvider provider = (JettyNpnProvider) NextProtoNego.get(socket); - if (!provider.unsupported && provider.selected == null) { - throw new IllegalStateException( - "No callback received. Is NPN configured properly?"); - } - try { - return provider.unsupported - ? null - : provider.selected.getBytes("US-ASCII"); - } catch (UnsupportedEncodingException e) { - throw new AssertionError(e); - } - } - } - - public static void setNpnProtocols(SSLSocket socket, byte[] npnProtocols) { - if (useAndroidTlsApis) { - // This is Android: use reflection on OpenSslSocketImpl. - if (android41TlsOptionsAvailable && openSslSocketClass.isInstance(socket)) { - try { - setNpnProtocols.invoke(socket, new Object[] {npnProtocols}); - } catch (IllegalAccessException e) { - throw new AssertionError(e); - } catch (InvocationTargetException e) { - throw new RuntimeException(e); - } - } - } else { - // This is OpenJDK: use JettyNpnProvider. - try { - List strings = new ArrayList(); - for (int i = 0; i < npnProtocols.length;) { - int length = npnProtocols[i++]; - strings.add(new String(npnProtocols, i, length, "US-ASCII")); - i += length; - } - JettyNpnProvider provider = new JettyNpnProvider(); - provider.protocols = strings; - NextProtoNego.put(socket, provider); - } catch (UnsupportedEncodingException e) { - throw new AssertionError(e); - } - } - } - - private static class JettyNpnProvider - implements NextProtoNego.ClientProvider, NextProtoNego.ServerProvider { - List protocols; - boolean unsupported; - String selected; - - @Override public boolean supports() { - return true; - } - @Override public List protocols() { - return protocols; - } - @Override public void unsupported() { - this.unsupported = true; - } - @Override public void protocolSelected(String selected) { - this.selected = selected; - } - @Override public String selectProtocol(List strings) { - // TODO: use OpenSSL's algorithm which uses 2 lists - System.out.println("CLIENT PROTOCOLS: " + protocols + " SERVER PROTOCOLS: " + strings); - String selected = protocols.get(0); - protocolSelected(selected); - return selected; - } - } - public static void deleteIfExists(File file) throws IOException { // okhttp-changed: was Libcore.os.remove() in a try/catch block file.delete();