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
@@ -168,13 +168,26 @@ class Player(internal val context: Context) {
168
168
  mediaSessionCallback.updateMediaSession(
169
169
  mediaSession,
170
170
  options.capabilities,
171
- options.notificationButtons,
171
+ options.remoteButtonLayout,
172
172
  searchAvailable,
173
+ options.forwardJumpInterval,
174
+ options.backwardJumpInterval,
173
175
  )
174
176
  }
175
177
  }
176
178
  }
177
179
 
180
+ /**
181
+ * Drops a completed browser registration so [awaitBrowser] waits for the replacement instead of
182
+ * handing out the disposed browser. A [CompletableDeferred] completes once, so it would otherwise
183
+ * pin the first browser for the life of the process.
184
+ */
185
+ fun forgetBrowserRegistration() {
186
+ if (browserRegistered.isCompleted) {
187
+ browserRegistered = CompletableDeferred()
188
+ }
189
+ }
190
+
178
191
  /**
179
192
  * Suspends until the browser is registered, with a timeout.
180
193
  *
@@ -834,8 +847,8 @@ class Player(internal val context: Context) {
834
847
  * A restart from a terminal error begins a new load of the same track: fresh retry budgets and
835
848
  * unproven playback, so the user's tap yields a visible first-connect retry window instead of a
836
849
  * single silent attempt — the same behavior as re-selecting the track (ADR 0004). Hooked off the
837
- * player error clearing: every recovery path (retry(), play(), the transport controls'
838
- * idle-error recovery) goes through exoPlayer.prepare(), which clears the error.
850
+ * player error clearing: every recovery path (retry(), play(), the transport controls' idle-error
851
+ * recovery) goes through exoPlayer.prepare(), which clears the error.
839
852
  */
840
853
  internal fun resetForNewLoad() {
841
854
  resetRetryTimer()
@@ -1215,8 +1228,8 @@ class Player(internal val context: Context) {
1215
1228
  val backwardJumpIntervalChanged =
1216
1229
  previousOptions.backwardJumpInterval != options.backwardJumpInterval
1217
1230
  val capabilitiesChanged = previousOptions.capabilities != options.capabilities
1218
- val notificationButtonsChanged =
1219
- previousOptions.notificationButtons != options.notificationButtons
1231
+ val remoteButtonLayoutChanged =
1232
+ !previousOptions.remoteButtonLayout.sameAs(options.remoteButtonLayout)
1220
1233
  val appKilledPlaybackBehaviorChanged =
1221
1234
  previousOptions.appKilledPlaybackBehavior != options.appKilledPlaybackBehavior
1222
1235
 
@@ -1226,7 +1239,7 @@ class Player(internal val context: Context) {
1226
1239
  forwardJumpIntervalChanged ||
1227
1240
  backwardJumpIntervalChanged ||
1228
1241
  capabilitiesChanged ||
1229
- notificationButtonsChanged ||
1242
+ remoteButtonLayoutChanged ||
1230
1243
  appKilledPlaybackBehaviorChanged
1231
1244
 
1232
1245
  // Apply only changed properties
@@ -1244,13 +1257,22 @@ class Player(internal val context: Context) {
1244
1257
  setProgressUpdateInterval(options.progressUpdateEventInterval)
1245
1258
  }
1246
1259
 
1247
- if (capabilitiesChanged || notificationButtonsChanged) {
1260
+ // The jump intervals pick the button icons (ICON_SKIP_BACK_15 and friends), so a changed
1261
+ // interval has to rebuild the layout even when capabilities and placement are untouched.
1262
+ if (
1263
+ capabilitiesChanged ||
1264
+ remoteButtonLayoutChanged ||
1265
+ forwardJumpIntervalChanged ||
1266
+ backwardJumpIntervalChanged
1267
+ ) {
1248
1268
  val searchAvailable = browser?.browserManager?.config?.hasSearch ?: false
1249
1269
  mediaSessionCallback.updateMediaSession(
1250
1270
  mediaSession,
1251
1271
  options.capabilities,
1252
- options.notificationButtons,
1272
+ options.remoteButtonLayout,
1253
1273
  searchAvailable,
1274
+ options.forwardJumpInterval,
1275
+ options.backwardJumpInterval,
1254
1276
  )
1255
1277
  }
1256
1278
 
@@ -107,7 +107,8 @@ class RetryLoadErrorHandlingPolicy(
107
107
  */
108
108
  private fun exhaustedBudget(currentTime: Long): String? {
109
109
  firstErrorTime?.let { start ->
110
- if (currentTime - start >= maxRetryDurationMs) return "max retry duration ($maxRetryDurationMs ms)"
110
+ if (currentTime - start >= maxRetryDurationMs)
111
+ return "max retry duration ($maxRetryDurationMs ms)"
111
112
  }
112
113
  if (!hasPlayed() && isOnline()) {
113
114
  firstOnlineErrorTime?.let { start ->
@@ -113,7 +113,8 @@ class TransformingDataSource(private val upstream: DataSource, private val facto
113
113
  * installs the `IcyDataSource` that emits in-stream ICY/Shoutcast song metadata via
114
114
  * `Player.Listener.onMetadata`. The `DataSource` default returns an empty map, so without this
115
115
  * override this transparent wrapper would swallow the ICY headers — playback keeps working but
116
- * `onMetadata` never fires (live "now playing" song goes missing).
116
+ * `onMetadata` never fires (live "now playing" song goes missing). Swallowing them also leaves
117
+ * the interleaved metadata bytes in the audio stream, where they decode as audible chirping.
117
118
  */
118
119
  override fun getResponseHeaders(): Map<String, List<String>> {
119
120
  return upstream.responseHeaders
@@ -106,9 +106,13 @@ object AiaCertChaser {
106
106
  }
107
107
 
108
108
  /**
109
- * Minimal DER TLV reader over a byte buffer. Does no bounds-checking by design: truncated or
110
- * malformed input throws (e.g. [IndexOutOfBoundsException]), which the callers above expect and
111
- * turn into "no AIA".
109
+ * Minimal DER TLV reader over a byte buffer. Malformed input throws [IllegalArgumentException],
110
+ * which the callers above expect and turn into "no AIA".
111
+ *
112
+ * Every read is bounds-checked, and a declared length is rejected unless it is non-negative and
113
+ * within the buffer. Both matter: the extension bytes come from a server-presented certificate,
114
+ * so a length that shifts into a negative Int would rewind `pos` and spin [extractCaIssuerUrls]'s
115
+ * loop forever on the handshake thread rather than failing.
112
116
  */
113
117
  private class DerReader(private val buf: ByteArray) {
114
118
  private var pos = 0
@@ -116,17 +120,28 @@ object AiaCertChaser {
116
120
  fun hasNext(): Boolean = pos < buf.size
117
121
 
118
122
  fun read(): Tlv {
119
- val tag = buf[pos++].toInt() and 0xFF
120
- var len = buf[pos++].toInt() and 0xFF
123
+ val tag = readByte()
124
+ var len = readByte()
121
125
  if (len and 0x80 != 0) {
126
+ // Long form: the low bits give the number of length bytes. Four already covers any
127
+ // buffer that fits in memory; more would overflow the Int we shift them into. The
128
+ // indefinite form (0x80, i.e. zero length bytes) is not valid DER.
122
129
  val numBytes = len and 0x7F
130
+ require(numBytes in 1..4) { "unsupported DER length form: $numBytes length bytes" }
123
131
  len = 0
124
- repeat(numBytes) { len = (len shl 8) or (buf[pos++].toInt() and 0xFF) }
132
+ repeat(numBytes) { len = (len shl 8) or readByte() }
133
+ require(len >= 0) { "DER length overflows Int" }
125
134
  }
135
+ require(len <= buf.size - pos) { "DER length $len runs past the end of the buffer" }
126
136
  val tlv = Tlv(buf, tag, pos, len)
127
137
  pos += len
128
138
  return tlv
129
139
  }
140
+
141
+ private fun readByte(): Int {
142
+ require(pos < buf.size) { "truncated DER" }
143
+ return buf[pos++].toInt() and 0xFF
144
+ }
130
145
  }
131
146
 
132
147
  /** A parsed tag-length-value: the tag and a view of its content bytes. */
@@ -0,0 +1,109 @@
1
+ package com.audiobrowser.tls
2
+
3
+ import java.net.Socket
4
+ import java.security.cert.CertificateException
5
+ import java.security.cert.X509Certificate
6
+ import javax.net.ssl.SSLEngine
7
+ import javax.net.ssl.X509ExtendedTrustManager
8
+ import javax.net.ssl.X509TrustManager
9
+
10
+ /**
11
+ * The [AiaChasingTrustManager] behaviour, as an [X509ExtendedTrustManager].
12
+ *
13
+ * The socket- and engine-aware overloads are the ones the platform actually calls, and they are not
14
+ * optional. Android's default trust manager is a `RootTrustManager`, which extends
15
+ * [X509ExtendedTrustManager] and whose two-argument `checkServerTrusted` throws unconditionally
16
+ * once the app declares any `<domain-config>` in its network security config:
17
+ * > Domain specific configurations require that hostname aware
18
+ * > checkServerTrusted(X509Certificate[], String, String) is used
19
+ *
20
+ * Wrapping it in a plain [X509TrustManager] hides those overloads, so conscrypt falls through to
21
+ * the two-argument form and *every* HTTPS connection in the process fails — and, because the
22
+ * wrapper treats a [CertificateException] as its cue to chase AIA, fails slowly. The socket
23
+ * overload is also where endpoint identification happens for a raw `SSLSocket` configured with
24
+ * `endpointIdentificationAlgorithm = "HTTPS"`; skipping it skips hostname verification.
25
+ *
26
+ * [X509ExtendedTrustManager] is API 24+, hence the split from [AiaChasingTrustManager] — see
27
+ * [AiaTls.trustManager], which picks by SDK level. Per-domain network security configs are
28
+ * themselves API 24+, so the plain variant on 23 loses nothing.
29
+ *
30
+ * @param delegate the wrapped trust manager, normally the platform default.
31
+ * @param fetch resolves a CA-Issuers URL to its candidate certificates.
32
+ */
33
+ class AiaChasingExtendedTrustManager(
34
+ private val delegate: X509TrustManager,
35
+ private val fetch: (url: String) -> List<X509Certificate>,
36
+ ) : X509ExtendedTrustManager() {
37
+
38
+ /** The delegate's own extended overloads, when it has them. */
39
+ private val extended = delegate as? X509ExtendedTrustManager
40
+
41
+ override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) =
42
+ withAiaRetry(chain) { delegate.checkServerTrusted(it, authType) }
43
+
44
+ override fun checkServerTrusted(
45
+ chain: Array<X509Certificate>,
46
+ authType: String,
47
+ socket: Socket?,
48
+ ) =
49
+ withAiaRetry(chain) {
50
+ // A delegate without the extended overloads cannot be hostname-aware in the first place,
51
+ // so falling back to its two-argument form loses nothing it could have offered.
52
+ extended?.checkServerTrusted(it, authType, socket)
53
+ ?: delegate.checkServerTrusted(it, authType)
54
+ }
55
+
56
+ override fun checkServerTrusted(
57
+ chain: Array<X509Certificate>,
58
+ authType: String,
59
+ engine: SSLEngine?,
60
+ ) =
61
+ withAiaRetry(chain) {
62
+ extended?.checkServerTrusted(it, authType, engine)
63
+ ?: delegate.checkServerTrusted(it, authType)
64
+ }
65
+
66
+ // Client certificates are the app's own; there is nothing to chase.
67
+
68
+ override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) =
69
+ delegate.checkClientTrusted(chain, authType)
70
+
71
+ override fun checkClientTrusted(
72
+ chain: Array<X509Certificate>,
73
+ authType: String,
74
+ socket: Socket?,
75
+ ) {
76
+ extended?.checkClientTrusted(chain, authType, socket)
77
+ ?: delegate.checkClientTrusted(chain, authType)
78
+ }
79
+
80
+ override fun checkClientTrusted(
81
+ chain: Array<X509Certificate>,
82
+ authType: String,
83
+ engine: SSLEngine?,
84
+ ) {
85
+ extended?.checkClientTrusted(chain, authType, engine)
86
+ ?: delegate.checkClientTrusted(chain, authType)
87
+ }
88
+
89
+ override fun getAcceptedIssuers(): Array<X509Certificate> = delegate.acceptedIssuers
90
+
91
+ /**
92
+ * Runs [check], and on rejection retries it once against the AIA-completed chain. The completed
93
+ * chain goes through the same [check], so trust is never weakened: whatever the delegate would
94
+ * have refused, it still refuses.
95
+ */
96
+ private inline fun withAiaRetry(
97
+ chain: Array<X509Certificate>,
98
+ check: (Array<X509Certificate>) -> Unit,
99
+ ) {
100
+ try {
101
+ check(chain)
102
+ } catch (original: CertificateException) {
103
+ val completed = AiaCertChaser.completeChain(chain.toList(), fetch = fetch)
104
+ // Nothing could be added — the original failure stands (don't re-check an unchanged chain).
105
+ if (completed.size == chain.size) throw original
106
+ check(completed.toTypedArray())
107
+ }
108
+ }
109
+ }
@@ -1,5 +1,6 @@
1
1
  package com.audiobrowser.tls
2
2
 
3
+ import android.os.Build
3
4
  import java.security.KeyStore
4
5
  import javax.net.ssl.SSLContext
5
6
  import javax.net.ssl.SSLSocketFactory
@@ -27,10 +28,23 @@ object AiaTls {
27
28
  fun socketFactory(): SSLSocketFactory =
28
29
  SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager()), null) }.socketFactory
29
30
 
30
- /** An [X509TrustManager] wrapping the platform default with AIA intermediate chasing. */
31
+ /**
32
+ * An [X509TrustManager] wrapping the platform default with AIA intermediate chasing.
33
+ *
34
+ * On API 24+ this is an [X509ExtendedTrustManager]. That is not cosmetic: the platform's default
35
+ * trust manager rejects the two-argument `checkServerTrusted` outright once the app declares any
36
+ * `<domain-config>`, so a plain wrapper would break every HTTPS connection in the process. See
37
+ * [AiaChasingExtendedTrustManager].
38
+ */
31
39
  fun trustManager(): X509TrustManager {
32
40
  val fetcher = CachingCertificateFetcher()
33
- return AiaChasingTrustManager(systemTrustManager()) { fetcher.fetch(it) }
41
+ val fetch = { url: String -> fetcher.fetch(url) }
42
+ val system = systemTrustManager()
43
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
44
+ AiaChasingExtendedTrustManager(system, fetch)
45
+ } else {
46
+ AiaChasingTrustManager(system, fetch)
47
+ }
34
48
  }
35
49
 
36
50
  private fun systemTrustManager(): X509TrustManager {
@@ -2,6 +2,7 @@ package com.audiobrowser.tls
2
2
 
3
3
  import java.io.ByteArrayInputStream
4
4
  import java.io.ByteArrayOutputStream
5
+ import java.io.InputStream
5
6
  import java.net.InetSocketAddress
6
7
  import java.net.Socket
7
8
  import java.net.URL
@@ -22,6 +23,11 @@ import timber.log.Timber
22
23
  * chasing). Only non-empty results are cached, so the (typically `http`) round-trip happens at most
23
24
  * once per intermediate for the process lifetime while transient failures stay retryable.
24
25
  *
26
+ * Responses are capped at [MAX_RESPONSE_BYTES]. The URL comes from a server-presented certificate,
27
+ * so the host, port and path are chosen by whoever we are failing to validate; the read timeout is
28
+ * per-read and would not stop a slow drip of unbounded length. A certificate is a few kilobytes and
29
+ * even a PKCS#7 bundle a few dozen, so the cap is far above anything legitimate.
30
+ *
25
31
  * `http` fetches go over a raw [Socket] rather than `HttpURLConnection`. CA "CA Issuers" URLs are
26
32
  * virtually always plain `http` (the CA/Browser Forum Baseline Requirements mandate it, to avoid a
27
33
  * chicken-and-egg TLS dependency when fetching the cert needed to complete a TLS chain), but
@@ -56,6 +62,7 @@ class CachingCertificateFetcher(
56
62
 
57
63
  private fun download(url: String, redirectsLeft: Int = MAX_REDIRECTS): ByteArray? =
58
64
  try {
65
+ require(isSafeUrl(url)) { "AIA URL contains characters that are not valid in a URL" }
59
66
  val parsed = URL(url)
60
67
  when (parsed.protocol) {
61
68
  "http" -> downloadCleartext(parsed, redirectsLeft)
@@ -77,7 +84,7 @@ class CachingCertificateFetcher(
77
84
  connection.connectTimeout = connectTimeoutMs
78
85
  connection.readTimeout = readTimeoutMs
79
86
  connection.sslSocketFactory = plainSslSocketFactory
80
- return connection.inputStream.use { it.readBytes() }
87
+ return connection.inputStream.use { readCapped(it) }
81
88
  }
82
89
 
83
90
  /**
@@ -104,8 +111,8 @@ class CachingCertificateFetcher(
104
111
  write(request.toByteArray(Charsets.ISO_8859_1))
105
112
  flush()
106
113
  }
107
- socket.getInputStream().use { it.readBytes() }
108
- }
114
+ socket.getInputStream().use { readCapped(it) }
115
+ } ?: return null
109
116
  val response = parseHttpResponse(raw) ?: return null
110
117
  return when {
111
118
  response.status in 200..299 -> response.body
@@ -119,6 +126,55 @@ class CachingCertificateFetcher(
119
126
  companion object {
120
127
  private const val MAX_REDIRECTS = 5
121
128
 
129
+ /**
130
+ * Whether [url] is free of characters that have no business in a URL.
131
+ *
132
+ * The cleartext fetch writes the path into a request line on a raw socket, and the URL is an
133
+ * IA5String copied verbatim out of a certificate a hostile server presented. `java.net.URL`
134
+ * preserves control characters — `URL("http://host:6379/\r\nSET foo bar")` parses to that host
135
+ * and port with the CRLF intact in `file` — so without this check a certificate could inject
136
+ * request lines of its own, to any host and port the device can reach. Space is excluded too:
137
+ * it would split the request line's target from its HTTP version.
138
+ */
139
+ fun isSafeUrl(url: String): Boolean = url.none { it.code <= 0x20 || it.code == 0x7F }
140
+
141
+ /** Ceiling on a single AIA response body. */
142
+ const val MAX_RESPONSE_BYTES = 1 shl 20 // 1 MiB
143
+
144
+ /** Ceiling on the wall-clock time spent reading one AIA response. */
145
+ const val MAX_RESPONSE_MILLIS = 10_000L
146
+
147
+ /**
148
+ * Reads [input] to EOF, or returns null once it exceeds [limit] bytes or [budgetMs] of
149
+ * wall-clock time. Null rather than a truncated buffer: a cut-off certificate is not parseable
150
+ * anyway, and "too big" or "too slow" is a failed fetch, which the caller already knows how to
151
+ * treat as "no AIA".
152
+ *
153
+ * The byte cap alone would not bound the time. The socket's timeout is per-read, so a server
154
+ * returning one byte just inside it resets the clock on every iteration and holds the handshake
155
+ * thread for as long as it likes — a slow-drip stall in place of the unbounded buffer. Both
156
+ * limits are needed, and the deadline is checked against [nowNanos] (injectable so a test need
157
+ * not actually wait).
158
+ */
159
+ fun readCapped(
160
+ input: InputStream,
161
+ limit: Int = MAX_RESPONSE_BYTES,
162
+ budgetMs: Long = MAX_RESPONSE_MILLIS,
163
+ nowNanos: () -> Long = System::nanoTime,
164
+ ): ByteArray? {
165
+ val deadline = nowNanos() + budgetMs * 1_000_000
166
+ val out = ByteArrayOutputStream()
167
+ val chunk = ByteArray(8 * 1024)
168
+ while (true) {
169
+ val read = input.read(chunk)
170
+ if (read < 0) return out.toByteArray()
171
+ if (out.size() + read > limit) return null
172
+ out.write(chunk, 0, read)
173
+ // Subtraction, not `>`: overflow-safe across nanoTime's arbitrary origin.
174
+ if (nowNanos() - deadline >= 0) return null
175
+ }
176
+ }
177
+
122
178
  /**
123
179
  * Parses every certificate from the bytes a CA-Issuers URL serves — a single DER certificate
124
180
  * (e.g. Let's Encrypt), a PEM file, or a PKCS#7 "certs-only" bundle. Empty if the bytes are not
@@ -5,11 +5,10 @@ import com.margelo.nitro.audiobrowser.Variant_String_ArtworkVariants
5
5
  /**
6
6
  * Reading `Track.artwork`, which is either one URL or a URL per appearance.
7
7
  *
8
- * Android Auto is dark-only, so there is nothing here to choose: a pair always
9
- * resolves to its dark URL, which is also what a single-URL track would have
10
- * shipped. The per-appearance half of the feature is iOS/CarPlay only — see
11
- * `ios/Extension/Variant+Artwork.swift`, where both URLs are kept so a
12
- * `UIImageAsset` can swap them without a re-fetch.
8
+ * Android Auto is dark-only, so there is nothing here to choose: a pair always resolves to its dark
9
+ * URL, which is also what a single-URL track would have shipped. The per-appearance half of the
10
+ * feature is iOS/CarPlay only — see `ios/Extension/Variant+Artwork.swift`, where both URLs are kept
11
+ * so a `UIImageAsset` can swap them without a re-fetch.
13
12
  */
14
13
  val Variant_String_ArtworkVariants.url: String
15
14
  get() = match(first = { it }, second = { it.dark })
@@ -17,10 +16,9 @@ val Variant_String_ArtworkVariants.url: String
17
16
  /**
18
17
  * Wraps a plain URL as the single-URL side of the union.
19
18
  *
20
- * For the places that build a `Track` from something that only ever has one
21
- * image — restored persistence, a `MediaMetadata` handed back by the platform.
22
- * Restoring collapses a pair to its dark URL, which costs nothing here: Android
23
- * Auto would have rendered that one anyway.
19
+ * For the places that build a `Track` from something that only ever has one image — restored
20
+ * persistence, a `MediaMetadata` handed back by the platform. Restoring collapses a pair to its
21
+ * dark URL, which costs nothing here: Android Auto would have rendered that one anyway.
24
22
  */
25
23
  fun artworkOf(url: String?): Variant_String_ArtworkVariants? =
26
24
  url?.let { Variant_String_ArtworkVariants.First(it) }
@@ -26,12 +26,12 @@ object TrackFactory {
26
26
  }
27
27
 
28
28
  /**
29
- * Android Auto has no image-row rendering; its closest equivalent is a grid of artwork
30
- * tiles. A track carrying `imageRow` expands into its items as grid-styled rows (the
31
- * per-item content-style hint — hosts that ignore it fall back to list rows) grouped under
32
- * the row's title, followed by the row itself as a browsable "view all" link when it has a
33
- * `url` (a url-less row is a pure preview and contributes only its items). Tracks without
34
- * an `imageRow` pass through unchanged.
29
+ * Android Auto has no image-row rendering; its closest equivalent is a grid of artwork tiles. A
30
+ * track carrying `imageRow` expands into its items as grid-styled rows (the per-item
31
+ * content-style hint — hosts that ignore it fall back to list rows) grouped under the row's
32
+ * title, followed by the row itself as a browsable "view all" link when it has a `url` (a
33
+ * url-less row is a pure preview and contributes only its items). Tracks without an `imageRow`
34
+ * pass through unchanged.
35
35
  */
36
36
  fun expandImageRows(tracks: List<Track>): List<Track> =
37
37
  tracks.flatMap { track ->
@@ -95,7 +95,8 @@ object TrackFactory {
95
95
  val scheme = rawUrl?.let { Uri.parse(it).scheme?.lowercase() }
96
96
  val builder = metadataBuilder(track)
97
97
  if (rawUrl != null && (scheme == "http" || scheme == "https")) {
98
- val isSvg = SvgArtworkRenderer.isSvgUrl(rawUrl) || SvgArtworkRenderer.isSvgUrl(track.artwork?.url)
98
+ val isSvg =
99
+ SvgArtworkRenderer.isSvgUrl(rawUrl) || SvgArtworkRenderer.isSvgUrl(track.artwork?.url)
99
100
  val token = ArtworkUris.tokenFor(rawUrl)
100
101
  registry.register(token, ResolvedArtwork(rawUrl, track.artworkSource?.headers, isSvg))
101
102
  builder.setArtworkUri(ArtworkUris.contentUri(authority, token).toUri())
@@ -0,0 +1,87 @@
1
+ package com.audiobrowser
2
+
3
+ import android.content.ComponentName
4
+ import android.content.Intent
5
+ import android.content.ServiceConnection
6
+ import android.os.IBinder
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertFalse
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+ import org.junit.runner.RunWith
12
+ import org.robolectric.RobolectricTestRunner
13
+ import org.robolectric.RuntimeEnvironment
14
+ import org.robolectric.Shadows.shadowOf
15
+
16
+ // Robolectric: bindService/unbindService only exist under the framework, and ShadowApplication
17
+ // records both sides — which is precisely the leak being guarded against.
18
+ @RunWith(RobolectricTestRunner::class)
19
+ class ServiceBindingTest {
20
+
21
+ private val app = RuntimeEnvironment.getApplication()
22
+ private val intent = Intent(app, Service::class.java)
23
+
24
+ private val connection =
25
+ object : ServiceConnection {
26
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) = Unit
27
+
28
+ override fun onServiceDisconnected(name: ComponentName?) = Unit
29
+ }
30
+
31
+ /** Both the auto-bind at construction and setup's bind reach the same object. */
32
+ @Test
33
+ fun bindingTwiceTakesOnlyOneBinding() {
34
+ val binding = ServiceBinding(app)
35
+
36
+ assertTrue(binding.bind(intent, connection))
37
+ assertTrue(binding.bind(intent, connection))
38
+
39
+ assertEquals(1, shadowOf(app).boundServiceConnections.size)
40
+
41
+ binding.unbind(connection)
42
+
43
+ assertTrue(shadowOf(app).boundServiceConnections.isEmpty())
44
+ assertEquals(1, shadowOf(app).unboundServiceConnections.size)
45
+ }
46
+
47
+ /** dispose() must not throw, and Android throws when unbinding a connection it never had. */
48
+ @Test
49
+ fun unbindWithoutBindIsANoOp() {
50
+ val binding = ServiceBinding(app)
51
+
52
+ binding.unbind(connection)
53
+
54
+ assertFalse(binding.isBound)
55
+ assertTrue(shadowOf(app).unboundServiceConnections.isEmpty())
56
+ }
57
+
58
+ /**
59
+ * Android registers the connection before it asks the ActivityManager, so a false return still
60
+ * leaves a binding to release. Treating false as "nothing bound" strands it permanently — the
61
+ * leak this class exists to prevent.
62
+ */
63
+ @Test
64
+ fun failedBindStillHoldsAConnectionToRelease() {
65
+ val binding = ServiceBinding(app)
66
+ shadowOf(app).declareActionUnbindable("unbindable")
67
+
68
+ assertFalse(binding.bind(Intent("unbindable"), connection))
69
+
70
+ assertTrue(binding.isBound)
71
+
72
+ binding.unbind(connection)
73
+
74
+ assertEquals(1, shadowOf(app).unboundServiceConnections.size)
75
+ }
76
+
77
+ @Test
78
+ fun unbindIsIdempotent() {
79
+ val binding = ServiceBinding(app)
80
+ binding.bind(intent, connection)
81
+
82
+ binding.unbind(connection)
83
+ binding.unbind(connection)
84
+
85
+ assertEquals(1, shadowOf(app).unboundServiceConnections.size)
86
+ }
87
+ }
@@ -1,7 +1,6 @@
1
1
  package com.audiobrowser
2
2
 
3
3
  import com.audiobrowser.util.artworkOf
4
-
5
4
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
6
5
  import com.margelo.nitro.audiobrowser.ImageQueryParams
7
6
  import com.margelo.nitro.audiobrowser.ImageRowItem
@@ -10,7 +10,8 @@ class JsonArtworkTest {
10
10
 
11
11
  @Test
12
12
  fun `decodes a single url`() {
13
- val track = json.decodeFromString<JsonTrack>("""{"title":"X","artwork":"https://e.example/a.png"}""")
13
+ val track =
14
+ json.decodeFromString<JsonTrack>("""{"title":"X","artwork":"https://e.example/a.png"}""")
14
15
  assertEquals(
15
16
  Variant_String_ArtworkVariants.First("https://e.example/a.png"),
16
17
  track.toNitro().artwork,
@@ -59,8 +59,7 @@ class SectionScopeTest {
59
59
  val row = track(title = "Most Played", src = null, imageRow = items)
60
60
  val children = listOf(row, track(src = "a"))
61
61
 
62
- val section =
63
- SectionScope.section(children, "s2") as SectionScope.Section.ImageRow
62
+ val section = SectionScope.section(children, "s2") as SectionScope.Section.ImageRow
64
63
  assertEquals(listOf("s1", "s2"), section.items.map { it.src })
65
64
  }
66
65
 
@@ -76,8 +75,7 @@ class SectionScopeTest {
76
75
  val row = track(title = "Row", src = null, imageRow = arrayOf(imageRowItem("dup")))
77
76
  val children = listOf(row, track(src = "dup"), track(src = "b"))
78
77
 
79
- val section =
80
- SectionScope.section(children, "dup") as SectionScope.Section.ImageRow
78
+ val section = SectionScope.section(children, "dup") as SectionScope.Section.ImageRow
81
79
  assertEquals(listOf("dup"), section.items.map { it.src })
82
80
  }
83
81