Skip to content

Commit 04e50d5

Browse files
Calvin Liumeta-codesync[bot]
authored andcommitted
Android: default User-Agent includes app name and version (#58147)
Summary: Pull Request resolved: #58147 On Android, React Native's Networking module fell back to OkHttp's default User-Agent (okhttp/<version>) when no User-Agent was supplied, so requests omitted the app name and version. iOS already includes this automatically via the system networking stack. This synthesizes a default User-Agent of "AppName/Version" (or just "AppName" when no versionName is available) from the app's PackageManager, applied only when no User-Agent is otherwise set. JS-supplied User-Agent headers still take precedence. Resolves react-native-community/discussions-and-proposals#284 Changelog: [Android][Added] - Send app name and version as the default `User-Agent` header for network requests, matching iOS Reviewed By: cortinico Differential Revision: D116705471 fbshipit-source-id: 91f919912fd30d11579f196a5260dc418f12c57b
1 parent 6f03ca5 commit 04e50d5

2 files changed

Lines changed: 266 additions & 3 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010

1111
package com.facebook.react.modules.network
1212

13+
import android.content.Context
14+
import android.content.pm.ApplicationInfo
15+
import android.content.pm.PackageInfo
16+
import android.content.pm.PackageManager
1317
import android.net.Uri
1418
import android.util.Base64
19+
import androidx.annotation.VisibleForTesting
1520
import com.facebook.common.logging.FLog
1621
import com.facebook.fbreact.specs.NativeNetworkingAndroidSpec
1722
import com.facebook.react.bridge.ReactApplicationContext
@@ -114,13 +119,18 @@ public class NetworkingModule(
114119
} else {
115120
null
116121
}
117-
this.defaultUserAgent = defaultUserAgent
122+
val resolvedUserAgent =
123+
defaultUserAgent ?: createDefaultUserAgent(reactContext.applicationContext ?: reactContext)
124+
this.defaultUserAgent = resolvedUserAgent
118125
}
119126

120127
/**
121128
* @param context the ReactContext of the application
122129
* @param defaultUserAgent the User-Agent header that will be set for all requests where the
123-
* caller does not provide one explicitly
130+
* caller (JS) does not provide one explicitly. When `null`, a default is derived from the
131+
* application's label and version name (`AppName/versionName`). If neither the label nor the
132+
* package name can be resolved (e.g. no PackageManager), no default User-Agent is applied and
133+
* requests are sent without one unless JS supplies the header.
124134
* @param client the [OkHttpClient] to be used for networking
125135
*/
126136
internal constructor(
@@ -152,7 +162,9 @@ public class NetworkingModule(
152162
/**
153163
* @param context the ReactContext of the application
154164
* @param defaultUserAgent the User-Agent header that will be set for all requests where the
155-
* caller does not provide one explicitly
165+
* caller (JS) does not provide one explicitly. When `null`, a default is derived from the
166+
* application's label and version name; see the [NetworkingModule] constructor taking a
167+
* `client` for the full null/fallback behavior.
156168
*/
157169
public constructor(
158170
context: ReactApplicationContext,
@@ -1046,6 +1058,89 @@ public class NetworkingModule(
10461058
return headersBuilder.build()
10471059
}
10481060

1061+
/**
1062+
* Visible for testing so the null / fallback branches can be exercised without a real
1063+
* PackageManager.
1064+
*/
1065+
@VisibleForTesting internal fun getDefaultUserAgentForTest(): String? = defaultUserAgent
1066+
1067+
private fun createDefaultUserAgent(context: Context): String? {
1068+
val pm = context.packageManager ?: return null
1069+
val packageName = context.packageName ?: return null
1070+
val packageInfo =
1071+
try {
1072+
pm.getPackageInfo(packageName, 0)
1073+
} catch (e: PackageManager.NameNotFoundException) {
1074+
null
1075+
}
1076+
val appInfo =
1077+
try {
1078+
pm.getApplicationInfo(packageName, 0)
1079+
} catch (e: PackageManager.NameNotFoundException) {
1080+
null
1081+
}
1082+
return createDefaultUserAgentInternal(pm, packageName, packageInfo, appInfo)
1083+
}
1084+
1085+
@VisibleForTesting
1086+
internal fun createDefaultUserAgentInternal(
1087+
pm: PackageManager?,
1088+
packageName: String?,
1089+
packageInfo: PackageInfo?,
1090+
appInfo: ApplicationInfo?,
1091+
): String? {
1092+
if (pm == null || packageName == null) return null
1093+
// The application label is human-facing and may contain whitespace, punctuation, or
1094+
// non-ASCII characters (localized names, emoji, symbols like the (c) sign). OkHttp's
1095+
// Headers.Builder.add() rejects any value outside \u0020..\u007E, so an unsanitized label
1096+
// would throw on every request, and spaces/invalid chars would violate the RFC 7231
1097+
// User-Agent product-token syntax. Reduce the label to token-safe characters; if nothing
1098+
// usable remains, fall back to the package name, which is always a valid token.
1099+
val label = appInfo?.let { pm.getApplicationLabel(it)?.toString() }
1100+
val appName = sanitizeToUserAgentToken(label).ifEmpty { sanitizeToUserAgentToken(packageName) }
1101+
if (appName.isEmpty()) return null
1102+
val version =
1103+
packageInfo?.versionName?.let { sanitizeToUserAgentToken(it) }?.takeIf { it.isNotEmpty() }
1104+
return if (version != null) "$appName/$version" else appName
1105+
}
1106+
1107+
/**
1108+
* Reduces [value] to characters allowed in an RFC 7231 `token` (ASCII letters and digits plus
1109+
* `!#$%&'*+-.^_\`|~`). All other characters - whitespace, punctuation outside that set, and any
1110+
* non-ASCII character - are dropped, so the result is always a well-formed User-Agent product
1111+
* token that OkHttp will accept without throwing.
1112+
*/
1113+
private fun sanitizeToUserAgentToken(value: String?): String {
1114+
if (value.isNullOrEmpty()) return ""
1115+
val sb = StringBuilder(value.length)
1116+
for (c in value) {
1117+
if (isUserAgentTokenChar(c)) {
1118+
sb.append(c)
1119+
}
1120+
}
1121+
return sb.toString()
1122+
}
1123+
1124+
private fun isUserAgentTokenChar(c: Char): Boolean =
1125+
c in 'a'..'z' ||
1126+
c in 'A'..'Z' ||
1127+
c in '0'..'9' ||
1128+
c == '!' ||
1129+
c == '#' ||
1130+
c == '\u0024' ||
1131+
c == '%' ||
1132+
c == '&' ||
1133+
c == '\'' ||
1134+
c == '*' ||
1135+
c == '+' ||
1136+
c == '-' ||
1137+
c == '.' ||
1138+
c == '^' ||
1139+
c == '_' ||
1140+
c == '`' ||
1141+
c == '|' ||
1142+
c == '~'
1143+
10491144
public companion object {
10501145
public const val NAME: String = NativeNetworkingAndroidSpec.NAME
10511146
private const val TAG: String = NativeNetworkingAndroidSpec.NAME

packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.kt

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,174 @@ class NetworkingModuleTest {
654654
assertThat(completionArgs.getInt(0)).isEqualTo(1)
655655
assertThat(completionArgs.isNull(1)).isTrue()
656656
}
657+
658+
// ----- User-Agent parity (issue 284) -----
659+
660+
@Test
661+
fun testDefaultUserAgentNullFallbackUsesPackageName() {
662+
// Pass null defaultUserAgent with a mock context that has no PackageManager details.
663+
// The fallback should not crash and should synthesise a value or return null gracefully.
664+
val mockPm = mock<android.content.pm.PackageManager>()
665+
val appInfo = android.content.pm.ApplicationInfo()
666+
whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("MyApp")
667+
// Internal helper directly — null packageInfo => appName only
668+
val dummyModule = NetworkingModule(context, "__test__", httpClient, null)
669+
val uaNoVersion =
670+
dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", null, appInfo)
671+
assertThat(uaNoVersion).isEqualTo("MyApp")
672+
673+
// With version available => AppName/Version
674+
val pkgInfo = android.content.pm.PackageInfo()
675+
pkgInfo.versionName = "1.2.3"
676+
val uaWithVersion =
677+
dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo)
678+
assertThat(uaWithVersion).isEqualTo("MyApp/1.2.3")
679+
}
680+
681+
@Test
682+
fun testDefaultUserAgentNullPackageManagerReturnsNull() {
683+
val dummyModule = NetworkingModule(context, "__test__", httpClient, null)
684+
assertThat(dummyModule.createDefaultUserAgentInternal(null, "com.example", null, null)).isNull()
685+
assertThat(dummyModule.createDefaultUserAgentInternal(mock(), null, null, null)).isNull()
686+
}
687+
688+
@Test
689+
fun testBlankAppLabelFallsBackToPackageNameNotVersionOnly() {
690+
// A blank application label must not produce a version-only User-Agent (e.g. "1.2.3").
691+
// Fall back to the package name so the UA always carries a product identifier.
692+
val mockPm = mock<android.content.pm.PackageManager>()
693+
val appInfo = android.content.pm.ApplicationInfo()
694+
whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("")
695+
val pkgInfo = android.content.pm.PackageInfo()
696+
pkgInfo.versionName = "1.2.3"
697+
val dummyModule = NetworkingModule(context, "__test__", httpClient, null)
698+
assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo))
699+
.isEqualTo("com.example/1.2.3")
700+
}
701+
702+
@Test
703+
fun testUserAgentLabelWhitespaceIsSanitized() {
704+
// Spaces (and other non-token chars) in the human-readable label would violate the RFC 7231
705+
// product-token syntax; they must be stripped so the UA is well-formed.
706+
val mockPm = mock<android.content.pm.PackageManager>()
707+
val appInfo = android.content.pm.ApplicationInfo()
708+
whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("My Cool App")
709+
val pkgInfo = android.content.pm.PackageInfo()
710+
pkgInfo.versionName = "1.2.3"
711+
val dummyModule = NetworkingModule(context, "__test__", httpClient, null)
712+
assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo))
713+
.isEqualTo("MyCoolApp/1.2.3")
714+
}
715+
716+
@Test
717+
fun testUserAgentNonAsciiCharsStrippedFromLabel() {
718+
// Non-ASCII characters (accents, symbols like (c)/(R), emoji) would make OkHttp's Headers.add()
719+
// throw on every request; they must be dropped, leaving the ASCII remainder.
720+
val mockPm = mock<android.content.pm.PackageManager>()
721+
val appInfo = android.content.pm.ApplicationInfo()
722+
whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("Caf\u00e9\u00ae")
723+
val pkgInfo = android.content.pm.PackageInfo()
724+
pkgInfo.versionName = "2.0"
725+
val dummyModule = NetworkingModule(context, "__test__", httpClient, null)
726+
assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo))
727+
.isEqualTo("Caf/2.0")
728+
}
729+
730+
@Test
731+
fun testUserAgentAllNonAsciiLabelFallsBackToPackageName() {
732+
// A label with no token-safe characters at all must fall back to the package name rather than
733+
// producing an empty product token.
734+
val mockPm = mock<android.content.pm.PackageManager>()
735+
val appInfo = android.content.pm.ApplicationInfo()
736+
whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("\u65e5\u672c\u8a9e\u30a2\u30d7\u30ea")
737+
val pkgInfo = android.content.pm.PackageInfo()
738+
pkgInfo.versionName = "1.2.3"
739+
val dummyModule = NetworkingModule(context, "__test__", httpClient, null)
740+
assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo))
741+
.isEqualTo("com.example/1.2.3")
742+
}
743+
744+
@Test
745+
fun testUserAgentVersionNameIsSanitized() {
746+
// versionName is developer-controlled and may contain spaces/parentheses/non-ASCII; sanitize it
747+
// to a token too. If nothing usable remains, the UA is just the app name.
748+
val mockPm = mock<android.content.pm.PackageManager>()
749+
val appInfo = android.content.pm.ApplicationInfo()
750+
whenever(mockPm.getApplicationLabel(appInfo)).thenReturn("MyApp")
751+
val pkgInfo = android.content.pm.PackageInfo()
752+
pkgInfo.versionName = "1.0 (\u03b2)"
753+
val dummyModule = NetworkingModule(context, "__test__", httpClient, null)
754+
assertThat(dummyModule.createDefaultUserAgentInternal(mockPm, "com.example", pkgInfo, appInfo))
755+
.isEqualTo("MyApp/1.0")
756+
}
757+
758+
@Test
759+
fun testNullDefaultUserAgentStillSendsHeadersGracefully() {
760+
// When PackageManager lookups fail, no User-Agent is injected — request still succeeds
761+
// with whatever headers JS supplied (empty here).
762+
val nullUaContext = mock<ReactApplicationContext>()
763+
whenever(nullUaContext.hasActiveReactInstance()).thenReturn(true)
764+
whenever(nullUaContext.applicationContext).thenReturn(null)
765+
whenever(nullUaContext.packageName).thenReturn("com.nonexistent")
766+
whenever(nullUaContext.packageManager).thenReturn(null)
767+
// applicationContext is null so createDefaultUserAgent receives the ReactApplicationContext
768+
// itself which has null packageManager => defaultUserAgent == null
769+
val moduleWithNullUa = NetworkingModule(nullUaContext, null, httpClient, null)
770+
assertThat(moduleWithNullUa.getDefaultUserAgentForTest()).isNull()
771+
772+
moduleWithNullUa.sendRequest(
773+
"GET",
774+
"http://somedomain/foo",
775+
0.0,
776+
JavaOnlyArray.of(),
777+
null,
778+
"text",
779+
true,
780+
0.0,
781+
false,
782+
)
783+
with(requestArgumentCaptor) {
784+
verify(httpClient).newCall(capture())
785+
// No User-Agent injected when fallback returns null
786+
assertThat(firstValue.headers().size()).isEqualTo(0)
787+
}
788+
}
789+
790+
@Test
791+
fun testDefaultUserAgentInjectedIntoRequest() {
792+
// End-to-end: with a null supplied UA and a context that exposes an app label + version,
793+
// the constructor fallback must synthesise "AppName/Version" AND that value must be injected
794+
// into the outgoing request's User-Agent header. This is the actual issue #284 behaviour.
795+
val ctx = mock<ReactApplicationContext>()
796+
whenever(ctx.hasActiveReactInstance()).thenReturn(true)
797+
whenever(ctx.applicationContext).thenReturn(null) // fall back to ctx itself
798+
whenever(ctx.packageName).thenReturn("com.example")
799+
val pm = mock<android.content.pm.PackageManager>()
800+
val appInfo = android.content.pm.ApplicationInfo()
801+
val pkgInfo = android.content.pm.PackageInfo().apply { versionName = "1.2.3" }
802+
whenever(ctx.packageManager).thenReturn(pm)
803+
whenever(pm.getPackageInfo("com.example", 0)).thenReturn(pkgInfo)
804+
whenever(pm.getApplicationInfo("com.example", 0)).thenReturn(appInfo)
805+
whenever(pm.getApplicationLabel(appInfo)).thenReturn("MyApp")
806+
807+
val module = NetworkingModule(ctx, null, httpClient, null) // null => fallback runs
808+
module.sendRequest(
809+
"GET",
810+
"http://somedomain/foo",
811+
0.0,
812+
JavaOnlyArray.of(),
813+
null,
814+
"text",
815+
true,
816+
0.0,
817+
false,
818+
)
819+
820+
with(requestArgumentCaptor) {
821+
verify(httpClient).newCall(capture())
822+
assertThat(firstValue.header("User-Agent")).isEqualTo("MyApp/1.2.3")
823+
}
824+
}
657825
}
658826

659827
private val FORM = MediaType.get("multipart/form-data")

0 commit comments

Comments
 (0)