From ce57613b1312e6678ae410bcebb0cab43129ef1a Mon Sep 17 00:00:00 2001 From: Mario Zorz Date: Sun, 21 Jun 2026 20:37:53 +0200 Subject: [PATCH] Replace AndroidAsync WebSocket transport with OkHttp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AndroidAsync is unmaintained (last release ~2022) and the source of intermittent transport crashes (e.g. the 'double connect callback' AssertionError during the SSL handshake). Swap the realtime WebSocket to OkHttp, which is actively maintained and already shipped by most host apps. - Add OkHttpWebSocketProvider implementing WebSocketManager.ConnectionProvider, mapping Connection/ConnectionListener onto OkHttp's WebSocketListener. - Route a post-open onFailure through onDisconnect (OkHttp's terminal failure has no onClosed) so channels are notified, the heartbeat is cancelled, and a reconnect is scheduled — matching AndroidAsync. - Sanitize the session id sent as User-Agent (OkHttp validates header values strictly; AndroidAsync did not). - Wire AndroidClient.buildChannelProvider to the new provider; keep AsyncWebSocketProvider in the tree for now. Note: cert pinning is not yet ported to the OkHttp client (see TODO); must be addressed before merge. --- Simperium/build.gradle | 2 + .../com/simperium/android/AndroidClient.java | 7 +- .../android/OkHttpWebSocketProvider.java | 122 ++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 Simperium/src/main/java/com/simperium/android/OkHttpWebSocketProvider.java diff --git a/Simperium/build.gradle b/Simperium/build.gradle index 2fd7421..522723a 100644 --- a/Simperium/build.gradle +++ b/Simperium/build.gradle @@ -35,6 +35,8 @@ android { implementation 'com.google.code.gson:gson:2.8.5' implementation 'com.koushikdutta.async:androidasync:3.1.0' implementation 'org.thoughtcrime.ssl.pinning:AndroidPinning:1.0.0' + // Realtime WebSocket transport (replaces AndroidAsync for the socket). + implementation 'com.squareup.okhttp3:okhttp:4.12.0' } useLibrary 'android.test.base' diff --git a/Simperium/src/main/java/com/simperium/android/AndroidClient.java b/Simperium/src/main/java/com/simperium/android/AndroidClient.java index 5180db0..3bd5c61 100644 --- a/Simperium/src/main/java/com/simperium/android/AndroidClient.java +++ b/Simperium/src/main/java/com/simperium/android/AndroidClient.java @@ -6,6 +6,8 @@ import com.koushikdutta.async.http.AsyncHttpClient; +import okhttp3.OkHttpClient; + import org.thoughtcrime.ssl.pinning.PinningTrustManager; import org.thoughtcrime.ssl.pinning.SystemKeyStore; @@ -54,6 +56,9 @@ public class AndroidClient implements ClientFactory { protected ExecutorService mExecutor; protected AsyncHttpClient mHttpClient = AsyncHttpClient.getDefaultInstance(); + // Realtime WebSocket transport. TODO before merge: replicate the cert pinning that + // configureSSL() applies to mHttpClient (sslSocketFactory + X509TrustManager / CertificatePinner). + protected OkHttpClient mOkHttpClient = new OkHttpClient(); public AndroidClient(Context context){ int threads = Runtime.getRuntime().availableProcessors(); @@ -164,7 +169,7 @@ public AsyncAuthClient buildAuthProvider(String appId, String appSecret){ @Override public WebSocketManager buildChannelProvider(String appId){ // Simperium Bucket API - WebSocketManager.ConnectionProvider provider = new AsyncWebSocketProvider(appId, mSessionId, mHttpClient); + WebSocketManager.ConnectionProvider provider = new OkHttpWebSocketProvider(appId, mSessionId, mOkHttpClient); return new WebSocketManager(mExecutor, appId, mSessionId, new QueueSerializer(mDatabase), provider, mContext); } diff --git a/Simperium/src/main/java/com/simperium/android/OkHttpWebSocketProvider.java b/Simperium/src/main/java/com/simperium/android/OkHttpWebSocketProvider.java new file mode 100644 index 0000000..fe2af86 --- /dev/null +++ b/Simperium/src/main/java/com/simperium/android/OkHttpWebSocketProvider.java @@ -0,0 +1,122 @@ +package com.simperium.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; + +/** + * WebSocket transport backed by OkHttp, replacing {@link AsyncWebSocketProvider} (AndroidAsync). + * + * AndroidAsync is unmaintained (last release ~2022) and the source of intermittent transport + * crashes (e.g. the "double connect callback" AssertionError during the SSL handshake). OkHttp is + * actively maintained, already shipped by most host apps, and delivers each text message fully + * reassembled to {@link WebSocketListener#onMessage(WebSocket, String)}. + * + * OkHttp's WebSocket is thread-safe — send()/close() enqueue internally — so, unlike the + * AndroidAsync provider, we don't hop onto the main looper. Listener callbacks arrive on OkHttp's + * dispatcher thread, which is what {@link WebSocketManager} already expects. + */ +class OkHttpWebSocketProvider implements WebSocketManager.ConnectionProvider { + + // Normal closure (RFC 6455). + private static final int CLOSE_NORMAL = 1000; + + private final OkHttpClient mClient; + private final String mAppId; + private final String mSessionId; + + OkHttpWebSocketProvider(String appId, String sessionId, OkHttpClient client) { + mAppId = appId; + mSessionId = sessionId; + mClient = client; + } + + @Override + public void connect(final WebSocketManager.ConnectionListener listener) { + try { + String url = String.format(AndroidClient.WEBSOCKET_URL, mAppId); + Request request = new Request.Builder() + .url(url) + // OkHttp rejects header values with non-printable / non-ASCII characters (where + // AndroidAsync did not). The session id is sent as User-Agent, so sanitize it. + .header(AndroidClient.USER_AGENT_HEADER, sanitizeHeaderValue(mSessionId)) + .build(); + + // Whether the upgrade succeeded — used to route onFailure correctly (see below). + final AtomicBoolean opened = new AtomicBoolean(false); + + mClient.newWebSocket(request, new WebSocketListener() { + @Override + public void onOpen(@NonNull WebSocket webSocket, @NonNull Response response) { + opened.set(true); + listener.onConnect(new WebSocketManager.Connection() { + @Override + public void close() { + webSocket.close(CLOSE_NORMAL, null); + } + + @Override + public void send(String message) { + webSocket.send(message); + } + }); + } + + @Override + public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) { + listener.onMessage(text); + } + + @Override + public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String reason) { + webSocket.close(CLOSE_NORMAL, null); + } + + @Override + public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String reason) { + listener.onDisconnect(null); + } + + @Override + public void onFailure(@NonNull WebSocket webSocket, @NonNull Throwable t, @Nullable Response response) { + Exception ex = t instanceof Exception ? (Exception) t : new IOException(t); + // After a successful upgrade, onFailure is OkHttp's terminal disconnect (no + // onClosed follows). Route it through onDisconnect so the manager notifies + // channels, cancels the heartbeat, and schedules a reconnect — matching the + // AndroidAsync end/closed-callback behavior. A pre-open failure is a genuine + // connect error. + if (opened.get()) { + listener.onDisconnect(ex); + } else { + listener.onError(ex); + } + } + }); + } catch (Throwable t) { + listener.onError(t instanceof Exception ? (Exception) t : new IOException(t)); + } + } + + /** Strip characters OkHttp won't allow in a header value (keep printable ASCII + tab). */ + private static String sanitizeHeaderValue(String value) { + if (value == null) { + return ""; + } + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\t' || (c >= ' ' && c <= '~')) { + sb.append(c); + } + } + return sb.toString(); + } +}