Skip to content
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 @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<android.content.pm.PackageManager>()
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<android.content.pm.PackageManager>()
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<android.content.pm.PackageManager>()
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<android.content.pm.PackageManager>()
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<android.content.pm.PackageManager>()
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<android.content.pm.PackageManager>()
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<ReactApplicationContext>()
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<ReactApplicationContext>()
whenever(ctx.hasActiveReactInstance()).thenReturn(true)
whenever(ctx.applicationContext).thenReturn(null) // fall back to ctx itself
whenever(ctx.packageName).thenReturn("com.example")
val pm = mock<android.content.pm.PackageManager>()
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")
Loading