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
@@ -18,28 +18,13 @@
18
18
  -->
19
19
  <resources>
20
20
  <!--
21
- The offline tile: shown in place of a page's contents when the device is
22
- offline and the consumer left `androidControllerOfflineError` on. Distinct
23
- from the browse-error tile below — this one is used only when the network
24
- monitor says we are actually offline, so it may name the cause. The
25
- subtitle is the tile's second line.
21
+ The SessionError message a browse or search failure sends alongside its
22
+ tile. The tiles themselves are worded by the app's `formatNavigationError`
23
+ (ADR 0001), so these two are the library's only device-locale browse copy —
24
+ kept because a SessionError carries a string, not a navigation error.
25
+ Offline is distinct from the browse error: it is sent only when the network
26
+ monitor says we are actually offline, so it may name the cause.
26
27
  -->
27
28
  <string name="audio_browser_offline_error">No internet connection</string>
28
- <string name="audio_browser_offline_error_subtitle">Check your connection and try again</string>
29
-
30
- <!--
31
- The browse-error tile: shown when a browse fails while ONLINE — a server
32
- error, a bad status, a timeout waiting for the JS browser. Deliberately
33
- vague, and deliberately not "no internet connection": the genuinely-offline
34
- case is caught earlier and gets the tile above, so anything reaching this
35
- one has a working connection and would be misdiagnosed by that wording.
36
-
37
- A tile rather than an error dialog because Media3 drops the message from
38
- `LibraryResult.ofError` on the legacy browse bridge, which would leave a
39
- bare "No items" screen. Verified on a head unit (2026-06): the Android Auto
40
- browse list never renders error text by any other route, so tiles are the
41
- only in-browse signal the library controls.
42
- -->
43
29
  <string name="audio_browser_browse_error">Something went wrong</string>
44
- <string name="audio_browser_browse_error_subtitle">Please try again later</string>
45
30
  </resources>
@@ -0,0 +1,385 @@
1
+ package com.audiobrowser.player
2
+
3
+ import android.os.Bundle
4
+ import androidx.media3.common.MediaItem
5
+ import androidx.media3.common.MediaMetadata
6
+ import androidx.media3.session.MediaLibraryService
7
+ import androidx.media3.session.MediaSession
8
+ import com.audiobrowser.NavigationErrorException
9
+ import com.audiobrowser.TestFixtures
10
+ import com.audiobrowser.browser.HttpStatusException
11
+ import com.audiobrowser.browser.NetworkException
12
+ import com.audiobrowser.defaultFormattedError
13
+ import com.audiobrowser.formattedOrDefault
14
+ import com.audiobrowser.navigationErrorFor
15
+ import com.audiobrowser.util.BrowserPathHelper
16
+ import com.margelo.nitro.audiobrowser.FormattedNavigationError
17
+ import com.margelo.nitro.audiobrowser.NavigationError
18
+ import com.margelo.nitro.audiobrowser.NavigationErrorType
19
+ import kotlin.coroutines.Continuation
20
+ import kotlin.coroutines.resume
21
+ import kotlin.coroutines.suspendCoroutine
22
+ import kotlinx.coroutines.test.runTest
23
+ import org.junit.Assert.assertEquals
24
+ import org.junit.Assert.assertFalse
25
+ import org.junit.Assert.assertNotEquals
26
+ import org.junit.Assert.assertNotNull
27
+ import org.junit.Assert.assertNull
28
+ import org.junit.Assert.assertSame
29
+ import org.junit.Assert.assertTrue
30
+ import org.junit.Test
31
+ import org.junit.runner.RunWith
32
+ import org.robolectric.RobolectricTestRunner
33
+
34
+ /**
35
+ * Tests the tiles an Android Auto browse level serves in place of a list: the empty tile of a page
36
+ * that resolved with zero children, and the offline and failure tiles, all worded by the app's
37
+ * `formatNavigationError` (ADR 0001). MediaSessionCallback itself needs a live MediaLibrarySession,
38
+ * so the pieces under test are the ones carved out of it: [browseLevelItems] (the empty-branch
39
+ * decision), the tile builders, [BrowserPathHelper.isDeadEndPath] (the drill-in guard),
40
+ * [announcedSearchCount] (what onSearch tells the controller to ask for), [FailedSearchSlot] (a
41
+ * failed search, which must not read as an empty one), [OfflineSearchRegistry] (the searches
42
+ * connectivity returning re-runs), [navigationErrorFor] (the one exception mapping browse, search
43
+ * and the JS-facing `search` rejection share) and [formattedOrDefault] (the formatter hop's
44
+ * fallback rule, which sits off the JNI-backed Promise edge for exactly this reason).
45
+ */
46
+ @RunWith(RobolectricTestRunner::class)
47
+ class BrowseTilesTest {
48
+
49
+ private fun controller(packageName: String, uid: Int): MediaSession.ControllerInfo =
50
+ MediaSession.ControllerInfo.createTestOnlyControllerInfo(
51
+ packageName,
52
+ /* pid= */ 0,
53
+ uid,
54
+ /* libraryVersion= */ 2,
55
+ /* interfaceVersion= */ 2,
56
+ /* trusted= */ true,
57
+ Bundle.EMPTY,
58
+ /* isPackageNameVerified= */ true,
59
+ )
60
+
61
+ private fun track(title: String): MediaItem =
62
+ MediaItem.Builder()
63
+ .setMediaId("/station/$title")
64
+ .setMediaMetadata(MediaMetadata.Builder().setTitle(title).build())
65
+ .build()
66
+
67
+ /**
68
+ * Stands in for an app's `formatNavigationError`: different copy per error and per path, the
69
+ * point of ADR 0001. The search branches match the prefix the library builds car search paths
70
+ * from.
71
+ */
72
+ private fun appCopy(error: NavigationError, path: String): FormattedNavigationError {
73
+ val isSearch = path.startsWith(BrowserPathHelper.SEARCH_PATH_PREFIX)
74
+ return when {
75
+ error.code == NavigationErrorType.NETWORK_ERROR && isSearch ->
76
+ FormattedNavigationError("Search needs a connection", "Reconnect and try again.")
77
+ error.code == NavigationErrorType.NETWORK_ERROR ->
78
+ FormattedNavigationError("You're offline", "Check your connection.")
79
+ error.code != NavigationErrorType.EMPTY_CONTENT ->
80
+ FormattedNavigationError("We couldn't load that", "Try again in a moment.")
81
+ path == "/favorites" ->
82
+ FormattedNavigationError("No favorites yet", "Tap the heart on a station to add it here.")
83
+ isSearch -> FormattedNavigationError("No results found", "Try a different search.")
84
+ else -> FormattedNavigationError("Nothing here", null)
85
+ }
86
+ }
87
+
88
+ @Test
89
+ fun `an empty favorites page serves one tile carrying the app's copy for that path`() = runTest {
90
+ val items = browseLevelItems(emptyList()) { appCopy(emptyContentError(), "/favorites") }
91
+
92
+ assertEquals(1, items.size)
93
+ val tile = items.single()
94
+ assertEquals(BrowserPathHelper.EMPTY_PATH, tile.mediaId)
95
+ assertEquals("No favorites yet", tile.mediaMetadata.title)
96
+ assertEquals("Tap the heart on a station to add it here.", tile.mediaMetadata.subtitle)
97
+ assertFalse(tile.mediaMetadata.isBrowsable!!)
98
+ assertFalse(tile.mediaMetadata.isPlayable!!)
99
+ }
100
+
101
+ @Test
102
+ fun `with no formatter the tile carries the neutral default`() = runTest {
103
+ val default = defaultFormattedError(emptyContentError())
104
+ assertEquals("Nothing here", default.title)
105
+ assertNull(default.message)
106
+
107
+ val tile = browseLevelItems(emptyList()) { default }.single()
108
+ assertEquals("Nothing here", tile.mediaMetadata.title)
109
+ assertEquals("", tile.mediaMetadata.subtitle)
110
+ }
111
+
112
+ @Test
113
+ fun `a multi-line message collapses to the single-line subtitle`() = runTest {
114
+ val tile =
115
+ browseLevelItems(emptyList()) { FormattedNavigationError("Empty", "One\nTwo") }.single()
116
+ assertEquals("One Two", tile.mediaMetadata.subtitle)
117
+ }
118
+
119
+ @Test
120
+ fun `a page with children is served unchanged and never asks for copy`() = runTest {
121
+ val children = listOf(track("a"), track("b"))
122
+
123
+ val items = browseLevelItems(children) { error("formatter must not run for a full page") }
124
+
125
+ assertSame(children, items)
126
+ }
127
+
128
+ @Test
129
+ fun `a drill-in on the empty tile dead-ends`() {
130
+ assertTrue(BrowserPathHelper.isDeadEndPath(BrowserPathHelper.EMPTY_PATH))
131
+ assertFalse(BrowserPathHelper.isDeadEndPath("/favorites"))
132
+ // The gate tile re-serves its message instead of dead-ending.
133
+ assertFalse(BrowserPathHelper.isDeadEndPath(BrowserPathHelper.GATE_PATH))
134
+ }
135
+
136
+ @Test
137
+ fun `the formatter hop keeps the app's answer`() = runTest {
138
+ val custom = FormattedNavigationError("No favorites yet", "Tap the heart.")
139
+ assertEquals(
140
+ custom,
141
+ formattedOrDefault(backgroundScope, FormattedNavigationError("Nothing here", null)) { custom },
142
+ )
143
+ }
144
+
145
+ @Test
146
+ fun `the formatter hop falls back to the default on null and on a throw`() = runTest {
147
+ val default = FormattedNavigationError("Nothing here", null)
148
+
149
+ assertEquals(default, formattedOrDefault(backgroundScope, default) { null })
150
+ assertEquals(
151
+ default,
152
+ formattedOrDefault(backgroundScope, default) { throw IllegalStateException("boom") },
153
+ )
154
+ }
155
+
156
+ @Test
157
+ fun `an empty search serves one tile carrying the app's copy for the search path`() = runTest {
158
+ val searchPath = BrowserPathHelper.createSearchPath("jazz")
159
+
160
+ val items = browseLevelItems(emptyList()) { appCopy(emptyContentError(), searchPath) }
161
+
162
+ assertEquals(1, items.size)
163
+ val tile = items.single()
164
+ assertEquals(BrowserPathHelper.EMPTY_PATH, tile.mediaId)
165
+ assertEquals("No results found", tile.mediaMetadata.title)
166
+ assertEquals("Try a different search.", tile.mediaMetadata.subtitle)
167
+ assertFalse(tile.mediaMetadata.isBrowsable!!)
168
+ assertFalse(tile.mediaMetadata.isPlayable!!)
169
+ }
170
+
171
+ @Test
172
+ fun `a search with hits is served unchanged and never asks for copy`() = runTest {
173
+ val hits = listOf(track("Jazz FM"), track("Jazz24"))
174
+
175
+ val items = browseLevelItems(hits) { error("formatter must not run for a search with hits") }
176
+
177
+ assertSame(hits, items)
178
+ }
179
+
180
+ @Test
181
+ fun `a search whose every hit is disabled counts as empty and announces the tile`() {
182
+ val disabledOnly =
183
+ listOf(
184
+ TestFixtures.section(
185
+ children =
186
+ arrayOf(
187
+ TestFixtures.track(title = "Gone", disabled = true),
188
+ TestFixtures.track(title = "Also gone", disabled = true),
189
+ )
190
+ )
191
+ )
192
+
193
+ // Nothing servable, so the announced count is the tile's — a controller told zero would never
194
+ // come back for it.
195
+ assertEquals(1, announcedSearchCount(disabledOnly))
196
+ }
197
+
198
+ @Test
199
+ fun `a search announces only its servable hits`() {
200
+ val mixed =
201
+ listOf(
202
+ TestFixtures.section(
203
+ children =
204
+ arrayOf(
205
+ TestFixtures.track(title = "Playable"),
206
+ TestFixtures.track(title = "Gone", disabled = true),
207
+ )
208
+ ),
209
+ TestFixtures.section(children = arrayOf(TestFixtures.track(title = "Another"))),
210
+ )
211
+
212
+ assertEquals(2, announcedSearchCount(mixed))
213
+ assertEquals(1, announcedSearchCount(null))
214
+ }
215
+
216
+ @Test
217
+ fun `an offline search serves the offline tile with the app's copy for the search path`() {
218
+ val searchPath = BrowserPathHelper.createSearchPath("jazz")
219
+ val slot = FailedSearchSlot()
220
+
221
+ val tile = createOfflineMediaItem(appCopy(offlineError(), searchPath))
222
+
223
+ assertEquals(BrowserPathHelper.OFFLINE_PATH, tile.mediaId)
224
+ assertEquals("Search needs a connection", tile.mediaMetadata.title)
225
+ assertEquals("Reconnect and try again.", tile.mediaMetadata.subtitle)
226
+ assertFalse(tile.mediaMetadata.isBrowsable!!)
227
+ assertFalse(tile.mediaMetadata.isPlayable!!)
228
+ assertTrue(BrowserPathHelper.isDeadEndPath(BrowserPathHelper.OFFLINE_PATH))
229
+ // Offline is not a search that failed: connectivity returning is what serves results again,
230
+ // so the slot stays empty and a later re-query of the query is not a failure.
231
+ assertNull(slot.errorFor("jazz"))
232
+ }
233
+
234
+ @Test
235
+ fun `the offline tile is worded per path, and title-only by default`() {
236
+ val browseTile = createOfflineMediaItem(appCopy(offlineError(), "/favorites"))
237
+ assertEquals("You're offline", browseTile.mediaMetadata.title)
238
+ assertEquals("Check your connection.", browseTile.mediaMetadata.subtitle)
239
+
240
+ val default = defaultFormattedError(offlineError())
241
+ assertEquals("Network Error", default.title)
242
+ assertNull(default.message)
243
+ assertEquals("", createOfflineMediaItem(default).mediaMetadata.subtitle)
244
+ }
245
+
246
+ @Test
247
+ fun `the browse-error tile is worded by the error its exception mapped to`() {
248
+ val failure = navigationErrorFor(HttpStatusException(503, "Service Unavailable"))
249
+
250
+ val appTile = createBrowseErrorMediaItem(appCopy(failure, "/favorites"))
251
+ assertEquals(BrowserPathHelper.ERROR_PATH, appTile.mediaId)
252
+ assertEquals("We couldn't load that", appTile.mediaMetadata.title)
253
+ assertEquals("Try again in a moment.", appTile.mediaMetadata.subtitle)
254
+
255
+ // Without a formatter, the status text the in-app error already shows.
256
+ val defaultTile = createBrowseErrorMediaItem(defaultFormattedError(failure))
257
+ assertEquals("Service Unavailable", defaultTile.mediaMetadata.title)
258
+
259
+ // A re-read of the tile has no exception left to map, so it stands on the generic failure.
260
+ assertEquals("Error", defaultFormattedError(browseFailureError()).title)
261
+ }
262
+
263
+ @Test
264
+ fun `a recorded search failure carries its error for that query only, until a success clears it`() {
265
+ val slot = FailedSearchSlot()
266
+ val failure = navigationErrorFor(HttpStatusException(500, "Internal Server Error"))
267
+ assertNull(slot.errorFor("jazz"))
268
+
269
+ slot.record("jazz", failure)
270
+ assertEquals(failure, slot.errorFor("jazz"))
271
+ assertNull(slot.errorFor("blues"))
272
+
273
+ slot.clear()
274
+ assertNull(slot.errorFor("jazz"))
275
+ }
276
+
277
+ @Test
278
+ fun `a failed search rejects with the navigation error a failed browse would raise`() {
279
+ // Nitro's Promise is JNI-backed (its constructor calls initHybrid), so the JS-facing
280
+ // AudioBrowser.search cannot be built here; what it rejects with is this.
281
+ val rejection =
282
+ NavigationErrorException(navigationErrorFor(HttpStatusException(503, "Service Unavailable")))
283
+ assertEquals(NavigationErrorType.HTTP_ERROR, rejection.error.code)
284
+ assertEquals(503.0, rejection.error.statusCode!!, 0.0)
285
+ assertEquals(false, rejection.error.statusCodeSuccess)
286
+ assertEquals("Service Unavailable", rejection.message)
287
+
288
+ assertEquals(
289
+ NavigationErrorType.NETWORK_ERROR,
290
+ navigationErrorFor(NetworkException("Network request failed")).code,
291
+ )
292
+
293
+ val unknown = navigationErrorFor(IllegalStateException())
294
+ assertEquals(NavigationErrorType.UNKNOWN_ERROR, unknown.code)
295
+ assertEquals("An unexpected error occurred", unknown.message)
296
+ }
297
+
298
+ @Test
299
+ fun `an offline search is remembered per controller, latest query only`() {
300
+ val registry = OfflineSearchRegistry()
301
+ val auto = controller("com.google.android.projection.gearhead", uid = 10)
302
+ val assistant = controller("com.google.android.googlequicksearchbox", uid = 11)
303
+
304
+ registry.record(auto, "jazz", null)
305
+ registry.record(assistant, "blues", null)
306
+ registry.record(auto, "jazz fm", null)
307
+
308
+ val pending = registry.drain()
309
+ assertEquals(2, pending.size)
310
+ assertEquals("jazz fm", pending.single { it.browser == auto }.query)
311
+ assertEquals("blues", pending.single { it.browser == assistant }.query)
312
+ // Drained, so a second connectivity change does not re-run the same searches again.
313
+ assertTrue(registry.drain().isEmpty())
314
+ }
315
+
316
+ @Test
317
+ fun `a re-run search is announced by its hits, an offline one by its tile`() {
318
+ val hits =
319
+ listOf(
320
+ TestFixtures.section(
321
+ children =
322
+ arrayOf(TestFixtures.track(title = "Jazz FM"), TestFixtures.track(title = "Jazz24"))
323
+ )
324
+ )
325
+
326
+ // The re-run goes through the same count as the first, online search, so what a controller is
327
+ // told to fetch is what onGetSearchResult then serves from the cache that search filled.
328
+ assertEquals(2, announcedSearchCount(hits))
329
+ // While offline there is nothing to count: the one tile is announced instead.
330
+ assertEquals(1, announcedSearchCount(null))
331
+ }
332
+
333
+ @Test
334
+ fun `a disconnected controller's offline search is forgotten`() {
335
+ val registry = OfflineSearchRegistry()
336
+ val auto = controller("com.google.android.projection.gearhead", uid = 10)
337
+ val assistant = controller("com.google.android.googlequicksearchbox", uid = 11)
338
+ registry.record(auto, "jazz", null)
339
+ registry.record(assistant, "blues", null)
340
+
341
+ registry.forget(auto)
342
+
343
+ assertEquals(listOf("blues"), registry.drain().map { it.query })
344
+ }
345
+
346
+ @Test
347
+ fun `a remembered search keeps the params its controller asked with`() {
348
+ val registry = OfflineSearchRegistry()
349
+ val auto = controller("com.google.android.projection.gearhead", uid = 10)
350
+ val params =
351
+ MediaLibraryService.LibraryParams.Builder()
352
+ .setExtras(Bundle().apply { putInt("page", 3) })
353
+ .build()
354
+
355
+ registry.record(auto, "jazz", params)
356
+
357
+ assertEquals(3, registry.drain().single().params?.extras?.getInt("page"))
358
+ }
359
+
360
+ @Test
361
+ fun `the failure sentinel is distinct from the empty one and dead-ends too`() {
362
+ assertNotEquals(BrowserPathHelper.EMPTY_PATH, BrowserPathHelper.ERROR_PATH)
363
+ assertTrue(BrowserPathHelper.isDeadEndPath(BrowserPathHelper.ERROR_PATH))
364
+ }
365
+
366
+ @Test
367
+ fun `a formatter parked in a non-cancellable wait falls back without hanging`() = runTest {
368
+ val default = FormattedNavigationError("Nothing here", null)
369
+ var parked: Continuation<FormattedNavigationError?>? = null
370
+
371
+ try {
372
+ // Nitro's Promise.await parks exactly like this — suspendCoroutine, so cancelling the
373
+ // coroutine never unparks it. The hop must be abandoned, not waited on.
374
+ val formatted =
375
+ formattedOrDefault(backgroundScope, default) {
376
+ suspendCoroutine { continuation -> parked = continuation }
377
+ }
378
+
379
+ assertNotNull(parked)
380
+ assertEquals(default, formatted)
381
+ } finally {
382
+ parked?.resume(null)
383
+ }
384
+ }
385
+ }
@@ -0,0 +1,94 @@
1
+ package com.audiobrowser.player
2
+
3
+ import okhttp3.HttpUrl.Companion.toHttpUrl
4
+ import okhttp3.Request
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Assert.assertNull
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Test
10
+
11
+ /**
12
+ * OkHttp drops `Authorization` across a cross-origin redirect by itself; these cover the rest,
13
+ * which it forwards and [CrossOriginHeaderStripper.stripCallerHeaders] removes.
14
+ */
15
+ class CrossOriginHeaderStripperTest {
16
+
17
+ private val api = "https://api.example.com/stream/7".toHttpUrl()
18
+
19
+ @Test
20
+ fun `same origin ignores the path`() {
21
+ assertTrue(
22
+ CrossOriginHeaderStripper.sameOrigin(
23
+ api,
24
+ "https://api.example.com/signed/track.mp3".toHttpUrl(),
25
+ )
26
+ )
27
+ }
28
+
29
+ @Test
30
+ fun `a different host, port or scheme is a different origin`() {
31
+ assertFalse(
32
+ CrossOriginHeaderStripper.sameOrigin(api, "https://cdn.example.com/track.mp3".toHttpUrl())
33
+ )
34
+ assertFalse(
35
+ CrossOriginHeaderStripper.sameOrigin(
36
+ api,
37
+ "https://api.example.com:8443/track.mp3".toHttpUrl(),
38
+ )
39
+ )
40
+ assertFalse(
41
+ CrossOriginHeaderStripper.sameOrigin(api, "http://api.example.com/track.mp3".toHttpUrl())
42
+ )
43
+ }
44
+
45
+ @Test
46
+ fun `strips every caller header, whatever its name`() {
47
+ val request =
48
+ Request.Builder()
49
+ .url(api)
50
+ .header("Authorization", "Bearer token")
51
+ .header("X-Api-Key", "k")
52
+ .header("Cookie", "session=abc")
53
+ .build()
54
+
55
+ val stripped = CrossOriginHeaderStripper.stripCallerHeaders(request)
56
+
57
+ assertNull(stripped.header("Authorization"))
58
+ assertNull(stripped.header("X-Api-Key"))
59
+ assertNull(stripped.header("Cookie"))
60
+ }
61
+
62
+ @Test
63
+ fun `keeps the transport headers the hop needs`() {
64
+ val request =
65
+ Request.Builder()
66
+ .url(api)
67
+ .header("Range", "bytes=1024-")
68
+ .header("accept-encoding", "identity")
69
+ .header("User-Agent", "react-native-audio-browser")
70
+ .header("X-Api-Key", "k")
71
+ .build()
72
+
73
+ val stripped = CrossOriginHeaderStripper.stripCallerHeaders(request)
74
+
75
+ // a dropped Range would restart the read from zero on every seek past a redirect
76
+ assertEquals("bytes=1024-", stripped.header("Range"))
77
+ assertEquals("identity", stripped.header("Accept-Encoding"))
78
+ assertEquals("react-native-audio-browser", stripped.header("User-Agent"))
79
+ assertNull(stripped.header("X-Api-Key"))
80
+ }
81
+
82
+ @Test
83
+ fun `keeps media3's ICY metadata request across a hop`() {
84
+ // ProgressiveMediaPeriod asks for Shoutcast metadata on the DataSpec, the same channel the
85
+ // config's headers arrive on. Dropping it costs icy-metaint, so onMetadata never fires again.
86
+ val request =
87
+ Request.Builder().url(api).header("Icy-MetaData", "1").header("X-Api-Key", "k").build()
88
+
89
+ val stripped = CrossOriginHeaderStripper.stripCallerHeaders(request)
90
+
91
+ assertEquals("1", stripped.header("Icy-MetaData"))
92
+ assertNull(stripped.header("X-Api-Key"))
93
+ }
94
+ }
@@ -156,8 +156,16 @@ function hasSearch() {
156
156
  /**
157
157
  * Searches for tracks using the configured search source.
158
158
  *
159
+ * Resolves `[]` only when the source found nothing; a failed search rejects,
160
+ * so a caller can tell "no results" from "the request failed". The rejection's
161
+ * message describes the failure — native platforms reject with the underlying
162
+ * throwable and JS sees only its message. On web the `Error` also carries a
163
+ * `code`, the {@link NavigationErrorType} the browse path reports as a
164
+ * {@link NavigationError} (`'http-error'`, `'network-error'`).
165
+ *
159
166
  * @param query - The search query string
160
167
  * @returns Promise resolving to an array of matching tracks
168
+ * @throws When the search source or its request fails
161
169
  *
162
170
  * @see {@link configureBrowser}
163
171
  *
@@ -1 +1 @@
1
- {"version":3,"names":["_native","require","_NativeUpdatedValue","_useNativeUpdatedValue","_browserConfig","currentConfiguration","configureBrowser","configuration","__DEV__","validateBrowserConfiguration","nativeBrowser","toNativeConfig","getBrowserConfiguration","navigate","pathOrTrack","navigatePath","navigateTrack","getPath","path","onPathChanged","exports","NativeUpdatedValue","emitterize","cb","usePath","useNativeUpdatedValue","getContent","onContentChanged","useContent","getTabs","tabs","onTabsChanged","useTabs","notifyContentChanged","invalidateAllContent","hasSearch","config","searchRoute","routes","find","r","SEARCH_ROUTE_PATH","searchCallback","searchConfig","search","query"],"sourceRoot":"../../../src","sources":["features/browser.ts"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["_native","require","_NativeUpdatedValue","_useNativeUpdatedValue","_browserConfig","currentConfiguration","configureBrowser","configuration","__DEV__","validateBrowserConfiguration","nativeBrowser","toNativeConfig","getBrowserConfiguration","navigate","pathOrTrack","navigatePath","navigateTrack","getPath","path","onPathChanged","exports","NativeUpdatedValue","emitterize","cb","usePath","useNativeUpdatedValue","getContent","onContentChanged","useContent","getTabs","tabs","onTabsChanged","useTabs","notifyContentChanged","invalidateAllContent","hasSearch","config","searchRoute","routes","find","r","SEARCH_ROUTE_PATH","searchCallback","searchConfig","search","query"],"sourceRoot":"../../../src","sources":["features/browser.ts"],"mappings":"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","ignoreList":[]}