react-native-audio-browser 0.2.0-next.1 → 0.2.0-next.10

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 (284) hide show
  1. package/README.md +22 -18
  2. package/android/build.gradle +7 -5
  3. package/android/gradle.properties +1 -1
  4. package/android/src/main/AndroidManifest.xml +3 -1
  5. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +76 -21
  6. package/android/src/main/java/com/audiobrowser/AudioBrowserLifecycleModule.kt +38 -0
  7. package/android/src/main/java/com/audiobrowser/AudioBrowserPackage.kt +18 -2
  8. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +48 -0
  9. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +1 -2
  10. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +1 -2
  11. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +6 -7
  12. package/android/src/main/java/com/audiobrowser/http/HttpClient.kt +20 -3
  13. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +11 -11
  14. package/android/src/main/java/com/audiobrowser/player/CapabilityControls.kt +111 -58
  15. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +9 -5
  16. package/android/src/main/java/com/audiobrowser/player/MediaSessionCommandManager.kt +218 -252
  17. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +0 -1
  18. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -4
  19. package/android/src/main/java/com/audiobrowser/player/Player.kt +30 -8
  20. package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
  21. package/android/src/main/java/com/audiobrowser/player/TransformingDataSource.kt +2 -1
  22. package/android/src/main/java/com/audiobrowser/tls/AiaCertChaser.kt +21 -6
  23. package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +109 -0
  24. package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +16 -2
  25. package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +59 -3
  26. package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
  27. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
  28. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +87 -0
  29. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +0 -1
  30. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
  31. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
  32. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +100 -56
  33. package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
  34. package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
  35. package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
  36. package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +46 -0
  37. package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +113 -0
  38. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherTest.kt +83 -0
  39. package/ios/Browser/BrowserManager.swift +2 -27
  40. package/ios/Browser/SearchDrillIn.swift +1 -1
  41. package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
  42. package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
  43. package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
  44. package/ios/HybridAudioBrowser.swift +87 -56
  45. package/ios/Model/RemoteCommand.swift +18 -0
  46. package/ios/Model/TrackPlayerError.swift +2 -2
  47. package/ios/Option/SessionCategories.swift +3 -2
  48. package/ios/Player/MediaIntentCriteria.swift +4 -4
  49. package/ios/Player/MediaLoader.swift +3 -1
  50. package/ios/Player/PlaybackCoordinator.swift +4 -1
  51. package/ios/Player/PlaybackErrorHandler.swift +4 -4
  52. package/ios/Player/QueueManager.swift +10 -3
  53. package/ios/Player/RetryManager.swift +79 -18
  54. package/ios/Player/ShuffleOrder.swift +10 -0
  55. package/ios/Player/SleepTimerManager.swift +43 -15
  56. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
  57. package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
  58. package/ios/Tests/LRUCacheTests.swift +111 -0
  59. package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
  60. package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
  61. package/ios/Tests/MediaLoaderTests.swift +10 -13
  62. package/ios/Tests/PlaybackCoordinatorTests.swift +10 -7
  63. package/ios/Tests/PlaybackErrorHandlerTests.swift +3 -1
  64. package/ios/Tests/PlayerTimeObserverTests.swift +37 -0
  65. package/ios/Tests/QueueManagerTests.swift +46 -0
  66. package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
  67. package/ios/Tests/RemoteCommandTests.swift +67 -0
  68. package/ios/Tests/RetryManagerTests.swift +89 -0
  69. package/ios/Tests/SectionScopeTests.swift +2 -2
  70. package/ios/Tests/SleepTimerManagerTests.swift +22 -19
  71. package/ios/Tests/Support/ManualSleepTimerScheduler.swift +47 -0
  72. package/ios/Tests/Support/TestTypes.swift +1 -1
  73. package/ios/Tests/TabBarEntriesTests.swift +7 -7
  74. package/ios/TrackPlayer.swift +6 -1
  75. package/ios/TrackPlayerCallbacks.swift +5 -38
  76. package/ios/Util/LRUCache.swift +4 -1
  77. package/lib/commonjs/AudioBrowser.js +0 -11
  78. package/lib/commonjs/AudioBrowser.js.map +1 -1
  79. package/lib/commonjs/CLAUDE.md +52 -0
  80. package/lib/commonjs/features/browser.js +1 -1
  81. package/lib/commonjs/features/browser.js.map +1 -1
  82. package/lib/commonjs/features/carConnection.js +1 -1
  83. package/lib/commonjs/features/equalizer.js +1 -1
  84. package/lib/commonjs/features/errors.js +3 -3
  85. package/lib/commonjs/features/favorites.js +1 -1
  86. package/lib/commonjs/features/gate.js +16 -1
  87. package/lib/commonjs/features/gate.js.map +1 -1
  88. package/lib/commonjs/features/metadata.js +20 -3
  89. package/lib/commonjs/features/metadata.js.map +1 -1
  90. package/lib/commonjs/features/network.js +1 -1
  91. package/lib/commonjs/features/nowPlaying.js +1 -1
  92. package/lib/commonjs/features/output.js +1 -1
  93. package/lib/commonjs/features/playback/playWhenReady.js +1 -1
  94. package/lib/commonjs/features/playback/playing.js +1 -1
  95. package/lib/commonjs/features/playback/progress.js +1 -1
  96. package/lib/commonjs/features/playback/state.js +1 -1
  97. package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
  98. package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
  99. package/lib/commonjs/features/playback/volume.js +1 -1
  100. package/lib/commonjs/features/player/options.js +48 -34
  101. package/lib/commonjs/features/player/options.js.map +1 -1
  102. package/lib/commonjs/features/player/setup.js +15 -8
  103. package/lib/commonjs/features/player/setup.js.map +1 -1
  104. package/lib/commonjs/features/player/validateOptions.js +21 -0
  105. package/lib/commonjs/features/player/validateOptions.js.map +1 -0
  106. package/lib/commonjs/features/queue/activeTrack.js +1 -1
  107. package/lib/commonjs/features/queue/queue.js +2 -2
  108. package/lib/commonjs/features/queue/repeatMode.js +1 -1
  109. package/lib/commonjs/features/queue/shuffle.js +1 -1
  110. package/lib/commonjs/features/remoteControls.js +13 -92
  111. package/lib/commonjs/features/remoteControls.js.map +1 -1
  112. package/lib/commonjs/features/sleepTimer.js +13 -1
  113. package/lib/commonjs/features/sleepTimer.js.map +1 -1
  114. package/lib/commonjs/test-utils/reactNativeStub.js +65 -0
  115. package/lib/commonjs/test-utils/reactNativeStub.js.map +1 -0
  116. package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
  117. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  118. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  119. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  120. package/lib/commonjs/web/browser/BrowserManager.js +1 -1
  121. package/lib/module/AudioBrowser.js +1 -1
  122. package/lib/module/AudioBrowser.js.map +1 -1
  123. package/lib/module/CLAUDE.md +52 -0
  124. package/lib/module/features/browser.js +1 -1
  125. package/lib/module/features/browser.js.map +1 -1
  126. package/lib/module/features/carConnection.js +1 -1
  127. package/lib/module/features/equalizer.js +1 -1
  128. package/lib/module/features/errors.js +3 -3
  129. package/lib/module/features/favorites.js +1 -1
  130. package/lib/module/features/gate.js +16 -1
  131. package/lib/module/features/gate.js.map +1 -1
  132. package/lib/module/features/metadata.js +20 -3
  133. package/lib/module/features/metadata.js.map +1 -1
  134. package/lib/module/features/network.js +1 -1
  135. package/lib/module/features/nowPlaying.js +1 -1
  136. package/lib/module/features/output.js +1 -1
  137. package/lib/module/features/playback/playWhenReady.js +1 -1
  138. package/lib/module/features/playback/playing.js +1 -1
  139. package/lib/module/features/playback/progress.js +1 -1
  140. package/lib/module/features/playback/state.js +1 -1
  141. package/lib/module/features/playback/trackPlaybackTime.js +0 -5
  142. package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
  143. package/lib/module/features/playback/volume.js +1 -1
  144. package/lib/module/features/player/options.js +47 -32
  145. package/lib/module/features/player/options.js.map +1 -1
  146. package/lib/module/features/player/setup.js +14 -7
  147. package/lib/module/features/player/setup.js.map +1 -1
  148. package/lib/module/features/player/validateOptions.js +17 -0
  149. package/lib/module/features/player/validateOptions.js.map +1 -0
  150. package/lib/module/features/queue/activeTrack.js +1 -1
  151. package/lib/module/features/queue/queue.js +2 -2
  152. package/lib/module/features/queue/repeatMode.js +1 -1
  153. package/lib/module/features/queue/shuffle.js +1 -1
  154. package/lib/module/features/remoteControls.js +13 -92
  155. package/lib/module/features/remoteControls.js.map +1 -1
  156. package/lib/module/features/sleepTimer.js +13 -1
  157. package/lib/module/features/sleepTimer.js.map +1 -1
  158. package/lib/module/test-utils/reactNativeStub.js +58 -0
  159. package/lib/module/test-utils/reactNativeStub.js.map +1 -0
  160. package/lib/module/utils/LazyNativeEmitter.js +8 -5
  161. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  162. package/lib/module/web/NativeAudioBrowser.js +1 -1
  163. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  164. package/lib/module/web/browser/BrowserManager.js +1 -1
  165. package/lib/typescript/src/AudioBrowser.d.ts +0 -1
  166. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  167. package/lib/typescript/src/features/carConnection.d.ts +1 -1
  168. package/lib/typescript/src/features/equalizer.d.ts +1 -1
  169. package/lib/typescript/src/features/errors.d.ts +3 -3
  170. package/lib/typescript/src/features/favorites.d.ts +1 -1
  171. package/lib/typescript/src/features/gate.d.ts +18 -2
  172. package/lib/typescript/src/features/gate.d.ts.map +1 -1
  173. package/lib/typescript/src/features/metadata.d.ts +20 -3
  174. package/lib/typescript/src/features/metadata.d.ts.map +1 -1
  175. package/lib/typescript/src/features/network.d.ts +1 -1
  176. package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
  177. package/lib/typescript/src/features/output.d.ts +1 -1
  178. package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
  179. package/lib/typescript/src/features/playback/playing.d.ts +1 -1
  180. package/lib/typescript/src/features/playback/progress.d.ts +1 -1
  181. package/lib/typescript/src/features/playback/state.d.ts +1 -1
  182. package/lib/typescript/src/features/playback/volume.d.ts +1 -1
  183. package/lib/typescript/src/features/player/options.d.ts +87 -37
  184. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  185. package/lib/typescript/src/features/player/setup.d.ts +14 -4
  186. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  187. package/lib/typescript/src/features/player/validateOptions.d.ts +9 -0
  188. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
  189. package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
  190. package/lib/typescript/src/features/queue/queue.d.ts +2 -2
  191. package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
  192. package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
  193. package/lib/typescript/src/features/remoteControls.d.ts +23 -22
  194. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  195. package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
  196. package/lib/typescript/src/features/sleepTimer.d.ts.map +1 -1
  197. package/lib/typescript/src/test-utils/reactNativeStub.d.ts +36 -0
  198. package/lib/typescript/src/test-utils/reactNativeStub.d.ts.map +1 -0
  199. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
  200. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  201. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -1
  202. package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
  203. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +5 -5
  204. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +11 -11
  205. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +1 -1
  206. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +5 -5
  207. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +5 -5
  208. package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
  209. package/nitrogen/generated/android/c++/JOptions.hpp +5 -5
  210. package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
  211. package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
  212. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
  213. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
  214. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
  215. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +1 -1
  216. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
  217. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
  218. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
  219. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
  220. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +36 -51
  221. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +6 -6
  222. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +8 -8
  223. package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
  224. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +1 -1
  225. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +2 -2
  226. package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
  227. package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
  228. package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
  229. package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
  230. package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
  231. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +1 -1
  232. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +1 -1
  233. package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
  234. package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
  235. package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
  236. package/package.json +10 -3
  237. package/src/AudioBrowser.ts +1 -1
  238. package/src/CLAUDE.md +52 -0
  239. package/src/features/battery.test.ts +79 -0
  240. package/src/features/browser.ts +1 -1
  241. package/src/features/carConnection.ts +1 -1
  242. package/src/features/equalizer.ts +1 -1
  243. package/src/features/errors.ts +3 -3
  244. package/src/features/favorites.ts +1 -1
  245. package/src/features/gate.ts +16 -1
  246. package/src/features/hook-wiring.test.ts +254 -0
  247. package/src/features/metadata.ts +20 -3
  248. package/src/features/network.ts +1 -1
  249. package/src/features/nowPlaying.ts +1 -1
  250. package/src/features/output.ts +1 -1
  251. package/src/features/playback/playWhenReady.ts +1 -1
  252. package/src/features/playback/playing.ts +1 -1
  253. package/src/features/playback/progress.test.ts +169 -0
  254. package/src/features/playback/progress.ts +1 -1
  255. package/src/features/playback/state.ts +1 -1
  256. package/src/features/playback/trackPlaybackTime.test.ts +16 -6
  257. package/src/features/playback/trackPlaybackTime.ts +0 -5
  258. package/src/features/playback/volume.ts +1 -1
  259. package/src/features/player/options.ts +99 -57
  260. package/src/features/player/setup.test.ts +12 -4
  261. package/src/features/player/setup.ts +19 -9
  262. package/src/features/player/validateOptions.ts +19 -0
  263. package/src/features/queue/activeTrack.ts +1 -1
  264. package/src/features/queue/queue.ts +2 -2
  265. package/src/features/queue/repeatMode.ts +1 -1
  266. package/src/features/queue/shuffle.ts +1 -1
  267. package/src/features/remoteControls.ts +13 -92
  268. package/src/features/sleepTimer.test.ts +172 -0
  269. package/src/features/sleepTimer.ts +9 -1
  270. package/src/public-surface.test.ts +107 -0
  271. package/src/specs/audio-browser.nitro.ts +1 -1
  272. package/src/test-utils/reactNativeStub.ts +60 -0
  273. package/src/utils/LazyNativeEmitter.ts +9 -6
  274. package/src/utils/useDebug.test.ts +182 -0
  275. package/src/utils/useNativeUpdatedValue.test.ts +103 -0
  276. package/src/web/NativeAudioBrowser.ts +1 -1
  277. package/src/web/browser/BrowserManager.ts +1 -1
  278. package/ios/Model/MediaURL.swift +0 -67
  279. package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
  280. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
  281. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
  282. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
  283. package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
  284. package/nitrogen/generated/shared/c++/NotificationButtonLayout.hpp +0 -102
@@ -1,8 +1,8 @@
1
1
  package com.audiobrowser.player
2
2
 
3
- import com.margelo.nitro.audiobrowser.NotificationButton
4
- import com.margelo.nitro.audiobrowser.NotificationButtonLayout
5
3
  import com.margelo.nitro.audiobrowser.PlayerCapabilities
4
+ import com.margelo.nitro.audiobrowser.RemoteButton
5
+ import com.margelo.nitro.audiobrowser.RemoteButtonLayout
6
6
  import com.margelo.nitro.audiobrowser.Variant_Boolean_FavoriteConfig
7
7
  import org.junit.Assert.assertEquals
8
8
  import org.junit.Assert.assertFalse
@@ -12,8 +12,7 @@ import org.junit.Test
12
12
  /**
13
13
  * Truth tables for the pure Capability decision core: [PlayerCapabilities.isEnabled] (one rule for
14
14
  * "available by default, only false disables; favorite is opt-in; play/pause is one control") and
15
- * [deriveNotificationSlots] (the single notification slot derivation that both the button layout
16
- * and the notification player commands consume).
15
+ * [deriveButtonSlots] (the single slot derivation every Media3 assembly consumes).
17
16
  */
18
17
  class CapabilityControlsTest {
19
18
 
@@ -43,117 +42,162 @@ class CapabilityControlsTest {
43
42
  playbackRate = null,
44
43
  )
45
44
 
45
+ private fun layout(
46
+ back: RemoteButton? = null,
47
+ forward: RemoteButton? = null,
48
+ overflow: Array<RemoteButton> = emptyArray(),
49
+ ) = RemoteButtonLayout(back = back, forward = forward, overflow = overflow)
50
+
46
51
  private fun slots(
47
52
  capabilities: PlayerCapabilities,
48
- layout: NotificationButtonLayout? = null,
49
- ): List<Pair<NotificationButton, NotificationSlot>> =
50
- deriveNotificationSlots(capabilities, layout).map { it.button to it.slot }
53
+ layout: RemoteButtonLayout? = null,
54
+ ): List<Pair<RemoteButton, ButtonSlot>> =
55
+ deriveButtonSlots(capabilities, layout).map { it.button to it.slot }
51
56
 
52
57
  // MARK: isEnabled
53
58
 
54
59
  @Test
55
60
  fun `everything but favorite is enabled by default`() {
56
61
  val defaults = capabilities()
57
- for (control in Control.entries) {
58
- assertEquals("$control", control != Control.FAVORITE, defaults.isEnabled(control))
62
+ for (control in Capability.entries) {
63
+ assertEquals("$control", control != Capability.FAVORITE, defaults.isEnabled(control))
59
64
  }
60
65
  }
61
66
 
62
67
  @Test
63
68
  fun `only an explicit false disables a control`() {
64
69
  val caps = capabilities(skipToNext = false, jumpBackward = false)
65
- assertFalse(caps.isEnabled(Control.SKIP_TO_NEXT))
66
- assertFalse(caps.isEnabled(Control.JUMP_BACKWARD))
67
- assertTrue(caps.isEnabled(Control.SKIP_TO_PREVIOUS))
68
- assertTrue(caps.isEnabled(Control.JUMP_FORWARD))
70
+ assertFalse(caps.isEnabled(Capability.SKIP_TO_NEXT))
71
+ assertFalse(caps.isEnabled(Capability.JUMP_BACKWARD))
72
+ assertTrue(caps.isEnabled(Capability.SKIP_TO_PREVIOUS))
73
+ assertTrue(caps.isEnabled(Capability.JUMP_FORWARD))
69
74
  }
70
75
 
71
76
  @Test
72
77
  fun `play pause is one control - it survives a single half being disabled`() {
73
- assertTrue(capabilities(play = false).isEnabled(Control.PLAY_PAUSE))
74
- assertTrue(capabilities(pause = false).isEnabled(Control.PLAY_PAUSE))
75
- assertFalse(capabilities(play = false, pause = false).isEnabled(Control.PLAY_PAUSE))
78
+ assertTrue(capabilities(play = false).isEnabled(Capability.PLAY_PAUSE))
79
+ assertTrue(capabilities(pause = false).isEnabled(Capability.PLAY_PAUSE))
80
+ assertFalse(capabilities(play = false, pause = false).isEnabled(Capability.PLAY_PAUSE))
76
81
  }
77
82
 
78
83
  @Test
79
84
  fun `favorite is opt-in`() {
80
- assertFalse(capabilities().isEnabled(Control.FAVORITE))
81
- assertFalse(capabilities(favorite = false).isEnabled(Control.FAVORITE))
82
- assertTrue(capabilities(favorite = true).isEnabled(Control.FAVORITE))
85
+ assertFalse(capabilities().isEnabled(Capability.FAVORITE))
86
+ assertFalse(capabilities(favorite = false).isEnabled(Capability.FAVORITE))
87
+ assertTrue(capabilities(favorite = true).isEnabled(Capability.FAVORITE))
83
88
  }
84
89
 
85
- // MARK: deriveNotificationSlots — capability defaults
90
+ // MARK: deriveButtonSlots — capability defaults
86
91
 
87
92
  @Test
88
- fun `default layout puts skip on the primary slots, jump on the secondary, favorite in overflow`() {
93
+ fun `default layout puts skip on the primary positions, jump and favorite in overflow`() {
89
94
  val derived = slots(capabilities(favorite = true))
90
95
  assertEquals(
91
96
  listOf(
92
- NotificationButton.SKIP_TO_PREVIOUS to NotificationSlot.BACK,
93
- NotificationButton.SKIP_TO_NEXT to NotificationSlot.FORWARD,
94
- NotificationButton.JUMP_BACKWARD to NotificationSlot.BACK_SECONDARY,
95
- NotificationButton.JUMP_FORWARD to NotificationSlot.FORWARD_SECONDARY,
96
- NotificationButton.FAVORITE to NotificationSlot.OVERFLOW,
97
+ RemoteButton.SKIP_TO_PREVIOUS to ButtonSlot.BACK,
98
+ RemoteButton.SKIP_TO_NEXT to ButtonSlot.FORWARD,
99
+ RemoteButton.JUMP_BACKWARD to ButtonSlot.OVERFLOW,
100
+ RemoteButton.JUMP_FORWARD to ButtonSlot.OVERFLOW,
101
+ RemoteButton.FAVORITE to ButtonSlot.OVERFLOW,
97
102
  ),
98
103
  derived,
99
104
  )
100
105
  }
101
106
 
102
107
  @Test
103
- fun `jump falls back to the primary slot when skip is disabled`() {
108
+ fun `jump falls back to a primary position when skip is disabled`() {
104
109
  val derived = slots(capabilities(skipToPrevious = false, skipToNext = false))
105
110
  assertEquals(
106
111
  listOf(
107
- NotificationButton.JUMP_BACKWARD to NotificationSlot.BACK,
108
- NotificationButton.JUMP_FORWARD to NotificationSlot.FORWARD,
112
+ RemoteButton.JUMP_BACKWARD to ButtonSlot.BACK,
113
+ RemoteButton.JUMP_FORWARD to ButtonSlot.FORWARD,
109
114
  ),
110
115
  derived,
111
116
  )
112
117
  }
113
118
 
114
119
  @Test
115
- fun `a fully disabled side leaves its slot empty`() {
120
+ fun `a fully disabled side leaves its position empty`() {
116
121
  val derived =
117
122
  slots(capabilities(skipToPrevious = false, jumpBackward = false, jumpForward = false))
118
- assertEquals(listOf(NotificationButton.SKIP_TO_NEXT to NotificationSlot.FORWARD), derived)
123
+ assertEquals(listOf(RemoteButton.SKIP_TO_NEXT to ButtonSlot.FORWARD), derived)
119
124
  }
120
125
 
121
- // MARK: deriveNotificationSlots — explicit layout
126
+ // MARK: deriveButtonSlots — explicit layout
122
127
 
123
128
  @Test
124
- fun `explicit layout maps every slot and keeps overflow order`() {
125
- val layout =
126
- NotificationButtonLayout(
127
- back = NotificationButton.JUMP_BACKWARD,
128
- forward = NotificationButton.JUMP_FORWARD,
129
- backSecondary = null,
130
- forwardSecondary = null,
131
- overflow = arrayOf(NotificationButton.SKIP_TO_PREVIOUS, NotificationButton.SKIP_TO_NEXT),
129
+ fun `an explicit layout maps every field and keeps overflow order`() {
130
+ val derived =
131
+ slots(
132
+ capabilities(),
133
+ layout(
134
+ back = RemoteButton.JUMP_BACKWARD,
135
+ forward = RemoteButton.JUMP_FORWARD,
136
+ overflow = arrayOf(RemoteButton.SKIP_TO_PREVIOUS, RemoteButton.SKIP_TO_NEXT),
137
+ ),
132
138
  )
133
- val derived = slots(capabilities(), layout)
134
139
  assertEquals(
135
140
  listOf(
136
- NotificationButton.JUMP_BACKWARD to NotificationSlot.BACK,
137
- NotificationButton.JUMP_FORWARD to NotificationSlot.FORWARD,
138
- NotificationButton.SKIP_TO_PREVIOUS to NotificationSlot.OVERFLOW,
139
- NotificationButton.SKIP_TO_NEXT to NotificationSlot.OVERFLOW,
141
+ RemoteButton.JUMP_BACKWARD to ButtonSlot.BACK,
142
+ RemoteButton.JUMP_FORWARD to ButtonSlot.FORWARD,
143
+ RemoteButton.SKIP_TO_PREVIOUS to ButtonSlot.OVERFLOW,
144
+ RemoteButton.SKIP_TO_NEXT to ButtonSlot.OVERFLOW,
140
145
  ),
141
146
  derived,
142
147
  )
143
148
  }
144
149
 
145
150
  @Test
146
- fun `explicit layout entries are dropped when their capability is disabled`() {
147
- val layout =
148
- NotificationButtonLayout(
149
- back = NotificationButton.SKIP_TO_PREVIOUS,
150
- forward = NotificationButton.SKIP_TO_NEXT,
151
- backSecondary = null,
152
- forwardSecondary = null,
153
- overflow = arrayOf(NotificationButton.FAVORITE),
151
+ fun `a null field leaves that position empty`() {
152
+ val derived = slots(capabilities(), layout(back = null, forward = RemoteButton.SKIP_TO_NEXT))
153
+ assertEquals(listOf(RemoteButton.SKIP_TO_NEXT to ButtonSlot.FORWARD), derived)
154
+ }
155
+
156
+ @Test
157
+ fun `a layout is never merged with the capability defaults`() {
158
+ // Nitro collapses an omitted and a null enum field to the same Kotlin null, so a per-field
159
+ // merge is not expressible: an empty position must stay empty rather than falling back to the
160
+ // capability default. Every capability here is enabled, so a merge would add skip and jump.
161
+ val derived =
162
+ slots(capabilities(favorite = true), layout(overflow = arrayOf(RemoteButton.FAVORITE)))
163
+ assertEquals(listOf(RemoteButton.FAVORITE to ButtonSlot.OVERFLOW), derived)
164
+ }
165
+
166
+ @Test
167
+ fun `a disabled entry drops out without promoting anything into its position`() {
168
+ // favorite unset (= disabled), skipToPrevious explicitly off. skip-to-next must stay on
169
+ // FORWARD rather than sliding into the vacated BACK position.
170
+ val derived =
171
+ slots(
172
+ capabilities(skipToPrevious = false),
173
+ layout(
174
+ back = RemoteButton.SKIP_TO_PREVIOUS,
175
+ forward = RemoteButton.SKIP_TO_NEXT,
176
+ overflow = arrayOf(RemoteButton.FAVORITE),
177
+ ),
154
178
  )
155
- // favorite unset (= disabled), skipToPrevious explicitly off
156
- val derived = slots(capabilities(skipToPrevious = false), layout)
157
- assertEquals(listOf(NotificationButton.SKIP_TO_NEXT to NotificationSlot.FORWARD), derived)
179
+ assertEquals(listOf(RemoteButton.SKIP_TO_NEXT to ButtonSlot.FORWARD), derived)
180
+ }
181
+
182
+ // MARK: sameAs
183
+
184
+ @Test
185
+ fun `sameAs compares overflow by content, not by array identity`() {
186
+ val a = layout(back = RemoteButton.JUMP_BACKWARD, overflow = arrayOf(RemoteButton.FAVORITE))
187
+ val b = layout(back = RemoteButton.JUMP_BACKWARD, overflow = arrayOf(RemoteButton.FAVORITE))
188
+ // Distinct Array instances — data-class `==` would call these different and republish the
189
+ // layout to Android Auto on every unrelated options update.
190
+ assertTrue(a.sameAs(b))
191
+ assertFalse(a.sameAs(layout(back = RemoteButton.JUMP_BACKWARD)))
192
+ assertFalse(
193
+ a.sameAs(layout(back = RemoteButton.JUMP_FORWARD, overflow = arrayOf(RemoteButton.FAVORITE)))
194
+ )
195
+ }
196
+
197
+ @Test
198
+ fun `sameAs treats null as its own layout`() {
199
+ assertTrue(null.sameAs(null))
200
+ assertFalse(layout(back = RemoteButton.SKIP_TO_NEXT).sameAs(null))
201
+ assertFalse(null.sameAs(layout(back = RemoteButton.SKIP_TO_NEXT)))
158
202
  }
159
203
  }
@@ -0,0 +1,45 @@
1
+ package com.audiobrowser.player
2
+
3
+ import androidx.media3.session.CommandButton
4
+ import org.junit.Assert.assertArrayEquals
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ /**
9
+ * Guards the slot preference lists handed to Media3.
10
+ *
11
+ * The invariant that matters is the OVERFLOW fallback: every flattening keeps a button only if it
12
+ * won SLOT_BACK, won SLOT_FORWARD, or names SLOT_OVERFLOW. A slot that stops naming OVERFLOW
13
+ * silently deletes its button from the notification and the car the moment its primary position is
14
+ * unavailable, so the loop below asserts the property for every slot rather than only today's
15
+ * three.
16
+ */
17
+ class MediaSessionSlotMappingTest {
18
+
19
+ @Test
20
+ fun `primary slots are preferred with an overflow fallback`() {
21
+ assertArrayEquals(
22
+ intArrayOf(CommandButton.SLOT_BACK, CommandButton.SLOT_OVERFLOW),
23
+ media3SlotsFor(ButtonSlot.BACK),
24
+ )
25
+ assertArrayEquals(
26
+ intArrayOf(CommandButton.SLOT_FORWARD, CommandButton.SLOT_OVERFLOW),
27
+ media3SlotsFor(ButtonSlot.FORWARD),
28
+ )
29
+ }
30
+
31
+ @Test
32
+ fun `overflow does not repeat itself`() {
33
+ assertArrayEquals(intArrayOf(CommandButton.SLOT_OVERFLOW), media3SlotsFor(ButtonSlot.OVERFLOW))
34
+ }
35
+
36
+ @Test
37
+ fun `every slot names OVERFLOW so nothing can be dropped`() {
38
+ for (slot in ButtonSlot.entries) {
39
+ assertTrue(
40
+ "$slot must name SLOT_OVERFLOW or its button is dropped from the notification and the car",
41
+ media3SlotsFor(slot).contains(CommandButton.SLOT_OVERFLOW),
42
+ )
43
+ }
44
+ }
45
+ }
@@ -132,8 +132,8 @@ class PlaybackErrorClassifierTest {
132
132
 
133
133
  /**
134
134
  * Load errors are raw IOExceptions from the transport layer; the policy only retries network
135
- * ones, so the advisory classification is UNREACHABLE unless the device is offline or the
136
- * server actually answered.
135
+ * ones, so the advisory classification is UNREACHABLE unless the device is offline or the server
136
+ * actually answered.
137
137
  */
138
138
  @Test
139
139
  fun `retrying transport failure is unreachable`() {
@@ -158,8 +158,7 @@ class PlaybackErrorClassifierTest {
158
158
  /** The HTTP status is dug out of the cause chain and mapped through the shared status table. */
159
159
  @Test
160
160
  fun `retrying HTTP failure carries the status`() {
161
- val error =
162
- PlaybackErrorClassifier.retryingLoadError(httpException(503), online = true)
161
+ val error = PlaybackErrorClassifier.retryingLoadError(httpException(503), online = true)
163
162
  assertEquals(PlaybackErrorKind.SERVER_ERROR, error.kind)
164
163
  assertEquals(503.0, error.statusCode!!, 0.0)
165
164
  assertEquals(true, error.retrying)
@@ -201,9 +201,9 @@ class RetryLoadErrorHandlingPolicyBudgetTest {
201
201
  }
202
202
 
203
203
  /**
204
- * Offline failures must not burn the first-connect budget (a station tapped in a tunnel parks
205
- * for connectivity); the budget's clock starts at the first error observed online, so a dead
206
- * station still gets a fast verdict after restoration.
204
+ * Offline failures must not burn the first-connect budget (a station tapped in a tunnel parks for
205
+ * connectivity); the budget's clock starts at the first error observed online, so a dead station
206
+ * still gets a fast verdict after restoration.
207
207
  */
208
208
  @Test
209
209
  fun `offline time does not burn the first-connect budget`() {
@@ -10,9 +10,55 @@ import java.util.Date
10
10
  import org.junit.Assert.assertEquals
11
11
  import org.junit.Assert.assertThrows
12
12
  import org.junit.Test
13
+ import org.mockito.Mockito.mock
14
+ import org.mockito.Mockito.`when`
13
15
 
14
16
  class AiaCertChaserTest {
15
17
 
18
+ /** A certificate whose AIA extension value is exactly [extensionValue]. */
19
+ private fun certWithAia(extensionValue: ByteArray): X509Certificate =
20
+ mock(X509Certificate::class.java).also {
21
+ `when`(it.getExtensionValue("1.3.6.1.5.5.7.1.1")).thenReturn(extensionValue)
22
+ }
23
+
24
+ @Test(timeout = 5_000)
25
+ fun `a negative long-form length does not hang the parser`() {
26
+ // OCTET STRING { SEQUENCE { tag 0x00, long-form length 0xFFFFFFFA = -6 } }.
27
+ // The length's four bytes shift into a negative Int, so `pos += len` rewinds the
28
+ // reader by exactly the six bytes it just consumed: the tag is not a SEQUENCE, the
29
+ // loop `continue`s, and the reader is back where it started with more input to read.
30
+ val aia =
31
+ byteArrayOf(
32
+ 0x04,
33
+ 0x08,
34
+ 0x30,
35
+ 0x06,
36
+ 0x00,
37
+ 0x84.toByte(),
38
+ 0xFF.toByte(),
39
+ 0xFF.toByte(),
40
+ 0xFF.toByte(),
41
+ 0xFA.toByte(),
42
+ )
43
+
44
+ assertEquals(emptyList<String>(), AiaCertChaser.extractCaIssuerUrls(certWithAia(aia)))
45
+ }
46
+
47
+ @Test(timeout = 5_000)
48
+ fun `a length running past the buffer is treated as no AIA`() {
49
+ // SEQUENCE claiming 0x7F content bytes inside a 2-byte buffer.
50
+ val aia = byteArrayOf(0x04, 0x04, 0x30, 0x02, 0x30, 0x7F)
51
+
52
+ assertEquals(emptyList<String>(), AiaCertChaser.extractCaIssuerUrls(certWithAia(aia)))
53
+ }
54
+
55
+ @Test(timeout = 5_000)
56
+ fun `truncated DER is treated as no AIA`() {
57
+ val aia = byteArrayOf(0x04, 0x08, 0x30)
58
+
59
+ assertEquals(emptyList<String>(), AiaCertChaser.extractCaIssuerUrls(certWithAia(aia)))
60
+ }
61
+
16
62
  @Test
17
63
  fun `extracts CA Issuers URL from real leaf certificate AIA extension`() {
18
64
  val leaf = CertFixtures.cert("stationplaylist-leaf.pem")
@@ -1,7 +1,10 @@
1
1
  package com.audiobrowser.tls
2
2
 
3
+ import java.net.Socket
3
4
  import java.security.cert.CertificateException
4
5
  import java.security.cert.X509Certificate
6
+ import javax.net.ssl.SSLEngine
7
+ import javax.net.ssl.X509ExtendedTrustManager
5
8
  import javax.net.ssl.X509TrustManager
6
9
  import org.junit.Assert.assertEquals
7
10
  import org.junit.Assert.assertThrows
@@ -70,4 +73,114 @@ class AiaChasingTrustManagerTest {
70
73
  assertEquals(0, fetches)
71
74
  assertEquals(listOf(1), delegate.checkedChainSizes)
72
75
  }
76
+
77
+ // -- the API 24+ variant, which is what the platform actually calls into --
78
+
79
+ /**
80
+ * Stands in for Android's `RootTrustManager` under a per-domain network security config: the
81
+ * hostname-aware overloads work, and the two-argument one refuses to answer at all.
82
+ */
83
+ private class FakeDomainConfigDelegate(private val trustsChainOfSize: Int) :
84
+ X509ExtendedTrustManager() {
85
+ val hostnameAwareChecks = mutableListOf<Int>()
86
+
87
+ override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) =
88
+ throw CertificateException(
89
+ "Domain specific configurations require that hostname aware " +
90
+ "checkServerTrusted(X509Certificate[], String, String) is used"
91
+ )
92
+
93
+ override fun checkServerTrusted(
94
+ chain: Array<X509Certificate>,
95
+ authType: String,
96
+ socket: Socket?,
97
+ ) {
98
+ hostnameAwareChecks.add(chain.size)
99
+ if (chain.size != trustsChainOfSize) {
100
+ throw CertificateException("Trust anchor for certification path not found.")
101
+ }
102
+ }
103
+
104
+ override fun checkServerTrusted(
105
+ chain: Array<X509Certificate>,
106
+ authType: String,
107
+ engine: SSLEngine?,
108
+ ) = checkServerTrusted(chain, authType, null as Socket?)
109
+
110
+ override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = Unit
111
+
112
+ override fun checkClientTrusted(
113
+ chain: Array<X509Certificate>,
114
+ authType: String,
115
+ socket: Socket?,
116
+ ) = Unit
117
+
118
+ override fun checkClientTrusted(
119
+ chain: Array<X509Certificate>,
120
+ authType: String,
121
+ engine: SSLEngine?,
122
+ ) = Unit
123
+
124
+ override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
125
+ }
126
+
127
+ @Test
128
+ fun `forwards to the hostname-aware overload the platform requires`() {
129
+ val leaf = CertFixtures.cert("stationplaylist-leaf.pem")
130
+ val r13 = CertFixtures.cert("letsencrypt-r13.pem")
131
+ val delegate = FakeDomainConfigDelegate(trustsChainOfSize = 2)
132
+ val tm =
133
+ AiaChasingExtendedTrustManager(delegate) {
134
+ if (it == "http://r13.i.lencr.org/") listOf(r13) else emptyList()
135
+ }
136
+
137
+ // The socket overload must reach the delegate's socket overload. Routing it through the
138
+ // two-argument form instead would throw the domain-config refusal, which is what a plain
139
+ // X509TrustManager wrapper causes for every connection in the process.
140
+ tm.checkServerTrusted(arrayOf(leaf), "RSA", null as Socket?)
141
+
142
+ assertEquals(listOf(1, 2), delegate.hostnameAwareChecks)
143
+ }
144
+
145
+ @Test
146
+ fun `the engine overload is hostname-aware too`() {
147
+ val leaf = CertFixtures.cert("stationplaylist-leaf.pem")
148
+ val delegate = FakeDomainConfigDelegate(trustsChainOfSize = 1)
149
+
150
+ AiaChasingExtendedTrustManager(delegate) { emptyList() }
151
+ .checkServerTrusted(arrayOf(leaf), "RSA", null as SSLEngine?)
152
+
153
+ assertEquals(listOf(1), delegate.hostnameAwareChecks)
154
+ }
155
+
156
+ @Test
157
+ fun `a delegate without extended overloads still works`() {
158
+ val leaf = CertFixtures.cert("stationplaylist-leaf.pem")
159
+ val r13 = CertFixtures.cert("letsencrypt-r13.pem")
160
+ val delegate = FakeDelegate(trustsChainOfSize = 2)
161
+ val tm =
162
+ AiaChasingExtendedTrustManager(delegate) {
163
+ if (it == "http://r13.i.lencr.org/") listOf(r13) else emptyList()
164
+ }
165
+
166
+ tm.checkServerTrusted(arrayOf(leaf), "RSA", null as Socket?)
167
+
168
+ assertEquals(listOf(1, 2), delegate.checkedChainSizes)
169
+ }
170
+
171
+ @Test
172
+ fun `the extended variant does not weaken trust`() {
173
+ val leaf = CertFixtures.cert("stationplaylist-leaf.pem")
174
+ val r13 = CertFixtures.cert("letsencrypt-r13.pem")
175
+ // Trusts nothing, whatever the chain.
176
+ val delegate = FakeDomainConfigDelegate(trustsChainOfSize = -1)
177
+ val tm = AiaChasingExtendedTrustManager(delegate) { listOf(r13) }
178
+
179
+ assertThrows(CertificateException::class.java) {
180
+ tm.checkServerTrusted(arrayOf(leaf), "RSA", null as Socket?)
181
+ }
182
+
183
+ // The completed chain was offered to the delegate and refused, rather than assumed good.
184
+ assertEquals(listOf(1, 2), delegate.hostnameAwareChecks)
185
+ }
73
186
  }
@@ -1,5 +1,7 @@
1
1
  package com.audiobrowser.tls
2
2
 
3
+ import java.io.ByteArrayInputStream
4
+ import java.io.InputStream
3
5
  import org.junit.Assert.assertArrayEquals
4
6
  import org.junit.Assert.assertEquals
5
7
  import org.junit.Assert.assertNull
@@ -72,4 +74,85 @@ class CachingCertificateFetcherTest {
72
74
  CachingCertificateFetcher.parseHttpResponse("garbage without a header break".toByteArray())
73
75
  )
74
76
  }
77
+
78
+ // -- response cap (the AIA URL, so the host serving it, comes from the presented cert) --
79
+
80
+ // -- URL validation (the AIA URL is attacker-supplied text from the presented cert) --
81
+
82
+ @Test
83
+ fun `accepts the URL form CAs actually serve`() {
84
+ assertTrue(CachingCertificateFetcher.isSafeUrl("http://r13.i.lencr.org/"))
85
+ assertTrue(CachingCertificateFetcher.isSafeUrl("http://ca.example:8080/issuer.crt?v=2"))
86
+ }
87
+
88
+ @Test
89
+ fun `rejects a URL carrying CRLF, which would inject request lines`() {
90
+ // java.net.URL keeps the CRLF in `file`, and the cleartext fetch writes `file` straight
91
+ // into "GET $path HTTP/1.0\r\n" on a raw socket.
92
+ assertTrue(!CachingCertificateFetcher.isSafeUrl("http://127.0.0.1:6379/\r\nSET foo bar\r\n"))
93
+ assertTrue(!CachingCertificateFetcher.isSafeUrl("http://ca.example/a\nX-Injected: 1"))
94
+ }
95
+
96
+ @Test
97
+ fun `rejects a URL carrying a space, which would split the request line`() {
98
+ assertTrue(!CachingCertificateFetcher.isSafeUrl("http://ca.example/a b"))
99
+ }
100
+
101
+ @Test
102
+ fun `reads a response that fits within the cap`() {
103
+ val body = ByteArray(64) { it.toByte() }
104
+
105
+ assertArrayEquals(
106
+ body,
107
+ CachingCertificateFetcher.readCapped(ByteArrayInputStream(body), limit = 128),
108
+ )
109
+ }
110
+
111
+ @Test
112
+ fun `returns null for a response that exceeds the cap`() {
113
+ val body = ByteArray(129)
114
+
115
+ assertNull(CachingCertificateFetcher.readCapped(ByteArrayInputStream(body), limit = 128))
116
+ }
117
+
118
+ @Test
119
+ fun `a stream that never ends does not read without bound`() {
120
+ // Endless: read() always fills the buffer and never signals EOF, the shape a slow-drip
121
+ // server takes — the per-read timeout never fires because every read succeeds.
122
+ val endless =
123
+ object : InputStream() {
124
+ override fun read() = 0
125
+
126
+ override fun read(b: ByteArray, off: Int, len: Int): Int = len
127
+ }
128
+
129
+ assertNull(CachingCertificateFetcher.readCapped(endless, limit = 1 shl 16))
130
+ }
131
+
132
+ @Test
133
+ fun `a slow drip is cut off by the deadline, not left to exhaust the byte cap`() {
134
+ // One byte per read, each one landing just inside the socket's read timeout: the byte cap
135
+ // alone would let this run for a million reads. The clock advances 1s per read, so the
136
+ // 10s budget must end it after ~10 — far short of the 1 MiB cap.
137
+ var reads = 0
138
+ val drip =
139
+ object : InputStream() {
140
+ override fun read() = 0
141
+
142
+ override fun read(b: ByteArray, off: Int, len: Int): Int {
143
+ reads++
144
+ return 1
145
+ }
146
+ }
147
+ var nanos = 0L
148
+
149
+ val result =
150
+ CachingCertificateFetcher.readCapped(drip, budgetMs = 10_000) {
151
+ nanos += 1_000_000_000
152
+ nanos
153
+ }
154
+
155
+ assertNull(result)
156
+ assertTrue("gave up after $reads reads", reads < 20)
157
+ }
75
158
  }
@@ -64,9 +64,6 @@ final class BrowserManager {
64
64
  private let router = SimpleRouter()
65
65
  private let httpClient = HttpClient()
66
66
 
67
- // LRU cache for individual tracks - keyed by both url and src for O(1) lookup
68
- private let trackCache = LRUCache<String, Track>(maxSize: 3000)
69
-
70
67
  // LRU cache for resolved content - keyed by path
71
68
  private let contentCache = LRUCache<String, ResolvedTrack>(maxSize: 20)
72
69
 
@@ -223,22 +220,6 @@ final class BrowserManager {
223
220
  return resolvedTrack.copying(children: hydratedChildren)
224
221
  }
225
222
 
226
- // MARK: - Track Cache
227
-
228
- /// Cache a track by both url and src for O(1) lookup from either key.
229
- private func cacheTrack(_ track: Track) {
230
- if let url = track.url {
231
- trackCache.set(url, value: track)
232
- }
233
- if let src = track.src {
234
- trackCache.set(src, value: track)
235
- }
236
- }
237
-
238
- private func cacheChildren(_ resolvedTrack: ResolvedTrack) {
239
- resolvedTrack.children?.forEach { cacheTrack($0) }
240
- }
241
-
242
223
  // MARK: - Navigation
243
224
 
244
225
  /// Main navigation method - updates path and resolves content.
@@ -276,9 +257,6 @@ final class BrowserManager {
276
257
  // Cache the resolved content for future navigation
277
258
  contentCache.set(normalizedPath, value: resolvedTrack)
278
259
 
279
- // Cache children for track lookups
280
- cacheChildren(resolvedTrack)
281
-
282
260
  return hydrateChildren(resolvedTrack)
283
261
  }
284
262
 
@@ -682,9 +660,6 @@ final class BrowserManager {
682
660
  lastSearchQuery = query
683
661
  lastSearchResults = hydratedResults
684
662
 
685
- // Cache individual tracks
686
- hydratedResults.forEach { cacheTrack($0) }
687
-
688
663
  return makeSearchResult(query: query, results: hydratedResults)
689
664
  }
690
665
 
@@ -770,9 +745,9 @@ final class BrowserManager {
770
745
  // silently queueing the changed list would resume the wrong station.
771
746
  let sectionTracks: [Track]
772
747
  switch SectionScope.section(of: children, containing: trackId) {
773
- case .imageRow(let items):
748
+ case let .imageRow(items):
774
749
  sectionTracks = items.map { $0.toTrack() }
775
- case .run(let tracks):
750
+ case let .run(tracks):
776
751
  sectionTracks = tracks
777
752
  case nil:
778
753
  return nil
@@ -20,7 +20,7 @@ enum SearchDrillIn {
20
20
  /// yields nothing. Returns nil when nothing is playable.
21
21
  static func playable<Item: PlayableSearchItem>(
22
22
  from children: [Item],
23
- resolveChildren: sending (String) async throws -> [Item]
23
+ resolveChildren: sending (String) async throws -> [Item],
24
24
  ) async throws -> [Item]? {
25
25
  guard !children.isEmpty else { return nil }
26
26