diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt index 7ed8f28cbb71..b868109e113f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt @@ -10,8 +10,13 @@ package com.facebook.react.modules.network +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager import android.net.Uri import android.util.Base64 +import androidx.annotation.VisibleForTesting import com.facebook.common.logging.FLog import com.facebook.fbreact.specs.NativeNetworkingAndroidSpec import com.facebook.react.bridge.ReactApplicationContext @@ -114,13 +119,18 @@ public class NetworkingModule( } else { null } - this.defaultUserAgent = defaultUserAgent + val resolvedUserAgent = + defaultUserAgent ?: createDefaultUserAgent(reactContext.applicationContext ?: reactContext) + this.defaultUserAgent = resolvedUserAgent } /** * @param context the ReactContext of the application * @param defaultUserAgent the User-Agent header that will be set for all requests where the - * caller does not provide one explicitly + * caller (JS) does not provide one explicitly. When `null`, a default is derived from the + * application's label and version name (`AppName/versionName`). If neither the label nor the + * package name can be resolved (e.g. no PackageManager), no default User-Agent is applied and + * requests are sent without one unless JS supplies the header. * @param client the [OkHttpClient] to be used for networking */ internal constructor( @@ -152,7 +162,9 @@ public class NetworkingModule( /** * @param context the ReactContext of the application * @param defaultUserAgent the User-Agent header that will be set for all requests where the - * caller does not provide one explicitly + * caller (JS) does not provide one explicitly. When `null`, a default is derived from the + * application's label and version name; see the [NetworkingModule] constructor taking a + * `client` for the full null/fallback behavior. */ public constructor( context: ReactApplicationContext, @@ -1046,6 +1058,89 @@ public class NetworkingModule( return headersBuilder.build() } + /** + * Visible for testing so the null / fallback branches can be exercised without a real + * PackageManager. + */ + @VisibleForTesting internal fun getDefaultUserAgentForTest(): String? = defaultUserAgent + + private fun createDefaultUserAgent(context: Context): String? { + val pm = context.packageManager ?: return null + val packageName = context.packageName ?: return null + val packageInfo = + try { + pm.getPackageInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + null + } + val appInfo = + try { + pm.getApplicationInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + null + } + return createDefaultUserAgentInternal(pm, packageName, packageInfo, appInfo) + } + + @VisibleForTesting + internal fun createDefaultUserAgentInternal( + pm: PackageManager?, + packageName: String?, + packageInfo: PackageInfo?, + appInfo: ApplicationInfo?, + ): String? { + if (pm == null || packageName == null) return null + // The application label is human-facing and may contain whitespace, punctuation, or + // non-ASCII characters (localized names, emoji, symbols like the (c) sign). OkHttp's + // Headers.Builder.add() rejects any value outside \u0020..\u007E, so an unsanitized label + // would throw on every request, and spaces/invalid chars would violate the RFC 7231 + // User-Agent product-token syntax. Reduce the label to token-safe characters; if nothing + // usable remains, fall back to the package name, which is always a valid token. + val label = appInfo?.let { pm.getApplicationLabel(it)?.toString() } + val appName = sanitizeToUserAgentToken(label).ifEmpty { sanitizeToUserAgentToken(packageName) } + if (appName.isEmpty()) return null + val version = + packageInfo?.versionName?.let { sanitizeToUserAgentToken(it) }?.takeIf { it.isNotEmpty() } + return if (version != null) "$appName/$version" else appName + } + + /** + * Reduces [value] to characters allowed in an RFC 7231 `token` (ASCII letters and digits plus + * `!#$%&'*+-.^_\`|~`). All other characters - whitespace, punctuation outside that set, and any + * non-ASCII character - are dropped, so the result is always a well-formed User-Agent product + * token that OkHttp will accept without throwing. + */ + private fun sanitizeToUserAgentToken(value: String?): String { + if (value.isNullOrEmpty()) return "" + val sb = StringBuilder(value.length) + for (c in value) { + if (isUserAgentTokenChar(c)) { + sb.append(c) + } + } + return sb.toString() + } + + private fun isUserAgentTokenChar(c: Char): Boolean = + c in 'a'..'z' || + c in 'A'..'Z' || + c in '0'..'9' || + c == '!' || + c == '#' || + c == '\u0024' || + c == '%' || + c == '&' || + c == '\'' || + c == '*' || + c == '+' || + c == '-' || + c == '.' || + c == '^' || + c == '_' || + c == '`' || + c == '|' || + c == '~' + public companion object { public const val NAME: String = NativeNetworkingAndroidSpec.NAME private const val TAG: String = NativeNetworkingAndroidSpec.NAME diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.kt index cc625b1e6f78..0f60284d4a80 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.kt @@ -654,6 +654,174 @@ class NetworkingModuleTest { assertThat(completionArgs.getInt(0)).isEqualTo(1) assertThat(completionArgs.isNull(1)).isTrue() } + + // ----- User-Agent parity (issue 284) ----- + + @Test + fun testDefaultUserAgentNullFallbackUsesPackageName() { + // Pass null defaultUserAgent with a mock context that has no PackageManager details. + // The fallback should not crash and should synthesise a value or return null gracefully. + val mockPm = mock() + val appInfo = android.content.pm.ApplicationInfo() + whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("MyApp") + // Internal helper directly — null packageInfo => appName only + val dummyModule = NetworkingModule(context, "__test__", httpClient, null) + val uaNoVersion = + dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", null, appInfo) + assertThat(uaNoVersion).isEqualTo("MyApp") + + // With version available => AppName/Version + val pkgInfo = android.content.pm.PackageInfo() + pkgInfo.versionName = "1.2.3" + val uaWithVersion = + dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo) + assertThat(uaWithVersion).isEqualTo("MyApp/1.2.3") + } + + @Test + fun testDefaultUserAgentNullPackageManagerReturnsNull() { + val dummyModule = NetworkingModule(context, "__test__", httpClient, null) + assertThat(dummyModule.createDefaultUserAgentInternal(null, "com.example", null, null)).isNull() + assertThat(dummyModule.createDefaultUserAgentInternal(mock(), null, null, null)).isNull() + } + + @Test + fun testBlankAppLabelFallsBackToPackageNameNotVersionOnly() { + // A blank application label must not produce a version-only User-Agent (e.g. "1.2.3"). + // Fall back to the package name so the UA always carries a product identifier. + val mockPm = mock() + val appInfo = android.content.pm.ApplicationInfo() + whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("") + val pkgInfo = android.content.pm.PackageInfo() + pkgInfo.versionName = "1.2.3" + val dummyModule = NetworkingModule(context, "__test__", httpClient, null) + assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo)) + .isEqualTo("com.example/1.2.3") + } + + @Test + fun testUserAgentLabelWhitespaceIsSanitized() { + // Spaces (and other non-token chars) in the human-readable label would violate the RFC 7231 + // product-token syntax; they must be stripped so the UA is well-formed. + val mockPm = mock() + val appInfo = android.content.pm.ApplicationInfo() + whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("My Cool App") + val pkgInfo = android.content.pm.PackageInfo() + pkgInfo.versionName = "1.2.3" + val dummyModule = NetworkingModule(context, "__test__", httpClient, null) + assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo)) + .isEqualTo("MyCoolApp/1.2.3") + } + + @Test + fun testUserAgentNonAsciiCharsStrippedFromLabel() { + // Non-ASCII characters (accents, symbols like (c)/(R), emoji) would make OkHttp's Headers.add() + // throw on every request; they must be dropped, leaving the ASCII remainder. + val mockPm = mock() + val appInfo = android.content.pm.ApplicationInfo() + whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("Caf\u00e9\u00ae") + val pkgInfo = android.content.pm.PackageInfo() + pkgInfo.versionName = "2.0" + val dummyModule = NetworkingModule(context, "__test__", httpClient, null) + assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo)) + .isEqualTo("Caf/2.0") + } + + @Test + fun testUserAgentAllNonAsciiLabelFallsBackToPackageName() { + // A label with no token-safe characters at all must fall back to the package name rather than + // producing an empty product token. + val mockPm = mock() + val appInfo = android.content.pm.ApplicationInfo() + whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("\u65e5\u672c\u8a9e\u30a2\u30d7\u30ea") + val pkgInfo = android.content.pm.PackageInfo() + pkgInfo.versionName = "1.2.3" + val dummyModule = NetworkingModule(context, "__test__", httpClient, null) + assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo)) + .isEqualTo("com.example/1.2.3") + } + + @Test + fun testUserAgentVersionNameIsSanitized() { + // versionName is developer-controlled and may contain spaces/parentheses/non-ASCII; sanitize it + // to a token too. If nothing usable remains, the UA is just the app name. + val mockPm = mock() + val appInfo = android.content.pm.ApplicationInfo() + whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("MyApp") + val pkgInfo = android.content.pm.PackageInfo() + pkgInfo.versionName = "1.0 (\u03b2)" + val dummyModule = NetworkingModule(context, "__test__", httpClient, null) + assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo)) + .isEqualTo("MyApp/1.0") + } + + @Test + fun testNullDefaultUserAgentStillSendsHeadersGracefully() { + // When PackageManager lookups fail, no User-Agent is injected — request still succeeds + // with whatever headers JS supplied (empty here). + val nullUaContext = mock() + whenever(nullUaContext.hasActiveReactInstance()).thenReturn(true) + whenever(nullUaContext.applicationContext).thenReturn(null) + whenever(nullUaContext.packageName).thenReturn("com.nonexistent") + whenever(nullUaContext.packageManager).thenReturn(null) + // applicationContext is null so createDefaultUserAgent receives the ReactApplicationContext + // itself which has null packageManager => defaultUserAgent == null + val moduleWithNullUa = NetworkingModule(nullUaContext, null, httpClient, null) + assertThat(moduleWithNullUa.getDefaultUserAgentForTest()).isNull() + + moduleWithNullUa.sendRequest( + "GET", + "http://somedomain/foo", + 0.0, + JavaOnlyArray.of(), + null, + "text", + true, + 0.0, + false, + ) + with(requestArgumentCaptor) { + verify(httpClient).newCall(capture()) + // No User-Agent injected when fallback returns null + assertThat(firstValue.headers().size()).isEqualTo(0) + } + } + + @Test + fun testDefaultUserAgentInjectedIntoRequest() { + // End-to-end: with a null supplied UA and a context that exposes an app label + version, + // the constructor fallback must synthesise "AppName/Version" AND that value must be injected + // into the outgoing request's User-Agent header. This is the actual issue #284 behaviour. + val ctx = mock() + whenever(ctx.hasActiveReactInstance()).thenReturn(true) + whenever(ctx.applicationContext).thenReturn(null) // fall back to ctx itself + whenever(ctx.packageName).thenReturn("com.example") + val pm = mock() + val appInfo = android.content.pm.ApplicationInfo() + val pkgInfo = android.content.pm.PackageInfo().apply { versionName = "1.2.3" } + whenever(ctx.packageManager).thenReturn(pm) + whenever(pm.getPackageInfo("com.example", 0)).thenReturn(pkgInfo) + whenever(pm.getApplicationInfo("com.example", 0)).thenReturn(appInfo) + whenever(pm.getApplicationLabel(appInfo)).thenReturn("MyApp") + + val module = NetworkingModule(ctx, null, httpClient, null) // null => fallback runs + module.sendRequest( + "GET", + "http://somedomain/foo", + 0.0, + JavaOnlyArray.of(), + null, + "text", + true, + 0.0, + false, + ) + + with(requestArgumentCaptor) { + verify(httpClient).newCall(capture()) + assertThat(firstValue.header("User-Agent")).isEqualTo("MyApp/1.2.3") + } + } } private val FORM = MediaType.get("multipart/form-data")