Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce ConnectionInterceptor for individual connection processing #129

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ of this software and associated documentation files (the "Software"), to deal

import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
Expand All @@ -32,12 +33,17 @@ of this software and associated documentation files (the "Software"), to deal
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;

import static ly.count.android.sdk.UtilsNetworking.sha256Hash;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.isNull;
Expand Down Expand Up @@ -271,6 +277,85 @@ public void testRun_storeHasTwoConnections() throws IOException {
verify(mockURLConnection, times(2)).disconnect();
}

@Test
public void testUrlConnectionUsesInterceptor() throws IOException {
final String eventData = "blahblahblah";
ConnectionInterceptor interceptor = mock(ConnectionInterceptor.class);
when(interceptor.intercept(any(HttpURLConnection.class), nullable(byte[].class))).thenAnswer(new Answer<HttpURLConnection>() {
@Override public HttpURLConnection answer(InvocationOnMock invocation) throws Throwable {
return invocation.getArgument(0, HttpURLConnection.class);
}
});
connectionProcessor.setConnectionInterceptor(interceptor);
final URLConnection urlConnection = connectionProcessor.urlConnectionForServerRequest(eventData, null);
verify(interceptor).intercept(any(HttpURLConnection.class), nullable(byte[].class));
assertEquals(30000, urlConnection.getConnectTimeout());
assertEquals(30000, urlConnection.getReadTimeout());
assertFalse(urlConnection.getUseCaches());
assertTrue(urlConnection.getDoInput());
assertFalse(urlConnection.getDoOutput());
assertEquals(new URL(connectionProcessor.getServerURL() + "/i?" + eventData + "&checksum256=" + sha256Hash(eventData + null)), urlConnection.getURL());
}

@Test
public void testUrlConnectionInterceptorCanSetRequestPropertiesOnGet() throws IOException {
final String eventData = "blahblahblah";
ConnectionInterceptor interceptor = new ConnectionInterceptor() {
@Override public HttpURLConnection intercept(HttpURLConnection connection, byte[] body) {
connection.setRequestProperty("Prop", "SomeDynamicHeaderValue");
return connection;
}
};
connectionProcessor.setConnectionInterceptor(interceptor);
URLConnection conn = connectionProcessor.urlConnectionForServerRequest(eventData, null);
assertEquals("SomeDynamicHeaderValue", conn.getRequestProperty("Prop"));
}

@Test
public void connectionInterceptorCanSetRequestPropertiesOnPost() throws IOException {
// Crash data uses http post
final String eventData = "blahblahblah&crash=lol";
connectionProcessor = new ConnectionProcessor("https://count.ly/", mockStore, mockDeviceId, null, null, moduleLog);
ConnectionInterceptor interceptor = new ConnectionInterceptor() {
@Override public HttpURLConnection intercept(HttpURLConnection connection, byte[] body) {
connection.setRequestProperty("Prop", "SomeDynamicHeaderValue");
return connection;
}
};
connectionProcessor.setConnectionInterceptor(interceptor);
URLConnection conn = connectionProcessor.urlConnectionForServerRequest(eventData, null);
assertEquals("SomeDynamicHeaderValue", conn.getRequestProperty("Prop"));
}

@Test
public void testConnectionInterceptorCanSetRequestPropertiesOnPostPicturePath() throws IOException {
File picture = File.createTempFile("IconicFinance", ".png");
final String eventData = "picturePath="+picture.getPath();
connectionProcessor = new ConnectionProcessor("https://count.ly/", mockStore, mockDeviceId, null, null, moduleLog);
ConnectionInterceptor interceptor = new ConnectionInterceptor() {
@Override public HttpURLConnection intercept(HttpURLConnection connection, byte[] body) {
connection.setRequestProperty("Prop", "SomeDynamicHeaderValue");
return connection;
}
};
connectionProcessor.setConnectionInterceptor(interceptor);
URLConnection conn = connectionProcessor.urlConnectionForServerRequest(eventData, null);
assertEquals("SomeDynamicHeaderValue", conn.getRequestProperty("Prop"));
}

@Test
public void testUrlConnectionDoesNotUseInterceptorWhenNotAvailable() throws IOException {
final String eventData = "blahblahblah";
final URLConnection urlConnection = connectionProcessor.urlConnectionForServerRequest(eventData, null);
assertNull(connectionProcessor.getConnectionInterceptor());
assertEquals(30000, urlConnection.getConnectTimeout());
assertEquals(30000, urlConnection.getReadTimeout());
assertFalse(urlConnection.getUseCaches());
assertTrue(urlConnection.getDoInput());
assertFalse(urlConnection.getDoOutput());
assertEquals(new URL(connectionProcessor.getServerURL() + "/i?" + eventData + "&checksum256=" + sha256Hash(eventData + null)), urlConnection.getURL());
}

private static class TestInputStream2 extends InputStream {
boolean closed = false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import android.app.Application;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.net.HttpURLConnection;
import java.util.HashMap;
import java.util.Map;
import org.junit.Assert;
Expand Down Expand Up @@ -93,6 +94,12 @@ public boolean filterCrash(String crash) {

Application app = new Application();

ConnectionInterceptor interceptor = new ConnectionInterceptor() {
@Override public HttpURLConnection intercept(HttpURLConnection connection, byte[] body) {
return null;
}
};

assertDefaultValues(config, true);

config.setServerURL(s[0]);
Expand Down Expand Up @@ -142,6 +149,7 @@ public boolean filterCrash(String crash) {
config.setDisableLocation();
config.setLocation("CC", "city", "loc", "ip");
config.setMetricOverride(metricOverride);
config.setConnectionInterceptor(interceptor);

Assert.assertEquals(s[0], config.serverURL);
Assert.assertEquals(c, config.context);
Expand Down Expand Up @@ -194,6 +202,7 @@ public boolean filterCrash(String crash) {
Assert.assertEquals("loc", config.locationLocation);
Assert.assertEquals("ip", config.locationIpAddress);
Assert.assertEquals(metricOverride, config.metricOverride);
Assert.assertEquals(interceptor, config.interceptor);

config.setLocation("CC", "city", "loc", "ip");
}
Expand Down Expand Up @@ -265,5 +274,6 @@ void assertDefaultValues(CountlyConfig config, boolean includeConstructorValues)
Assert.assertNull(config.locationLocation);
Assert.assertNull(config.locationIpAddress);
Assert.assertNull(config.metricOverride);
Assert.assertNull(config.interceptor);
}
}
18 changes: 18 additions & 0 deletions sdk/src/main/java/ly/count/android/sdk/ConnectionInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ly.count.android.sdk;

import java.net.HttpURLConnection;

/**
* Interface to intercept Countly requests
*/
public interface ConnectionInterceptor {

/**
* This is called for each request which is send by Countly
*
* @param connection The connection which is about to be send
* @param body Body of the connection, null for GET requests
* @return HttpURLConnection which is used for connection
*/
HttpURLConnection intercept(HttpURLConnection connection, byte[] body);
}
83 changes: 55 additions & 28 deletions sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ of this software and associated documentation files (the "Software"), to deal
*/
package ly.count.android.sdk;

import android.util.Log;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
Expand Down Expand Up @@ -67,6 +67,8 @@ private enum RequestResult {
REMOVE // bad request, remove
}

private ConnectionInterceptor connectionInterceptor;

ConnectionProcessor(final String serverURL, final CountlyStore store, final DeviceId deviceId, final SSLContext sslContext, final Map<String, String> requestHeaderCustomValues, ModuleLog logModule) {
serverURL_ = serverURL;
store_ = store;
Expand All @@ -76,6 +78,38 @@ private enum RequestResult {
L = logModule;
}

private void writeMultipartDataToOutput(File binaryFile, String boundary, OutputStream output) throws IOException {
// Line separator required by multipart/form-data.
String CRLF = "\r\n";
String charset = "UTF-8";
PrintWriter writer = new PrintWriter(new OutputStreamWriter(output, charset), true);
// Send binary file.
writer.append("--").append(boundary).append(CRLF);
writer.append("Content-Disposition: form-data; name=\"binaryFile\"; filename=\"").append(binaryFile.getName()).append("\"").append(CRLF);
writer.append("Content-Type: ").append(URLConnection.guessContentTypeFromName(binaryFile.getName())).append(CRLF);
writer.append("Content-Transfer-Encoding: binary").append(CRLF);
writer.append(CRLF).flush();
FileInputStream fileInputStream = new FileInputStream(binaryFile);
byte[] buffer = new byte[1024];
int len;
try {
while ((len = fileInputStream.read(buffer)) != -1) {
output.write(buffer, 0, len);
}
} catch (IOException ex) {
ex.printStackTrace();
}
output.flush(); // Important before continuing with writer!
writer.append(CRLF).flush(); // CRLF is important! It indicates end of boundary.
fileInputStream.close();

// End of multipart/form-data.
writer.append("--").append(boundary).append("--").append(CRLF).flush();
writer.close();
output.flush();
output.close();
}

synchronized public URLConnection urlConnectionForServerRequest(String requestData, final String customEndpoint) throws IOException {
String urlEndpoint = "/i";
if (customEndpoint != null) {
Expand All @@ -92,7 +126,7 @@ synchronized public URLConnection urlConnectionForServerRequest(String requestDa
urlStr += "&checksum256=" + UtilsNetworking.sha256Hash(requestData + salt);
}
final URL url = new URL(urlStr);
final HttpURLConnection conn;
HttpURLConnection conn;
if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) {
conn = (HttpURLConnection) url.openConnection();
} else {
Expand Down Expand Up @@ -130,45 +164,30 @@ synchronized public URLConnection urlConnectionForServerRequest(String requestDa
conn.setDoOutput(true);
// Just generate some unique random value.
String boundary = Long.toHexString(System.currentTimeMillis());
// Line separator required by multipart/form-data.
String CRLF = "\r\n";
String charset = "UTF-8";
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
OutputStream output = conn.getOutputStream();
PrintWriter writer = new PrintWriter(new OutputStreamWriter(output, charset), true);
// Send binary file.
writer.append("--").append(boundary).append(CRLF);
writer.append("Content-Disposition: form-data; name=\"binaryFile\"; filename=\"").append(binaryFile.getName()).append("\"").append(CRLF);
writer.append("Content-Type: ").append(URLConnection.guessContentTypeFromName(binaryFile.getName())).append(CRLF);
writer.append("Content-Transfer-Encoding: binary").append(CRLF);
writer.append(CRLF).flush();
FileInputStream fileInputStream = new FileInputStream(binaryFile);
byte[] buffer = new byte[1024];
int len;
try {
while ((len = fileInputStream.read(buffer)) != -1) {
output.write(buffer, 0, len);
}
} catch (IOException ex) {
ex.printStackTrace();
if (connectionInterceptor != null) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
writeMultipartDataToOutput(binaryFile, boundary, output);
conn = connectionInterceptor.intercept(conn, output.toByteArray());
}
output.flush(); // Important before continuing with writer!
writer.append(CRLF).flush(); // CRLF is important! It indicates end of boundary.
fileInputStream.close();

// End of multipart/form-data.
writer.append("--").append(boundary).append("--").append(CRLF).flush();
writeMultipartDataToOutput(binaryFile, boundary, conn.getOutputStream());
} else {
if (usingHttpPost) {
conn.setDoOutput(true);
conn.setRequestMethod("POST");
if (connectionInterceptor != null) {
conn = connectionInterceptor.intercept(conn, requestData.getBytes());
}
OutputStream os = conn.getOutputStream();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));
writer.write(requestData);
writer.flush();
writer.close();
os.close();
} else {
if (connectionInterceptor != null) {
conn = connectionInterceptor.intercept(conn, null);
}
L.v("[Connection Processor] Using HTTP GET");
conn.setDoOutput(false);
}
Expand Down Expand Up @@ -394,4 +413,12 @@ CountlyStore getCountlyStore() {
DeviceId getDeviceId() {
return deviceId_;
}

public ConnectionInterceptor getConnectionInterceptor() {
return connectionInterceptor;
}

public void setConnectionInterceptor(ConnectionInterceptor connectionInterceptor) {
this.connectionInterceptor = connectionInterceptor;
}
}
13 changes: 12 additions & 1 deletion sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public class ConnectionQueue {
private Future<?> connectionProcessorFuture_;
private DeviceId deviceId_;
private SSLContext sslContext_;
private ConnectionInterceptor connectionInterceptor_;

private Map<String, String> requestHeaderCustomValues;
Map<String, String> metricOverride = null;
Expand Down Expand Up @@ -110,6 +111,14 @@ public void setDeviceId(DeviceId deviceId) {
this.deviceId_ = deviceId;
}

public ConnectionInterceptor getConnectionInterceptor() {
return connectionInterceptor_;
}

public void setConnectionInterceptor(ConnectionInterceptor connectionInterceptor_) {
this.connectionInterceptor_ = connectionInterceptor_;
}

protected void setRequestHeaderCustomValues(Map<String, String> headerCustomValues) {
requestHeaderCustomValues = headerCustomValues;
}
Expand Down Expand Up @@ -653,7 +662,9 @@ void tick() {
}

public ConnectionProcessor createConnectionProcessor() {
return new ConnectionProcessor(getServerURL(), store_, deviceId_, sslContext_, requestHeaderCustomValues, L);
ConnectionProcessor processor = new ConnectionProcessor(getServerURL(), store_, deviceId_, sslContext_, requestHeaderCustomValues, L);
processor.setConnectionInterceptor(connectionInterceptor_);
return processor;
}

public boolean queueContainsTemporaryIdItems() {
Expand Down
1 change: 1 addition & 0 deletions sdk/src/main/java/ly/count/android/sdk/Countly.java
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ public synchronized Countly init(CountlyConfig config) {
connectionQueue_.setDeviceId(config.deviceIdInstance);
connectionQueue_.setRequestHeaderCustomValues(requestHeaderCustomValues);
connectionQueue_.setMetricOverride(config.metricOverride);
connectionQueue_.setConnectionInterceptor(config.interceptor);
connectionQueue_.setContext(context_);

eventQueue_ = new EventQueue(countlyStore);
Expand Down
13 changes: 13 additions & 0 deletions sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ public class CountlyConfig {

protected boolean recordAppStartTime = false;

protected ConnectionInterceptor interceptor = null;

boolean disableLocation = false;

String locationCountyCode = null;
Expand Down Expand Up @@ -583,4 +585,15 @@ public synchronized CountlyConfig setLogListener(ModuleLog.LogCallback logCallba
providedLogCallback = logCallback;
return this;
}

/**
* Sets an interceptor which can be used to run custom connection processing for each network requests.
* This is useful to add dynamic headers for each request.
*
* @param interceptor Gets an HttpURLConnection and returns a new HttpURLConnection
*/
public synchronized CountlyConfig setConnectionInterceptor(ConnectionInterceptor interceptor) {
this.interceptor = interceptor;
return this;
}
}