diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml
index 64db71862..107a9a999 100644
--- a/.github/workflows/integration-test.yml
+++ b/.github/workflows/integration-test.yml
@@ -49,3 +49,32 @@ jobs:
with:
name: java-build-reports-realtime
path: java/build/reports/
+ check-rest-okhttp:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: 'recursive'
+
+ - name: Set up the JDK
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - run: ./gradlew :java:testRestSuite -Pokhttp
+
+ check-realtime-okhttp:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: 'recursive'
+
+ - name: Set up the JDK
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - run: ./gradlew :java:testRealtimeSuite -Pokhttp
diff --git a/README.md b/README.md
index 69ea2b260..449ec77ea 100644
--- a/README.md
+++ b/README.md
@@ -500,6 +500,67 @@ realtime.setAndroidContext(context);
realtime.push.activate();
```
+## Using Ably SDK Under a Proxy
+
+When working in environments where outbound internet access is restricted, such as behind a corporate proxy, the Ably SDK allows you to configure a proxy server for HTTP and WebSocket connections.
+
+### Add the Required Dependency
+
+You need to use **OkHttp** library for making HTTP calls and WebSocket connections in the Ably SDK to get proxy support both for your Rest and Realtime clients.
+
+Add the following dependency to your `build.gradle` file:
+
+```groovy
+dependencies {
+ runtimeOnly("io.ably:network-client-okhttp:1.2.43")
+}
+```
+
+### Configure Proxy Settings
+
+After adding the required OkHttp dependency, you need to configure the proxy settings for your Ably client. This can be done by setting the proxy options in the `ClientOptions` object when you instantiate the Ably SDK.
+
+Here’s an example of how to configure and use a proxy:
+
+#### Java Example
+
+```java
+import io.ably.lib.realtime.AblyRealtime;
+import io.ably.lib.rest.AblyRest;
+import io.ably.lib.transport.Defaults;
+import io.ably.lib.types.ClientOptions;
+import io.ably.lib.types.ProxyOptions;
+import io.ably.lib.http.HttpAuth;
+
+public class AblyWithProxy {
+ public static void main(String[] args) throws Exception {
+ // Configure Ably Client options
+ ClientOptions options = new ClientOptions();
+
+ // Setup proxy settings
+ ProxyOptions proxy = new ProxyOptions();
+ proxy.host = "your-proxy-host"; // Replace with your proxy host
+ proxy.port = 8080; // Replace with your proxy port
+
+ // Optional: If the proxy requires authentication
+ proxy.username = "your-username"; // Replace with proxy username
+ proxy.password = "your-password"; // Replace with proxy password
+ proxy.prefAuthType = HttpAuth.Type.BASIC; // Choose your preferred authentication type (e.g., BASIC or DIGEST)
+
+ // Attach the proxy settings to the client options
+ options.proxy = proxy;
+
+ // Create an instance of Ably using the configured options
+ AblyRest ably = new AblyRest(options);
+
+ // Alternatively, for real-time connections
+ AblyRealtime ablyRealtime = new AblyRealtime(options);
+
+ // Use the Ably client as usual
+ }
+}
+```
+
## Resources
Visit https://www.ably.com/docs for a complete API reference and more examples.
diff --git a/build.gradle.kts b/build.gradle.kts
index 9452386ca..c20fc7ead 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.maven.publish) apply false
alias(libs.plugins.lombok) apply false
+ alias(libs.plugins.test.retry) apply false
}
subprojects {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3545e89ea..542072c78 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,6 +17,8 @@ dexmaker = "1.4"
android-retrostreams = "1.7.4"
maven-publish = "0.29.0"
lombok = "8.10"
+okhttp = "4.12.0"
+test-retry = "1.6.0"
[libraries]
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
@@ -38,6 +40,7 @@ dexmaker = { group = "com.crittercism.dexmaker", name = "dexmaker", version.ref
dexmaker-dx = { group = "com.crittercism.dexmaker", name = "dexmaker-dx", version.ref = "dexmaker" }
dexmaker-mockito = { group = "com.crittercism.dexmaker", name = "dexmaker-mockito", version.ref = "dexmaker" }
android-retrostreams = { group = "net.sourceforge.streamsupport", name = "android-retrostreams", version.ref = "android-retrostreams" }
+okhttp = { group ="com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[bundles]
common = ["msgpack", "vcdiff-core"]
@@ -49,3 +52,4 @@ android-library = { id = "com.android.library", version.ref = "agp" }
build-config = { id = "com.github.gmazzo.buildconfig", version.ref = "build-config" }
maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" }
lombok = { id = "io.freefair.lombok", version.ref = "lombok" }
+test-retry = { id = "org.gradle.test-retry", version.ref = "test-retry" }
diff --git a/java/build.gradle.kts b/java/build.gradle.kts
index e537e6cfa..45b0c4e39 100644
--- a/java/build.gradle.kts
+++ b/java/build.gradle.kts
@@ -3,6 +3,7 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat
plugins {
alias(libs.plugins.build.config)
alias(libs.plugins.maven.publish)
+ alias(libs.plugins.test.retry)
checkstyle
`java-library`
}
@@ -20,7 +21,11 @@ dependencies {
api(libs.gson)
implementation(libs.bundles.common)
implementation(project(":network-client-core"))
- runtimeOnly(project(":network-client-default"))
+ if (findProperty("okhttp") == null) {
+ runtimeOnly(project(":network-client-default"))
+ } else {
+ runtimeOnly(project(":network-client-okhttp"))
+ }
testImplementation(libs.bundles.tests)
}
@@ -59,6 +64,12 @@ tasks.register("testRealtimeSuite") {
testLogging {
exceptionFormat = TestExceptionFormat.FULL
}
+ retry {
+ maxRetries.set(3)
+ maxFailures.set(8)
+ failOnPassedAfterRetry.set(false)
+ failOnSkippedAfterRetry.set(false)
+ }
}
tasks.register("testRestSuite") {
@@ -72,6 +83,12 @@ tasks.register("testRestSuite") {
testLogging {
exceptionFormat = TestExceptionFormat.FULL
}
+ retry {
+ maxRetries.set(3)
+ maxFailures.set(8)
+ failOnPassedAfterRetry.set(false)
+ failOnSkippedAfterRetry.set(false)
+ }
}
/*
diff --git a/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java b/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java
index cbcf58b5f..a44ee0194 100644
--- a/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java
+++ b/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java
@@ -1,12 +1,13 @@
package io.ably.lib.transport;
import io.ably.lib.http.HttpUtils;
+import io.ably.lib.network.EngineType;
+import io.ably.lib.network.NotConnectedException;
import io.ably.lib.network.WebSocketClient;
import io.ably.lib.network.WebSocketEngine;
import io.ably.lib.network.WebSocketEngineConfig;
import io.ably.lib.network.WebSocketEngineFactory;
import io.ably.lib.network.WebSocketListener;
-import io.ably.lib.network.NotConnectedException;
import io.ably.lib.types.AblyException;
import io.ably.lib.types.ErrorInfo;
import io.ably.lib.types.Param;
@@ -17,6 +18,8 @@
import javax.net.ssl.SSLContext;
import java.nio.ByteBuffer;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
import java.util.Timer;
import java.util.TimerTask;
@@ -48,16 +51,43 @@ public class WebSocketTransport implements ITransport {
private String wsUri;
private ConnectListener connectListener;
private WebSocketClient webSocketClient;
+ private final WebSocketEngine webSocketEngine;
+ private boolean activityCheckTurnedOff = false;
+
/******************
* protected constructor
******************/
-
protected WebSocketTransport(TransportParams params, ConnectionManager connectionManager) {
this.params = params;
this.connectionManager = connectionManager;
this.channelBinaryMode = params.options.useBinaryProtocol;
- /* We do not require Ably heartbeats, as we can use WebSocket pings instead. */
- params.heartbeats = false;
+ this.webSocketEngine = createWebSocketEngine(params);
+ params.heartbeats = !this.webSocketEngine.isPingListenerSupported();
+
+ }
+
+ private static WebSocketEngine createWebSocketEngine(TransportParams params) {
+ WebSocketEngineFactory engineFactory = WebSocketEngineFactory.getFirstAvailable();
+ Log.v(TAG, String.format("Using %s WebSocket Engine", engineFactory.getEngineType().name()));
+ WebSocketEngineConfig.WebSocketEngineConfigBuilder configBuilder = WebSocketEngineConfig.builder();
+ configBuilder
+ .tls(params.options.tls)
+ .host(params.host)
+ .proxy(ClientOptionsUtils.convertToProxyConfig(params.getClientOptions()));
+
+ // OkHttp supports modern TLS algorithms by default
+ if (params.options.tls && engineFactory.getEngineType() != EngineType.OKHTTP) {
+ try {
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, null, null);
+ SafeSSLSocketFactory factory = new SafeSSLSocketFactory(sslContext.getSocketFactory());
+ configBuilder.sslSocketFactory(factory);
+ } catch (NoSuchAlgorithmException | KeyManagementException e) {
+ throw new IllegalStateException("Can't get safe tls algorithms", e);
+ }
+ }
+
+ return engineFactory.create(configBuilder.build());
}
/******************
@@ -78,24 +108,7 @@ public void connect(ConnectListener connectListener) {
Log.d(TAG, "connect(); wsUri = " + wsUri);
synchronized (this) {
- WebSocketEngineFactory engineFactory = WebSocketEngineFactory.getFirstAvailable();
- Log.v(TAG, String.format("Using %s WebSocket Engine", engineFactory.getEngineType().name()));
-
- WebSocketEngineConfig.WebSocketEngineConfigBuilder configBuilder = WebSocketEngineConfig.builder();
- configBuilder
- .tls(isTls)
- .host(params.host)
- .proxy(ClientOptionsUtils.convertToProxyConfig(params.getClientOptions()));
-
- if (isTls) {
- SSLContext sslContext = SSLContext.getInstance("TLS");
- sslContext.init(null, null, null);
- SafeSSLSocketFactory factory = new SafeSSLSocketFactory(sslContext.getSocketFactory());
- configBuilder.sslSocketFactory(factory);
- }
-
- WebSocketEngine engine = engineFactory.create(configBuilder.build());
- webSocketClient = engine.create(wsUri, new WebSocketHandler(this::receive));
+ webSocketClient = this.webSocketEngine.create(wsUri, new WebSocketHandler(this::receive));
}
webSocketClient.connect();
} catch (AblyException e) {
@@ -161,6 +174,16 @@ protected void preProcessReceivedMessage(ProtocolMessage message) {
//Gives the chance to child classes to do message pre-processing
}
+ /**
+ * Visible For Testing
+ *
+ * We need to turn off activity check for some tests (e.g. io.ably.lib.test.realtime.RealtimeConnectFailTest.disconnect_retry_channel_timeout_jitter_after_consistent_detach[binary_protocol])
+ * Those tests expects that activity checks are passing, but protocol messages are not coming
+ */
+ protected void turnOffActivityCheckIfPingListenerIsNotSupported() {
+ if (!webSocketEngine.isPingListenerSupported()) activityCheckTurnedOff = true;
+ }
+
public String toString() {
return WebSocketTransport.class.getName() + " {" + getURL() + "}";
}
@@ -307,7 +330,7 @@ private synchronized void dispose() {
private synchronized void flagActivity() {
lastActivityTime = System.currentTimeMillis();
connectionManager.setLastActivity(lastActivityTime);
- if (activityTimerTask == null && connectionManager.maxIdleInterval != 0) {
+ if (activityTimerTask == null && connectionManager.maxIdleInterval != 0 && !activityCheckTurnedOff) {
/* No timer currently running because previously there was no
* maxIdleInterval configured, but now there is a
* maxIdleInterval configured. Call checkActivity so a timer
diff --git a/lib/src/test/java/io/ably/lib/test/util/MockWebsocketFactory.java b/lib/src/test/java/io/ably/lib/test/util/MockWebsocketFactory.java
index bbf3ba0d0..15a4cbfad 100644
--- a/lib/src/test/java/io/ably/lib/test/util/MockWebsocketFactory.java
+++ b/lib/src/test/java/io/ably/lib/test/util/MockWebsocketFactory.java
@@ -155,6 +155,7 @@ private MockWebsocketTransport(TransportParams givenTransportParams, TransportPa
super(transformedTransportParams, connectionManager);
this.givenTransportParams = givenTransportParams;
this.transformedTransportParams = transformedTransportParams;
+ turnOffActivityCheckIfPingListenerIsNotSupported();
}
public List getSentMessages() {
diff --git a/network-client-core/build.gradle.kts b/network-client-core/build.gradle.kts
index 9b3ba996a..f7bb62dd6 100644
--- a/network-client-core/build.gradle.kts
+++ b/network-client-core/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
`java-library`
alias(libs.plugins.lombok)
+ alias(libs.plugins.maven.publish)
}
java {
diff --git a/network-client-core/gradle.properties b/network-client-core/gradle.properties
new file mode 100644
index 000000000..f37ee24fe
--- /dev/null
+++ b/network-client-core/gradle.properties
@@ -0,0 +1,4 @@
+POM_ARTIFACT_ID=network-client-core
+POM_NAME=Core HTTP client abstraction
+POM_DESCRIPTION=Core HTTP client abstraction
+POM_PACKAGING=jar
diff --git a/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngine.java b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngine.java
index a4a236757..af29dce14 100644
--- a/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngine.java
+++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngine.java
@@ -5,4 +5,5 @@
*/
public interface WebSocketEngine {
WebSocketClient create(String url, WebSocketListener listener);
+ boolean isPingListenerSupported();
}
diff --git a/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineFactory.java b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineFactory.java
index ce22567b3..c5693d655 100644
--- a/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineFactory.java
+++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineFactory.java
@@ -19,7 +19,7 @@ static WebSocketEngineFactory getFirstAvailable() {
static WebSocketEngineFactory tryGetOkWebSocketFactory() {
try {
- Class> okWebSocketFactoryClass = Class.forName("io.ably.lib.network.OkWebSocketEngineFactory");
+ Class> okWebSocketFactoryClass = Class.forName("io.ably.lib.network.OkHttpWebSocketEngineFactory");
return (WebSocketEngineFactory) okWebSocketFactoryClass.getDeclaredConstructor().newInstance();
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException |
InvocationTargetException e) {
diff --git a/network-client-default/build.gradle.kts b/network-client-default/build.gradle.kts
index 4cf238353..9b19b174f 100644
--- a/network-client-default/build.gradle.kts
+++ b/network-client-default/build.gradle.kts
@@ -10,6 +10,6 @@ java {
}
dependencies {
- api(project(":network-client-core"))
+ implementation(project(":network-client-core"))
implementation(libs.java.websocket)
}
diff --git a/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngine.java b/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngine.java
index e8c5ae00e..a73f9f580 100644
--- a/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngine.java
+++ b/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngine.java
@@ -17,4 +17,9 @@ public WebSocketClient create(String url, WebSocketListener listener) {
}
return client;
}
+
+ @Override
+ public boolean isPingListenerSupported() {
+ return true;
+ }
}
diff --git a/network-client-okhttp/build.gradle.kts b/network-client-okhttp/build.gradle.kts
new file mode 100644
index 000000000..7e3118764
--- /dev/null
+++ b/network-client-okhttp/build.gradle.kts
@@ -0,0 +1,15 @@
+plugins {
+ `java-library`
+ alias(libs.plugins.lombok)
+ alias(libs.plugins.maven.publish)
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+}
+
+dependencies {
+ implementation(project(":network-client-core"))
+ implementation(libs.okhttp)
+}
diff --git a/network-client-okhttp/gradle.properties b/network-client-okhttp/gradle.properties
new file mode 100644
index 000000000..4b648381c
--- /dev/null
+++ b/network-client-okhttp/gradle.properties
@@ -0,0 +1,4 @@
+POM_ARTIFACT_ID=network-client-okhttp
+POM_NAME=Default HTTP client
+POM_DESCRIPTION=Default implementation for HTTP client
+POM_PACKAGING=jar
diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpCall.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpCall.java
new file mode 100644
index 000000000..643697391
--- /dev/null
+++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpCall.java
@@ -0,0 +1,45 @@
+package io.ably.lib.network;
+
+import okhttp3.Call;
+import okhttp3.Response;
+
+import java.io.IOException;
+import java.net.ConnectException;
+import java.net.NoRouteToHostException;
+import java.net.SocketTimeoutException;
+import java.net.UnknownHostException;
+
+public class OkHttpCall implements HttpCall {
+ private final Call call;
+
+ public OkHttpCall(Call call) {
+ this.call = call;
+ }
+
+ @Override
+ public HttpResponse execute() {
+ try (Response response = call.execute()) {
+ return HttpResponse.builder()
+ .headers(response.headers().toMultimap())
+ .code(response.code())
+ .message(response.message())
+ .body(
+ response.body() != null && response.body().contentType() != null
+ ? new HttpBody(response.body().contentType().toString(), response.body().bytes())
+ : null
+ )
+ .build();
+
+ } catch (ConnectException | SocketTimeoutException | UnknownHostException | NoRouteToHostException fce) {
+ throw new FailedConnectionException(fce);
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+
+ }
+
+ @Override
+ public void cancel() {
+ call.cancel();
+ }
+}
diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngine.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngine.java
new file mode 100644
index 000000000..50faa3610
--- /dev/null
+++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngine.java
@@ -0,0 +1,32 @@
+package io.ably.lib.network;
+
+import okhttp3.Call;
+import okhttp3.OkHttpClient;
+
+import java.util.concurrent.TimeUnit;
+
+public class OkHttpEngine implements HttpEngine {
+
+ private final OkHttpClient client;
+ private final HttpEngineConfig config;
+
+ public OkHttpEngine(OkHttpClient client, HttpEngineConfig config) {
+ this.client = client;
+ this.config = config;
+ }
+
+ @Override
+ public HttpCall call(HttpRequest request) {
+ Call call = client.newBuilder()
+ .connectTimeout(request.getHttpOpenTimeout(), TimeUnit.MILLISECONDS)
+ .readTimeout(request.getHttpReadTimeout(), TimeUnit.MILLISECONDS)
+ .build()
+ .newCall(OkHttpUtils.toOkhttpRequest(request));
+ return new OkHttpCall(call);
+ }
+
+ @Override
+ public boolean isUsingProxy() {
+ return config.getProxy() != null;
+ }
+}
diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngineFactory.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngineFactory.java
new file mode 100644
index 000000000..2cf65a9a8
--- /dev/null
+++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngineFactory.java
@@ -0,0 +1,17 @@
+package io.ably.lib.network;
+
+import okhttp3.OkHttpClient;
+
+public class OkHttpEngineFactory implements HttpEngineFactory {
+ @Override
+ public HttpEngine create(HttpEngineConfig config) {
+ OkHttpClient.Builder connectionBuilder = new OkHttpClient.Builder();
+ OkHttpUtils.injectProxySetting(config.getProxy(), connectionBuilder);
+ return new OkHttpEngine(connectionBuilder.build(), config);
+ }
+
+ @Override
+ public EngineType getEngineType() {
+ return EngineType.OKHTTP;
+ }
+}
diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpUtils.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpUtils.java
new file mode 100644
index 000000000..2bd566153
--- /dev/null
+++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpUtils.java
@@ -0,0 +1,51 @@
+package io.ably.lib.network;
+
+import okhttp3.Credentials;
+import okhttp3.Headers;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.util.List;
+import java.util.Map;
+
+public class OkHttpUtils {
+ public static void injectProxySetting(ProxyConfig proxyConfig, OkHttpClient.Builder connectionBuilder) {
+ if (proxyConfig == null) return;
+ connectionBuilder.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyConfig.getHost(), proxyConfig.getPort())));
+ if (proxyConfig.getUsername() == null || proxyConfig.getAuthType() != ProxyAuthType.BASIC) return;
+ String username = proxyConfig.getUsername();
+ String password = proxyConfig.getPassword();
+ connectionBuilder.proxyAuthenticator((route, response) -> {
+ String credential = Credentials.basic(username, password);
+ return response.request().newBuilder()
+ .header("Proxy-Authorization", credential)
+ .build();
+ });
+ }
+
+ public static Request toOkhttpRequest(HttpRequest request) {
+ Request.Builder builder = new Request.Builder()
+ .url(request.getUrl());
+
+ RequestBody body = null;
+
+ if (request.getBody() != null) {
+ body = RequestBody.create(request.getBody().getContent(), MediaType.parse(request.getBody().getContentType()));
+ }
+
+ builder.method(request.getMethod(), body);
+ for (Map.Entry> entry : request.getHeaders().entrySet()) {
+ String headerName = entry.getKey();
+ List values = entry.getValue();
+ for (String headerValue : values) {
+ builder.addHeader(headerName, headerValue);
+ }
+ }
+
+ return builder.build();
+ }
+}
diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketClient.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketClient.java
new file mode 100644
index 000000000..7341eb71a
--- /dev/null
+++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketClient.java
@@ -0,0 +1,87 @@
+package io.ably.lib.network;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.WebSocket;
+import okio.ByteString;
+
+import java.nio.ByteBuffer;
+
+public class OkHttpWebSocketClient implements WebSocketClient {
+ private final OkHttpClient connection;
+ private final Request request;
+ private final WebSocketListener listener;
+ private WebSocket webSocket;
+
+ public OkHttpWebSocketClient(OkHttpClient connection, Request request, WebSocketListener listener) {
+ this.connection = connection;
+ this.request = request;
+ this.listener = listener;
+ }
+
+ @Override
+ public void connect() {
+ webSocket = connection.newWebSocket(request, new WebSocketHandler(listener));
+ }
+
+ @Override
+ public void close() {
+ webSocket.close(1000, "Close");
+ }
+
+ @Override
+ public void close(int code, String reason) {
+ webSocket.close(code, reason);
+ }
+
+ @Override
+ public void cancel(int code, String reason) {
+ webSocket.cancel();
+ listener.onClose(code, reason);
+ }
+
+ @Override
+ public void send(byte[] bytes) {
+ webSocket.send(ByteString.of(bytes));
+ }
+
+ @Override
+ public void send(String message) {
+ webSocket.send(message);
+ }
+
+ private static class WebSocketHandler extends okhttp3.WebSocketListener {
+ private final WebSocketListener listener;
+
+ private WebSocketHandler(WebSocketListener listener) {
+ super();
+ this.listener = listener;
+ }
+
+ @Override
+ public void onClosed(WebSocket webSocket, int code, String reason) {
+ listener.onClose(code, reason);
+ }
+
+ @Override
+ public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+ listener.onError(t);
+ }
+
+ @Override
+ public void onMessage(WebSocket webSocket, String text) {
+ listener.onMessage(text);
+ }
+
+ @Override
+ public void onMessage(WebSocket webSocket, ByteString bytes) {
+ listener.onMessage(ByteBuffer.wrap(bytes.toByteArray()));
+ }
+
+ @Override
+ public void onOpen(WebSocket webSocket, Response response) {
+ listener.onOpen();
+ }
+ }
+}
diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngine.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngine.java
new file mode 100644
index 000000000..7715501ab
--- /dev/null
+++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngine.java
@@ -0,0 +1,32 @@
+package io.ably.lib.network;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+
+public class OkHttpWebSocketEngine implements WebSocketEngine {
+ private final WebSocketEngineConfig config;
+
+ public OkHttpWebSocketEngine(WebSocketEngineConfig config) {
+ this.config = config;
+ }
+
+ @Override
+ public WebSocketClient create(String url, WebSocketListener listener) {
+ OkHttpClient.Builder connectionBuilder = new OkHttpClient.Builder();
+
+ Request.Builder requestBuilder = new Request.Builder().url(url);
+
+ OkHttpUtils.injectProxySetting(config.getProxy(), connectionBuilder);
+
+ if (config.getSslSocketFactory() != null) {
+ connectionBuilder.sslSocketFactory(config.getSslSocketFactory());
+ }
+
+ return new OkHttpWebSocketClient(connectionBuilder.build(), requestBuilder.build(), listener);
+ }
+
+ @Override
+ public boolean isPingListenerSupported() {
+ return false;
+ }
+}
diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngineFactory.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngineFactory.java
new file mode 100644
index 000000000..24b7dcf20
--- /dev/null
+++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngineFactory.java
@@ -0,0 +1,13 @@
+package io.ably.lib.network;
+
+public class OkHttpWebSocketEngineFactory implements WebSocketEngineFactory {
+ @Override
+ public WebSocketEngine create(WebSocketEngineConfig config) {
+ return new OkHttpWebSocketEngine(config);
+ }
+
+ @Override
+ public EngineType getEngineType() {
+ return EngineType.OKHTTP;
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e905e3922..136b798ca 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -13,3 +13,4 @@ include("android")
include("gradle-lint")
include("network-client-core")
include("network-client-default")
+include("network-client-okhttp")