@@ -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
659827private val FORM = MediaType .get(" multipart/form-data" )
0 commit comments