react-native-audio-browser 0.2.0-next.17 → 0.2.0-next.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/android/build.gradle +2 -1
  2. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +160 -106
  3. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
  4. package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
  5. package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
  6. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
  7. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
  8. package/android/src/main/res/values/strings.xml +6 -21
  9. package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
  10. package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
  11. package/lib/commonjs/features/browser.js +8 -0
  12. package/lib/commonjs/features/browser.js.map +1 -1
  13. package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
  14. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  15. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
  16. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
  17. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  18. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  19. package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
  20. package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
  21. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
  22. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
  23. package/lib/commonjs/web/browser/BrowserManager.js +30 -12
  24. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  25. package/lib/commonjs/web/browser/SearchManager.js +16 -19
  26. package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
  27. package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
  28. package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
  29. package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
  30. package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
  31. package/lib/module/features/browser.js +8 -0
  32. package/lib/module/features/browser.js.map +1 -1
  33. package/lib/module/web/NativeAudioBrowser.js +163 -50
  34. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  35. package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
  36. package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
  37. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  38. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  39. package/lib/module/web/TrackPlayer/Player.js +232 -39
  40. package/lib/module/web/TrackPlayer/Player.js.map +1 -1
  41. package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
  42. package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
  43. package/lib/module/web/browser/BrowserManager.js +30 -12
  44. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  45. package/lib/module/web/browser/SearchManager.js +16 -19
  46. package/lib/module/web/browser/SearchManager.js.map +1 -1
  47. package/lib/module/web/http/RequestConfigBuilder.js +110 -27
  48. package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
  49. package/lib/module/web/http/followMediaRedirect.js +215 -0
  50. package/lib/module/web/http/followMediaRedirect.js.map +1 -0
  51. package/lib/typescript/src/features/browser.d.ts +8 -0
  52. package/lib/typescript/src/features/browser.d.ts.map +1 -1
  53. package/lib/typescript/src/types/browser.d.ts +13 -4
  54. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/features/browser.ts +8 -0
  57. package/src/types/browser.ts +13 -4
  58. package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
  59. package/src/web/NativeAudioBrowser.ts +193 -60
  60. package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
  61. package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
  62. package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
  63. package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
  64. package/src/web/TrackPlayer/Player.load.test.ts +137 -1
  65. package/src/web/TrackPlayer/Player.ts +256 -37
  66. package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
  67. package/src/web/__tests__/lifecycle.test.ts +345 -0
  68. package/src/web/__tests__/lifecycleHarness.ts +351 -0
  69. package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
  70. package/src/web/__tests__/loadSupersession.test.ts +175 -0
  71. package/src/web/__tests__/retry.test.ts +169 -0
  72. package/src/web/browser/BrowserManager.ts +32 -15
  73. package/src/web/browser/SearchManager.test.ts +75 -0
  74. package/src/web/browser/SearchManager.ts +23 -26
  75. package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
  76. package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
  77. package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
  78. package/src/web/http/RequestConfigBuilder.test.ts +241 -1
  79. package/src/web/http/RequestConfigBuilder.ts +140 -33
  80. package/src/web/http/followMediaRedirect.test.ts +289 -0
  81. package/src/web/http/followMediaRedirect.ts +253 -0
@@ -199,6 +199,7 @@ dependencies {
199
199
  implementation("androidx.media3:media3-exoplayer-dash:${media3Version}")
200
200
  implementation("androidx.media3:media3-exoplayer-smoothstreaming:${media3Version}")
201
201
  implementation("androidx.media3:media3-common:${media3Version}")
202
+ implementation("androidx.media3:media3-datasource-okhttp:${media3Version}")
202
203
 
203
204
  implementation "com.google.guava:guava:33.0.0-android"
204
205
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.0"
@@ -243,4 +244,4 @@ if (isNewArchitectureEnabled()) {
243
244
  libraryName = "AudioBrowser"
244
245
  codegenJavaPackageName = "com.audiobrowser"
245
246
  }
246
- }
247
+ }
@@ -93,13 +93,17 @@ import java.util.concurrent.atomic.AtomicReference
93
93
  import kotlin.coroutines.resume
94
94
  import kotlin.coroutines.resumeWithException
95
95
  import kotlin.coroutines.suspendCoroutine
96
+ import kotlin.time.Duration.Companion.seconds
96
97
  import kotlinx.coroutines.CancellationException
98
+ import kotlinx.coroutines.CoroutineScope
97
99
  import kotlinx.coroutines.Job
98
100
  import kotlinx.coroutines.MainScope
101
+ import kotlinx.coroutines.async
99
102
  import kotlinx.coroutines.cancel
100
103
  import kotlinx.coroutines.cancelChildren
101
104
  import kotlinx.coroutines.launch
102
105
  import kotlinx.coroutines.runBlocking
106
+ import kotlinx.coroutines.withTimeoutOrNull
103
107
  import timber.log.Timber
104
108
 
105
109
  @Keep
@@ -503,121 +507,48 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
503
507
 
504
508
  override fun getFormattedNavigationError(): FormattedNavigationError? = formattedNavigationError
505
509
 
506
- /** Creates a default formatted error from a NavigationError */
507
- private fun defaultFormattedError(error: NavigationError): FormattedNavigationError {
508
- val title =
509
- when (error.code) {
510
- NavigationErrorType.CONTENT_NOT_FOUND -> "Content Not Found"
511
- NavigationErrorType.NETWORK_ERROR -> "Network Error"
512
- NavigationErrorType.HTTP_ERROR -> {
513
- // Use system-localized HTTP status text (e.g., "Not Found", "Service Unavailable")
514
- error.statusCode?.let { httpStatusText(it.toInt()) } ?: "Server Error"
515
- }
516
- NavigationErrorType.CALLBACK_ERROR -> "Error"
517
- NavigationErrorType.UNKNOWN_ERROR -> "Error"
518
- // Not a failure — a container that resolved with no children. Neutral copy. See ADR 0001.
519
- NavigationErrorType.EMPTY_CONTENT -> "Nothing here"
520
- NavigationErrorType.TIMEOUT -> "Couldn't load"
521
- }
522
- // Omit an empty message so it renders as title-only (e.g. the empty-content case).
523
- return FormattedNavigationError(title, error.message.takeIf { it.isNotEmpty() })
524
- }
525
-
526
- /** Returns localized HTTP status text for the given status code */
527
- private fun httpStatusText(statusCode: Int): String {
528
- return when (statusCode) {
529
- 400 -> "Bad Request"
530
- 401 -> "Unauthorized"
531
- 403 -> "Forbidden"
532
- 404 -> "Not Found"
533
- 405 -> "Method Not Allowed"
534
- 408 -> "Request Timeout"
535
- 429 -> "Too Many Requests"
536
- 500 -> "Internal Server Error"
537
- 502 -> "Bad Gateway"
538
- 503 -> "Service Unavailable"
539
- 504 -> "Gateway Timeout"
540
- else -> "Server Error"
510
+ /**
511
+ * Resolves a navigation error to its display form: the app's `formatNavigationError` for the
512
+ * given [path] when one is configured, else the built-in default. Shared by [setNavigationError]
513
+ * and the Android Auto empty tile so in-app and in-car copy cannot drift (ADR 0001).
514
+ */
515
+ internal suspend fun resolveFormattedError(
516
+ error: NavigationError,
517
+ path: String,
518
+ ): FormattedNavigationError {
519
+ val default = defaultFormattedError(error)
520
+ val formatter = _configuration.formatNavigationError ?: return default
521
+ return formattedOrDefault(mainScope, default) {
522
+ formatter(FormatNavigationErrorParams(error, default, path)).await()
541
523
  }
542
524
  }
543
525
 
544
- private fun setNavigationError(
545
- code: NavigationErrorType,
546
- message: String,
547
- path: String,
548
- statusCode: Double? = null,
549
- statusCodeSuccess: Boolean? = null,
550
- ) {
551
- val navError = NavigationError(code, message, statusCode, statusCodeSuccess)
526
+ private fun setNavigationError(navError: NavigationError, path: String) {
552
527
  navigationError = navError
553
528
  onNavigationError(NavigationErrorEvent(navigationError))
554
529
 
555
- // Format the error (async if using JS callback, sync for defaults)
556
- val defaultFormatted = defaultFormattedError(navError)
557
- val formatter = _configuration.formatNavigationError
558
- if (formatter != null) {
559
- mainScope.launch {
560
- try {
561
- val params = FormatNavigationErrorParams(navError, defaultFormatted, path)
562
- val customFormatted = formatter(params).await()
563
- formattedNavigationError = customFormatted ?: defaultFormatted
564
- } catch (e: Exception) {
565
- formattedNavigationError = defaultFormatted
566
- }
567
- onFormattedNavigationError(formattedNavigationError)
568
- }
569
- } else {
570
- formattedNavigationError = defaultFormatted
571
- onFormattedNavigationError(formattedNavigationError)
530
+ // No formatter means no hop, and the field and event land in this turn: a same-turn
531
+ // getFormattedNavigationError, or a clearNavigationError right after, must not race a
532
+ // launch that has yet to run.
533
+ if (_configuration.formatNavigationError == null) {
534
+ val default = defaultFormattedError(navError)
535
+ formattedNavigationError = default
536
+ onFormattedNavigationError(default)
537
+ return
538
+ }
539
+
540
+ mainScope.launch {
541
+ val formatted = resolveFormattedError(navError, path)
542
+ formattedNavigationError = formatted
543
+ onFormattedNavigationError(formatted)
572
544
  }
573
545
  }
574
546
 
575
- /** Maps common browser exceptions to navigation errors */
547
+ /** Raises the navigation error [e] maps to, for a failed navigation to [path]. */
576
548
  private fun handleBrowserException(e: Exception, path: String, logContext: String) {
577
- when (e) {
578
- is HttpStatusException -> {
579
- Timber.e(e, "HTTP error $logContext")
580
- setNavigationError(
581
- NavigationErrorType.HTTP_ERROR,
582
- e.message ?: "Server error",
583
- path,
584
- e.statusCode.toDouble(),
585
- e.statusCode in 200..299,
586
- )
587
- }
588
- is NetworkException -> {
589
- Timber.e(e, "Network error $logContext")
590
- setNavigationError(
591
- NavigationErrorType.NETWORK_ERROR,
592
- e.message ?: "Network request failed",
593
- path,
594
- )
595
- }
596
- is ContentNotFoundException -> {
597
- Timber.e(e, "Content not found $logContext")
598
- setNavigationError(
599
- NavigationErrorType.CONTENT_NOT_FOUND,
600
- e.message ?: "Content not found",
601
- path,
602
- )
603
- }
604
- is CallbackException -> {
605
- Timber.e(e, "Callback error $logContext")
606
- setNavigationError(
607
- NavigationErrorType.CALLBACK_ERROR,
608
- e.message ?: "An error occurred",
609
- path,
610
- )
611
- }
612
- else -> {
613
- Timber.e(e, "Unexpected error $logContext")
614
- setNavigationError(
615
- NavigationErrorType.UNKNOWN_ERROR,
616
- e.message ?: "An unexpected error occurred",
617
- path,
618
- )
619
- }
620
- }
549
+ val navError = navigationErrorFor(e)
550
+ Timber.e(e, "${navError.code} $logContext")
551
+ setNavigationError(navError, path)
621
552
  }
622
553
 
623
554
  private fun clearNavigationError() {
@@ -769,7 +700,18 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
769
700
  override fun search(query: String): Promise<Array<Track>> {
770
701
  return Promise.async(mainScope) {
771
702
  Timber.d("Searching for: $query")
772
- val searchResults = browserManager.search(query)
703
+ // Rejects with the error the browse surface would raise, but leaves the navigation error
704
+ // itself unset: a search is not a navigation, and its failure must not replace the
705
+ // displayed page's state (mirrors HybridAudioBrowser.search).
706
+ val searchResults =
707
+ try {
708
+ browserManager.search(query)
709
+ } catch (e: CancellationException) {
710
+ throw e
711
+ } catch (e: Exception) {
712
+ Timber.e(e, "Search failed for query: $query")
713
+ throw NavigationErrorException(navigationErrorFor(e))
714
+ }
773
715
  // Flattened, not `children`: a resolved page carries its rows in
774
716
  // `sections` (ADR 0010) and search builds exactly one untitled section,
775
717
  // so `children` is always null here and this returned nothing at all.
@@ -1626,3 +1568,115 @@ private fun NativeRouteEntry.strippingJSCallbacks() =
1626
1568
  media = media?.strippingJSCallbacks(),
1627
1569
  artwork = artwork?.strippingJSCallbacks(),
1628
1570
  )
1571
+
1572
+ /**
1573
+ * Ceiling on the app's `formatNavigationError` hop. Same budget [Player.awaitBrowser] gives the JS
1574
+ * side, the only other JS wait a browse serve already blocks on.
1575
+ */
1576
+ private val FORMATTER_TIMEOUT = 10.seconds
1577
+
1578
+ /**
1579
+ * The navigation error an exception raises. Shared by [AudioBrowser]'s failed navigations, the
1580
+ * rejection [AudioBrowser.search] hands JS, and the Android Auto browse-error tile, so one failure
1581
+ * is named one way wherever it surfaces.
1582
+ */
1583
+ internal fun navigationErrorFor(e: Exception): NavigationError =
1584
+ when (e) {
1585
+ is HttpStatusException ->
1586
+ NavigationError(
1587
+ NavigationErrorType.HTTP_ERROR,
1588
+ e.message ?: "Server error",
1589
+ e.statusCode.toDouble(),
1590
+ e.statusCode in 200..299,
1591
+ )
1592
+ is NetworkException ->
1593
+ NavigationError(
1594
+ NavigationErrorType.NETWORK_ERROR,
1595
+ e.message ?: "Network request failed",
1596
+ null,
1597
+ null,
1598
+ )
1599
+ is ContentNotFoundException ->
1600
+ NavigationError(
1601
+ NavigationErrorType.CONTENT_NOT_FOUND,
1602
+ e.message ?: "Content not found",
1603
+ null,
1604
+ null,
1605
+ )
1606
+ is CallbackException ->
1607
+ NavigationError(
1608
+ NavigationErrorType.CALLBACK_ERROR,
1609
+ e.message ?: "An error occurred",
1610
+ null,
1611
+ null,
1612
+ )
1613
+ else ->
1614
+ NavigationError(
1615
+ NavigationErrorType.UNKNOWN_ERROR,
1616
+ e.message ?: "An unexpected error occurred",
1617
+ null,
1618
+ null,
1619
+ )
1620
+ }
1621
+
1622
+ /** Carries a [NavigationError] across a rejected Promise, whose JS error keeps its message. */
1623
+ class NavigationErrorException(val error: NavigationError) : Exception(error.message)
1624
+
1625
+ /**
1626
+ * The built-in display copy for a navigation error, and the fallback when the app's is unusable.
1627
+ */
1628
+ internal fun defaultFormattedError(error: NavigationError): FormattedNavigationError {
1629
+ val title =
1630
+ when (error.code) {
1631
+ NavigationErrorType.CONTENT_NOT_FOUND -> "Content Not Found"
1632
+ NavigationErrorType.NETWORK_ERROR -> "Network Error"
1633
+ NavigationErrorType.HTTP_ERROR -> {
1634
+ // Use system-localized HTTP status text (e.g., "Not Found", "Service Unavailable")
1635
+ error.statusCode?.let { httpStatusText(it.toInt()) } ?: "Server Error"
1636
+ }
1637
+ NavigationErrorType.CALLBACK_ERROR -> "Error"
1638
+ NavigationErrorType.UNKNOWN_ERROR -> "Error"
1639
+ // Not a failure — a container that resolved with no children. Neutral copy. See ADR 0001.
1640
+ NavigationErrorType.EMPTY_CONTENT -> "Nothing here"
1641
+ NavigationErrorType.TIMEOUT -> "Couldn't load"
1642
+ }
1643
+ // Omit an empty message so it renders as title-only (e.g. the empty-content case).
1644
+ return FormattedNavigationError(title, error.message.takeIf { it.isNotEmpty() })
1645
+ }
1646
+
1647
+ /**
1648
+ * The formatter hop's fallback rule: the app's answer when it produces one, [default] when it
1649
+ * answers null, throws, or outruns [FORMATTER_TIMEOUT]. Kept off the JS edge so the rule is
1650
+ * exercisable without a Nitro Promise.
1651
+ *
1652
+ * The hop runs in [scope] and is awaited through its [kotlinx.coroutines.Deferred], because Nitro's
1653
+ * `Promise.await` parks in a plain `suspendCoroutine`: cancelling it does not unpark it. Awaiting
1654
+ * the Deferred is cancellable, so the timeout leaves the orphan to finish unobserved instead of
1655
+ * holding the caller open until JS answers — which for a browse serve would be forever.
1656
+ */
1657
+ internal suspend fun formattedOrDefault(
1658
+ scope: CoroutineScope,
1659
+ default: FormattedNavigationError,
1660
+ format: suspend () -> FormattedNavigationError?,
1661
+ ): FormattedNavigationError {
1662
+ val hop = scope.async { runCatching { format() }.getOrNull() }
1663
+ return withTimeoutOrNull(FORMATTER_TIMEOUT) { hop.await() } ?: default
1664
+ }
1665
+
1666
+ /** Returns localized HTTP status text for the given status code */
1667
+ private fun httpStatusText(statusCode: Int): String {
1668
+ return when (statusCode) {
1669
+ 400 -> "Bad Request"
1670
+ 401 -> "Unauthorized"
1671
+ 403 -> "Forbidden"
1672
+ 404 -> "Not Found"
1673
+ 405 -> "Method Not Allowed"
1674
+ 408 -> "Request Timeout"
1675
+ 429 -> "Too Many Requests"
1676
+ 500 -> "Internal Server Error"
1677
+ 502 -> "Bad Gateway"
1678
+ 503 -> "Service Unavailable"
1679
+ 504 -> "Gateway Timeout"
1680
+ else -> "Server Error"
1681
+ }
1682
+ }
@@ -877,6 +877,8 @@ class BrowserManager {
877
877
  *
878
878
  * @param query The search query string
879
879
  * @return Array of playable tracks, or null if no results or search not configured
880
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
881
+ * never "couldn't ask"
880
882
  */
881
883
  suspend fun searchPlayable(query: String): Array<Track>? {
882
884
  return searchPlayable(
@@ -900,6 +902,8 @@ class BrowserManager {
900
902
  *
901
903
  * @param params The structured search parameters
902
904
  * @return Array of playable tracks, or null if no results or search not configured
905
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
906
+ * never "couldn't ask"
903
907
  */
904
908
  suspend fun searchPlayable(params: SearchParams): Array<Track>? {
905
909
  val searchResults = search(params)
@@ -937,6 +941,8 @@ class BrowserManager {
937
941
  *
938
942
  * @param query The search query string
939
943
  * @return ResolvedTrack containing search results as children
944
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
945
+ * never "couldn't ask"
940
946
  */
941
947
  suspend fun search(query: String): ResolvedTrack {
942
948
  return search(
@@ -960,6 +966,8 @@ class BrowserManager {
960
966
  *
961
967
  * @param params The structured search parameters
962
968
  * @return ResolvedTrack containing search results as children
969
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
970
+ * never "couldn't ask"
963
971
  */
964
972
  suspend fun search(params: SearchParams): ResolvedTrack {
965
973
  Timber.d("Executing fresh search for: ${params.query} (mode=${params.mode})")
@@ -1020,36 +1028,10 @@ class BrowserManager {
1020
1028
 
1021
1029
  return searchResolvedTrack
1022
1030
  } catch (e: Exception) {
1031
+ // Propagates, like the iOS `search`: a caller that cannot tell a failed search from one
1032
+ // that found nothing renders a server error as "No results found".
1023
1033
  Timber.e(e, "Error during search for query: ${params.query}")
1024
-
1025
- // Return empty search result on error
1026
- val emptySearchResult =
1027
- ResolvedTrack(
1028
- id = null,
1029
- path = searchPath,
1030
- title = "Search: ${params.query}",
1031
- sections = arrayOf(untitledSection(emptyArray())),
1032
- children = null,
1033
- carPlaySiriListButton = null,
1034
- artwork = null,
1035
- artworkSource = null,
1036
- request = null,
1037
- artist = null,
1038
- albumPath = null,
1039
- description = null,
1040
- subtitle = null,
1041
- album = null,
1042
- genre = null,
1043
- duration = null,
1044
- src = null,
1045
- style = null,
1046
- disabled = null,
1047
- favorited = null,
1048
- live = null,
1049
- icyCharset = null,
1050
- )
1051
-
1052
- return emptySearchResult
1034
+ throw e
1053
1035
  }
1054
1036
  }
1055
1037
 
@@ -0,0 +1,77 @@
1
+ package com.audiobrowser.player
2
+
3
+ import okhttp3.HttpUrl
4
+ import okhttp3.Interceptor
5
+ import okhttp3.Request
6
+ import okhttp3.Response
7
+
8
+ /**
9
+ * Drops the caller's headers once a redirect leaves the origin.
10
+ *
11
+ * OkHttp does this for `Authorization` alone — RetryAndFollowUpInterceptor.buildRedirectRequest is
12
+ * a single `removeHeader("Authorization")` — so every other configured header, an `X-Api-Key` say,
13
+ * still reaches whatever host the first one named. iOS forwards none across a hop and web clears
14
+ * them too; this brings Android in line.
15
+ *
16
+ * Installed as a **network** interceptor, which runs once per hop. `chain.call().request()` is the
17
+ * call's original request, so the comparison is always against the URL the credential was addressed
18
+ * to rather than the hop before.
19
+ *
20
+ * Scoped to the redirect hop, and only that. An HLS segment or key fetch is its own Call, already
21
+ * addressed to the absolute CDN URL, and [TransformingDataSource] deliberately re-applies the
22
+ * config's headers to it — authenticated segments are a supported shape, and web does the same
23
+ * through Shaka's request filter. So this closes the leak of handing a credential to a host the
24
+ * caller never named; it is not a rule that the credential stays on one origin.
25
+ */
26
+ internal object CrossOriginHeaderStripper : Interceptor {
27
+
28
+ /**
29
+ * Headers that describe the transport rather than the caller. OkHttp and media3 set these per hop
30
+ * — `Range` carries the seek, `Host` and `Connection` are the connection's own — so the request
31
+ * breaks without them. Everything else on a media request came from the media config, and is the
32
+ * caller's to send only to the host it addressed.
33
+ *
34
+ * `Icy-MetaData` is media3's, not the caller's: `ProgressiveMediaPeriod` asks for Shoutcast
35
+ * metadata through `ICY_METADATA_HEADERS` on the DataSpec — the same channel
36
+ * [TransformingDataSource] puts the config's headers on, so nothing downstream can tell them
37
+ * apart. Dropping it on a hop costs the station's `icy-metaint`, so `IcyDataSource` is never
38
+ * installed and `onMetadata` stops firing: live "now playing" disappears on exactly the
39
+ * redirecting streams this interceptor exists for, and the interleaved metadata bytes stay in the
40
+ * audio as audible chirping. It is a request for a response format, never a credential.
41
+ */
42
+ private val TRANSPORT_HEADERS =
43
+ setOf(
44
+ "accept",
45
+ "accept-encoding",
46
+ "connection",
47
+ "content-length",
48
+ "host",
49
+ "icy-metadata",
50
+ "range",
51
+ "transfer-encoding",
52
+ "user-agent",
53
+ )
54
+
55
+ override fun intercept(chain: Interceptor.Chain): Response {
56
+ val request = chain.request()
57
+ val addressed = chain.call().request().url
58
+ return chain.proceed(
59
+ if (sameOrigin(addressed, request.url)) request else stripCallerHeaders(request)
60
+ )
61
+ }
62
+
63
+ /** Scheme, host and port — the same origin test `followMediaRedirect` applies on web. */
64
+ internal fun sameOrigin(a: HttpUrl, b: HttpUrl): Boolean =
65
+ a.scheme == b.scheme && a.host == b.host && a.port == b.port
66
+
67
+ /** The request with every caller-supplied header removed. */
68
+ internal fun stripCallerHeaders(request: Request): Request =
69
+ request
70
+ .newBuilder()
71
+ .apply {
72
+ request.headers.names().forEach { name ->
73
+ if (name.lowercase() !in TRANSPORT_HEADERS) removeHeader(name)
74
+ }
75
+ }
76
+ .build()
77
+ }
@@ -4,10 +4,10 @@ import android.content.Context
4
4
  import androidx.media3.common.MediaItem
5
5
  import androidx.media3.datasource.DataSource
6
6
  import androidx.media3.datasource.DefaultDataSource
7
- import androidx.media3.datasource.DefaultHttpDataSource
8
7
  import androidx.media3.datasource.TransferListener
9
8
  import androidx.media3.datasource.cache.CacheDataSource
10
9
  import androidx.media3.datasource.cache.SimpleCache
10
+ import androidx.media3.datasource.okhttp.OkHttpDataSource
11
11
  import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
12
12
  import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
13
13
  import androidx.media3.exoplayer.source.MediaSource
@@ -16,6 +16,8 @@ import androidx.media3.extractor.DefaultExtractorsFactory
16
16
  import com.audiobrowser.model.RetryPolicy
17
17
  import com.margelo.nitro.audiobrowser.MediaRequestConfig
18
18
  import java.io.IOException
19
+ import java.util.concurrent.TimeUnit
20
+ import okhttp3.OkHttpClient
19
21
 
20
22
  class MediaFactory(
21
23
  private val context: Context,
@@ -31,6 +33,20 @@ class MediaFactory(
31
33
 
32
34
  companion object {
33
35
  private const val DEFAULT_USER_AGENT = "react-native-audio-browser"
36
+
37
+ /**
38
+ * The client media requests go out on. One per process so connections are reused; not
39
+ * `HttpClient`'s browse client, whose read timeout is sized for JSON and would cut a long media
40
+ * read short. The timeouts are media3's own defaults, so stall detection and
41
+ * [RetryLoadErrorHandlingPolicy] behave as they did before.
42
+ */
43
+ private val mediaHttpClient: OkHttpClient by lazy {
44
+ OkHttpClient.Builder()
45
+ .connectTimeout(8, TimeUnit.SECONDS)
46
+ .readTimeout(8, TimeUnit.SECONDS)
47
+ .addNetworkInterceptor(CrossOriginHeaderStripper)
48
+ .build()
49
+ }
34
50
  }
35
51
 
36
52
  private val extractorsFactory =
@@ -95,17 +111,19 @@ class MediaFactory(
95
111
  // ExoPlayer's IO thread. This prevents deadlocks when the JS thread is blocked by
96
112
  // synchronous Nitro calls (e.g., seekTo) while a media transform callback needs the
97
113
  // JS thread to resolve.
114
+
115
+ // OkHttp rather than DefaultHttpDataSource, for how each treats a redirect.
116
+ // DefaultHttpDataSource re-applies the request headers on every hop, handing
117
+ // an Authorization header to whatever host the first one named. OkHttp drops
118
+ // that one header itself, and [CrossOriginHeaderStripper] drops the rest.
98
119
  val httpFactory =
99
- DefaultHttpDataSource.Factory().apply {
100
- // Deliberately NOT setUserAgent(): DefaultHttpDataSource applies the factory
101
- // user-agent as a FINAL setRequestProperty("User-Agent", …) that runs after —
102
- // and overrides — the per-request DataSpec headers. That would clobber the
103
- // User-Agent TransformingDataSource sets per request (the whole point of the
104
- // per-request userAgent override). Supplying the same value as a default
105
- // request property keeps the default for un-overridden requests while letting
106
- // the per-request DataSpec header win.
120
+ OkHttpDataSource.Factory(mediaHttpClient).apply {
121
+ // Deliberately NOT setUserAgent(): it is applied last via addHeader(),
122
+ // which appends, so a request already carrying one would send two. As a
123
+ // default request property it is merged under dataSpec.httpRequestHeaders
124
+ // and applied with header(), letting TransformingDataSource's per-request
125
+ // override win.
107
126
  setDefaultRequestProperties(mapOf("User-Agent" to DEFAULT_USER_AGENT))
108
- setAllowCrossProtocolRedirects(true)
109
127
  // Connect transfer listener for bandwidth measurement
110
128
  transferListener?.let { setTransferListener(it) }
111
129
  }