diff --git a/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningTurboModule.java b/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningTurboModule.java index 77a0ebc2148..bee79a03108 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningTurboModule.java +++ b/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningTurboModule.java @@ -30,11 +30,11 @@ import android.security.KeyChainAliasCallback; import java.util.Arrays; +import java.util.Objects; import java.util.concurrent.TimeUnit; import com.reactnativecommunity.webview.RNCWebViewManager; import expo.modules.filesystem.legacy.FileSystemLegacyModule; -import chat.rocket.reactnative.networking.ExpoImageClient; public class SSLPinningTurboModule extends NativeSSLPinningSpec implements KeyChainAliasCallback { @@ -42,28 +42,39 @@ public class SSLPinningTurboModule extends NativeSSLPinningSpec implements KeyCh private static String alias; private static ReactApplicationContext reactContext; private static OkHttpClient sharedClient; + private static String sharedClientAlias; - public static OkHttpClient getSharedOkHttpClient() { - if (sharedClient != null) { + public static synchronized OkHttpClient getSharedOkHttpClient() { + if (sharedClient != null && Objects.equals(sharedClientAlias, alias)) { return sharedClient; } - if (alias != null) { - OkHttpClient.Builder builder = new OkHttpClient.Builder() - .connectTimeout(0, TimeUnit.MILLISECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) - .writeTimeout(0, TimeUnit.MILLISECONDS) - .cookieJar(new ReactCookieJarContainer()); + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .connectTimeout(0, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) + .cookieJar(new ReactCookieJarContainer()) + .addInterceptor(chain -> { + okhttp3.Request original = chain.request(); + if (original.header("User-Agent") != null) { + return chain.proceed(original); + } + okhttp3.Request request = original.newBuilder() + .header("User-Agent", UserAgent.get()) + .build(); + return chain.proceed(request); + }); + if (alias != null) { SSLSocketFactory sslSocketFactory = getSSLFactory(alias); X509TrustManager trustManager = getTrustManagerFactory(); if (sslSocketFactory != null) { builder.sslSocketFactory(sslSocketFactory, trustManager); } - - sharedClient = builder.build(); - return sharedClient; } - return null; + + sharedClient = builder.build(); + sharedClientAlias = alias; + return sharedClient; } public SSLPinningTurboModule(ReactApplicationContext reactContext) { @@ -85,25 +96,7 @@ public void apply(OkHttpClient.Builder builder) { } protected OkHttpClient getOkHttpClient() { - OkHttpClient shared = getSharedOkHttpClient(); - if (shared != null) { - return shared; - } - OkHttpClient.Builder builder = new OkHttpClient.Builder() - .connectTimeout(0, TimeUnit.MILLISECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) - .writeTimeout(0, TimeUnit.MILLISECONDS) - .cookieJar(new ReactCookieJarContainer()); - - if (alias != null) { - SSLSocketFactory sslSocketFactory = getSSLFactory(alias); - X509TrustManager trustManager = getTrustManagerFactory(); - if (sslSocketFactory != null) { - builder.sslSocketFactory(sslSocketFactory, trustManager); - } - } - - return builder.build(); + return getSharedOkHttpClient(); } @Override diff --git a/android/app/src/main/java/chat/rocket/reactnative/networking/UserAgent.java b/android/app/src/main/java/chat/rocket/reactnative/networking/UserAgent.java new file mode 100644 index 00000000000..e768c1f01a6 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/networking/UserAgent.java @@ -0,0 +1,15 @@ +package chat.rocket.reactnative.networking; + +import chat.rocket.reactnative.BuildConfig; + +/** + * Shared User-Agent for native Android HTTP requests. + */ +public class UserAgent { + public static String get() { + String systemVersion = android.os.Build.VERSION.RELEASE; + String appVersion = BuildConfig.VERSION_NAME; + int buildNumber = BuildConfig.VERSION_CODE; + return String.format("RC Mobile; android %s; v%s (%d)", systemVersion, appVersion, buildNumber); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java index 8ef0e20e763..9763f5f56d8 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java @@ -17,6 +17,7 @@ import java.util.concurrent.TimeoutException; import chat.rocket.reactnative.BuildConfig; +import chat.rocket.reactnative.networking.UserAgent; /** * Shared utility methods for notification handling @@ -46,10 +47,7 @@ public static String sanitizeUrl(String url) { * @return User-Agent string */ public static String getUserAgent() { - String systemVersion = android.os.Build.VERSION.RELEASE; - String appVersion = BuildConfig.VERSION_NAME; - int buildNumber = BuildConfig.VERSION_CODE; - return String.format("RC Mobile; android %s; v%s (%d)", systemVersion, appVersion, buildNumber); + return UserAgent.get(); } /** @@ -109,4 +107,3 @@ public static Bitmap fetchAvatarBitmap( } } } - diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt index 62115327773..0c77893aaae 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt @@ -20,7 +20,8 @@ class DDPClient { companion object { private const val TAG = "RocketChat.DDPClient" private val sharedClient: OkHttpClient by lazy { - SSLPinningTurboModule.getSharedOkHttpClient() ?: OkHttpClient.Builder() + SSLPinningTurboModule.getSharedOkHttpClient() + .newBuilder() .pingInterval(30, TimeUnit.SECONDS) .build() } @@ -349,4 +350,8 @@ class DDPClient { internal fun testSetActiveWebSocket(ws: WebSocket?) { this.webSocket = ws } + + internal fun testPingIntervalMillis(): Int { + return client.pingIntervalMillis + } } diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequest.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequest.kt index 4e7167030b5..92f7279ce90 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequest.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequest.kt @@ -43,7 +43,7 @@ class MediaCallsAnswerRequest( private const val TAG = "RocketChat.MediaCallsAnswerRequest" private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() private val httpClient: OkHttpClient by lazy { - val base = SSLPinningTurboModule.getSharedOkHttpClient() ?: OkHttpClient() + val base = SSLPinningTurboModule.getSharedOkHttpClient() base.newBuilder() .callTimeout(10, TimeUnit.SECONDS) .connectTimeout(5, TimeUnit.SECONDS) diff --git a/android/app/src/test/java/chat/rocket/reactnative/networking/SSLPinningTurboModuleTest.kt b/android/app/src/test/java/chat/rocket/reactnative/networking/SSLPinningTurboModuleTest.kt new file mode 100644 index 00000000000..40e0b426e44 --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/networking/SSLPinningTurboModuleTest.kt @@ -0,0 +1,69 @@ +package chat.rocket.reactnative.networking + +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class SSLPinningTurboModuleTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `shared OkHttp client injects RC Mobile User-Agent when absent`() { + server.enqueue(MockResponse().setBody("{}")) + + val client = SSLPinningTurboModule.getSharedOkHttpClient() + val request = Request.Builder() + .url(server.url("/api/v1/test")) + .build() + + client.newCall(request).execute().use { response -> + assertEquals(200, response.code) + } + + val userAgent = server.takeRequest().getHeader("User-Agent") + assertNotNull("User-Agent header must be present", userAgent) + assertTrue( + "User-Agent must start with 'RC Mobile' (got: $userAgent)", + userAgent!!.startsWith("RC Mobile") + ) + } + + @Test + fun `shared OkHttp client preserves explicit User-Agent`() { + server.enqueue(MockResponse().setBody("{}")) + + val client = SSLPinningTurboModule.getSharedOkHttpClient() + val request = Request.Builder() + .url(server.url("/api/v1/test")) + .header("User-Agent", "Custom UA") + .build() + + client.newCall(request).execute().use { response -> + assertEquals(200, response.code) + } + + assertEquals("Custom UA", server.takeRequest().getHeader("User-Agent")) + } +} diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/DDPClientTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/DDPClientTest.kt index c8cd454b081..41034ab7cf7 100644 --- a/android/app/src/test/java/chat/rocket/reactnative/voip/DDPClientTest.kt +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/DDPClientTest.kt @@ -28,6 +28,13 @@ private class StubWebSocket : WebSocket { @Config(sdk = [28]) class DDPClientTest { + @Test + fun `shared client preserves websocket ping interval`() { + val client = DDPClient() + + assertEquals(TimeUnit.SECONDS.toMillis(30).toInt(), client.testPingIntervalMillis()) + } + @Test fun `connected before connect timeout invokes callback exactly once`() { val client = DDPClient()