diff --git a/settings.gradle b/settings.gradle
index 575282a2..f5fac105 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,3 +1,4 @@
include ':stetho'
include ':stetho-urlconnection'
+include ':stetho-okhttp'
include ':stetho-sample'
diff --git a/stetho-okhttp/.gitignore b/stetho-okhttp/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/stetho-okhttp/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/stetho-okhttp/build.gradle b/stetho-okhttp/build.gradle
new file mode 100644
index 00000000..d3d0781f
--- /dev/null
+++ b/stetho-okhttp/build.gradle
@@ -0,0 +1,51 @@
+apply plugin: 'com.android.library'
+apply plugin: 'robolectric'
+
+android {
+ compileSdkVersion 21
+ buildToolsVersion "21.1.2"
+
+ defaultConfig {
+ minSdkVersion 9
+ targetSdkVersion 21
+ versionCode 1
+ versionName "1.0"
+ }
+
+ sourceSets {
+ androidTest {
+ setRoot('src/test')
+ }
+ }
+
+ lintOptions {
+ // This seems to be firing due to okio referencing java.nio.File
+ // which is harmless for us. Not sure how to disable this in
+ // more targeted fashion...
+ warning 'InvalidPackage'
+ }
+}
+
+robolectric {
+ include '**/*Test.class'
+ exclude '**/espresso/**/*.class'
+}
+
+dependencies {
+ compile project(':stetho')
+ compile 'com.google.code.findbugs:jsr305:2.0.1'
+ //noinspection GradleDynamicVersion
+ compile 'com.squareup.okhttp:okhttp:2.2.0+'
+
+ androidTestCompile 'junit:junit:4.12'
+ androidTestCompile('org.robolectric:robolectric:2.4') {
+ exclude module: 'commons-logging'
+ exclude module: 'httpclient'
+ }
+ androidTestCompile 'org.powermock:powermock-api-mockito:1.6.1'
+ androidTestCompile 'org.powermock:powermock-module-junit4:1.6.1'
+
+ // Needed for Robolectric and PowerMock to be combined in a single test.
+ androidTestCompile 'org.powermock:powermock-module-junit4-rule:1.6.1'
+ androidTestCompile 'org.powermock:powermock-classloading-xstream:1.6.1'
+}
diff --git a/stetho-okhttp/src/main/AndroidManifest.xml b/stetho-okhttp/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..7c833acc
--- /dev/null
+++ b/stetho-okhttp/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+ * OkHttpClient client = new OkHttpClient(); + * client.networkInterceptors().add(new StethoInterceptor()); + *+ */ +public class StethoInterceptor implements Interceptor { + private final NetworkEventReporter mEventReporter = NetworkEventReporterImpl.get(); + + private final AtomicInteger mNextRequestId = new AtomicInteger(0); + + @Override + public Response intercept(Chain chain) throws IOException { + String requestId = String.valueOf(mNextRequestId.getAndIncrement()); + + Request request = chain.request(); + + int requestSize = 0; + if (mEventReporter.isEnabled()) { + OkHttpInspectorRequest inspectorRequest = new OkHttpInspectorRequest(requestId, request); + mEventReporter.requestWillBeSent(inspectorRequest); + byte[] requestBody = inspectorRequest.body(); + if (requestBody != null) { + requestSize += requestBody.length; + } + } + + Response response; + try { + response = chain.proceed(request); + } catch (IOException e) { + if (mEventReporter.isEnabled()) { + mEventReporter.httpExchangeFailed(requestId, e.toString()); + } + throw e; + } + + if (mEventReporter.isEnabled()) { + if (requestSize > 0) { + mEventReporter.dataSent(requestId, requestSize, requestSize); + } + + Connection connection = chain.connection(); + mEventReporter.responseHeadersReceived( + new OkHttpInspectorResponse( + requestId, + request, + response, + connection)); + + ResponseBody body = response.body(); + MediaType contentType = null; + InputStream responseStream = null; + if (body != null) { + contentType = body.contentType(); + responseStream = body.byteStream(); + } + + responseStream = mEventReporter.interpretResponseStream( + requestId, + contentType != null ? contentType.toString() : null, + responseStream, + new DefaultResponseHandler(mEventReporter, requestId)); + if (responseStream != null) { + response = response.newBuilder() + .body(new ForwardingResponseBody(body, responseStream)) + .build(); + } + } + + return response; + } + + private static class OkHttpInspectorRequest implements NetworkEventReporter.InspectorRequest { + private final String mRequestId; + private final Request mRequest; + private byte[] mBody; + private boolean mBodyGenerated; + + public OkHttpInspectorRequest(String requestId, Request request) { + mRequestId = requestId; + mRequest = request; + } + + @Override + public String id() { + return mRequestId; + } + + @Override + public String friendlyName() { + // Hmm, can we do better? tag() perhaps? + return null; + } + + @Nullable + @Override + public Integer friendlyNameExtra() { + return null; + } + + @Override + public String url() { + return mRequest.urlString(); + } + + @Override + public String method() { + return mRequest.method(); + } + + @Nullable + @Override + public byte[] body() throws IOException { + if (!mBodyGenerated) { + mBodyGenerated = true; + mBody = generateBody(); + } + return mBody; + } + + private byte[] generateBody() throws IOException { + RequestBody body = mRequest.body(); + if (body != null) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + BufferedSink sink = Okio.buffer(Okio.sink(out)); + body.writeTo(sink); + sink.flush(); + return out.toByteArray(); + } else { + return null; + } + } + + @Override + public int headerCount() { + return mRequest.headers().size(); + } + + @Override + public String headerName(int index) { + return mRequest.headers().name(index); + } + + @Override + public String headerValue(int index) { + return mRequest.headers().value(index); + } + + @Nullable + @Override + public String firstHeaderValue(String name) { + return mRequest.header(name); + } + } + + private static class OkHttpInspectorResponse implements NetworkEventReporter.InspectorResponse { + private final String mRequestId; + private final Request mRequest; + private final Response mResponse; + private final Connection mConnection; + + public OkHttpInspectorResponse( + String requestId, + Request request, + Response response, + Connection connection) { + mRequestId = requestId; + mRequest = request; + mResponse = response; + mConnection = connection; + } + + @Override + public String requestId() { + return mRequestId; + } + + @Override + public String url() { + return mRequest.urlString(); + } + + @Override + public int statusCode() { + return mResponse.code(); + } + + @Override + public String reasonPhrase() { + return mResponse.message(); + } + + @Override + public boolean connectionReused() { + // Not sure... + return false; + } + + @Override + public int connectionId() { + return mConnection.hashCode(); + } + + @Override + public boolean fromDiskCache() { + return mResponse.cacheResponse() != null; + } + + @Override + public int headerCount() { + return mResponse.headers().size(); + } + + @Override + public String headerName(int index) { + return mResponse.headers().name(index); + } + + @Override + public String headerValue(int index) { + return mResponse.headers().value(index); + } + + @Nullable + @Override + public String firstHeaderValue(String name) { + return mResponse.header(name); + } + } + + private static class ForwardingResponseBody extends ResponseBody { + private final ResponseBody mBody; + private final BufferedSource mInterceptedSource; + + public ForwardingResponseBody(ResponseBody body, InputStream interceptedStream) { + mBody = body; + mInterceptedSource = Okio.buffer(Okio.source(interceptedStream)); + } + + @Override + public MediaType contentType() { + return mBody.contentType(); + } + + @Override + public long contentLength() { + return mBody.contentLength(); + } + + @Override + public BufferedSource source() { + // close on the delegating body will actually close this intercepted source, but it + // was derived from mBody.byteStream() therefore the close will be forwarded all the + // way to the original. + return mInterceptedSource; + } + } +} diff --git a/stetho-okhttp/src/test/java/com/facebook/stetho/okhttp/StethoInterceptorTest.java b/stetho-okhttp/src/test/java/com/facebook/stetho/okhttp/StethoInterceptorTest.java new file mode 100644 index 00000000..df36ec63 --- /dev/null +++ b/stetho-okhttp/src/test/java/com/facebook/stetho/okhttp/StethoInterceptorTest.java @@ -0,0 +1,150 @@ +package com.facebook.stetho.okhttp; + +import android.net.Uri; +import android.os.Build; +import com.facebook.stetho.inspector.network.*; +import com.squareup.okhttp.*; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.powermock.modules.junit4.rule.PowerMockRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import javax.annotation.Nullable; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Matchers.*; + +@Config(emulateSdk = Build.VERSION_CODES.JELLY_BEAN) +@RunWith(RobolectricTestRunner.class) +@PowerMockIgnore({ "org.mockito.*", "org.robolectric.*", "android.*" }) +@PrepareForTest(NetworkEventReporterImpl.class) +public class StethoInterceptorTest { + @Rule + public PowerMockRule rule = new PowerMockRule(); + + @Test + public void testManualChainHappyPath() throws IOException { + PowerMockito.mockStatic(NetworkEventReporterImpl.class); + + final NetworkEventReporter mockEventReporter = Mockito.mock(NetworkEventReporter.class); + InOrder inOrder = Mockito.inOrder(mockEventReporter); + Mockito.when(mockEventReporter.isEnabled()).thenReturn(true); + ByteArrayOutputStream capturedOutput = fakeInterpretResponseStream(mockEventReporter); + PowerMockito.when(NetworkEventReporterImpl.get()).thenReturn(mockEventReporter); + + StethoInterceptor interceptor = new StethoInterceptor(); + + Uri requestUri = Uri.parse("http://www.facebook.com/nowhere"); + Request request = new Request.Builder() + .url(requestUri.toString()) + .method( + "POST", + RequestBody.create(MediaType.parse("text/plain"), "Test input")) + .build(); + String originalBodyData = "Success!"; + Response reply = new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .body(ResponseBody.create(MediaType.parse("text/plain"), originalBodyData)) + .build(); + Response filteredResponse = + interceptor.intercept( + new SimpleTestChain(request, reply, null)); + + inOrder.verify(mockEventReporter).isEnabled(); + inOrder.verify(mockEventReporter) + .requestWillBeSent(any(NetworkEventReporter.InspectorRequest.class)); + inOrder.verify(mockEventReporter).dataSent(anyString(), anyInt(), anyInt()); + inOrder.verify(mockEventReporter) + .responseHeadersReceived(any(NetworkEventReporter.InspectorResponse.class)); + + String filteredResponseString = filteredResponse.body().string(); + String interceptedOutput = capturedOutput.toString(); + + inOrder.verify(mockEventReporter).dataReceived(anyString(), anyInt(), anyInt()); + inOrder.verify(mockEventReporter).responseReadFinished(anyString()); + + assertEquals(originalBodyData, filteredResponseString); + assertEquals(originalBodyData, interceptedOutput); + + inOrder.verifyNoMoreInteractions(); + } + + /** + * Provide a suitably "real" implementation of + * {@link NetworkEventReporter#interpretResponseStream} for our mock to test that + * events are properly delegated. + */ + private static ByteArrayOutputStream fakeInterpretResponseStream( + final NetworkEventReporter mockEventReporter) { + final ByteArrayOutputStream capturedOutput = new ByteArrayOutputStream(); + Mockito.when( + mockEventReporter.interpretResponseStream( + anyString(), + anyString(), + any(InputStream.class), + any(ResponseHandler.class))) + .thenAnswer( + new Answer