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

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 (290) hide show
  1. package/README.md +22 -18
  2. package/android/build.gradle +13 -10
  3. package/android/gradle.properties +2 -2
  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 +67 -11
  23. package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +168 -0
  24. package/android/src/main/java/com/audiobrowser/tls/AiaChasingTrustManager.kt +58 -14
  25. package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +35 -7
  26. package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +278 -59
  27. package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
  28. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
  29. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +87 -0
  30. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +52 -1
  31. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +6 -0
  32. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +167 -8
  33. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
  34. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
  35. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +100 -56
  36. package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
  37. package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
  38. package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
  39. package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +197 -7
  40. package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +325 -4
  41. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherCallSiteTest.kt +322 -0
  42. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherTest.kt +83 -0
  43. package/ios/Browser/BrowserManager.swift +2 -27
  44. package/ios/Browser/SearchDrillIn.swift +1 -1
  45. package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
  46. package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
  47. package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
  48. package/ios/HybridAudioBrowser.swift +87 -56
  49. package/ios/Model/NitroTypeStubs.swift +18 -2
  50. package/ios/Model/RemoteCommand.swift +18 -0
  51. package/ios/Model/TrackPlayerError.swift +2 -2
  52. package/ios/Option/SessionCategories.swift +3 -2
  53. package/ios/Player/MediaIntentCriteria.swift +4 -4
  54. package/ios/Player/MediaLoader.swift +3 -1
  55. package/ios/Player/PlaybackCoordinator.swift +4 -1
  56. package/ios/Player/PlaybackErrorHandler.swift +4 -4
  57. package/ios/Player/QueueManager.swift +10 -3
  58. package/ios/Player/RetryManager.swift +79 -18
  59. package/ios/Player/ShuffleOrder.swift +10 -0
  60. package/ios/Player/SleepTimerManager.swift +43 -15
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
  62. package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
  63. package/ios/Tests/LRUCacheTests.swift +111 -0
  64. package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
  65. package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
  66. package/ios/Tests/MediaLoaderTests.swift +10 -13
  67. package/ios/Tests/PlaybackCoordinatorTests.swift +10 -7
  68. package/ios/Tests/PlaybackErrorHandlerTests.swift +3 -1
  69. package/ios/Tests/PlayerTimeObserverTests.swift +37 -0
  70. package/ios/Tests/QueueManagerTests.swift +50 -4
  71. package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
  72. package/ios/Tests/RemoteCommandTests.swift +67 -0
  73. package/ios/Tests/RetryManagerTests.swift +89 -0
  74. package/ios/Tests/SectionScopeTests.swift +2 -2
  75. package/ios/Tests/SleepTimerManagerTests.swift +22 -19
  76. package/ios/Tests/Support/ManualSleepTimerScheduler.swift +47 -0
  77. package/ios/Tests/Support/TestTypes.swift +1 -1
  78. package/ios/Tests/TabBarEntriesTests.swift +7 -7
  79. package/ios/TrackPlayer.swift +6 -1
  80. package/ios/TrackPlayerCallbacks.swift +5 -38
  81. package/ios/Util/LRUCache.swift +4 -1
  82. package/lib/commonjs/AudioBrowser.js +0 -11
  83. package/lib/commonjs/AudioBrowser.js.map +1 -1
  84. package/lib/commonjs/CLAUDE.md +52 -0
  85. package/lib/commonjs/features/browser.js +1 -1
  86. package/lib/commonjs/features/browser.js.map +1 -1
  87. package/lib/commonjs/features/carConnection.js +1 -1
  88. package/lib/commonjs/features/equalizer.js +1 -1
  89. package/lib/commonjs/features/errors.js +3 -3
  90. package/lib/commonjs/features/favorites.js +1 -1
  91. package/lib/commonjs/features/gate.js +16 -1
  92. package/lib/commonjs/features/gate.js.map +1 -1
  93. package/lib/commonjs/features/metadata.js +20 -3
  94. package/lib/commonjs/features/metadata.js.map +1 -1
  95. package/lib/commonjs/features/network.js +1 -1
  96. package/lib/commonjs/features/nowPlaying.js +1 -1
  97. package/lib/commonjs/features/output.js +1 -1
  98. package/lib/commonjs/features/playback/playWhenReady.js +1 -1
  99. package/lib/commonjs/features/playback/playing.js +1 -1
  100. package/lib/commonjs/features/playback/progress.js +1 -1
  101. package/lib/commonjs/features/playback/state.js +1 -1
  102. package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
  103. package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
  104. package/lib/commonjs/features/playback/volume.js +1 -1
  105. package/lib/commonjs/features/player/options.js +48 -34
  106. package/lib/commonjs/features/player/options.js.map +1 -1
  107. package/lib/commonjs/features/player/setup.js +15 -8
  108. package/lib/commonjs/features/player/setup.js.map +1 -1
  109. package/lib/commonjs/features/player/validateOptions.js +21 -0
  110. package/lib/commonjs/features/player/validateOptions.js.map +1 -0
  111. package/lib/commonjs/features/queue/activeTrack.js +1 -1
  112. package/lib/commonjs/features/queue/queue.js +2 -2
  113. package/lib/commonjs/features/queue/repeatMode.js +1 -1
  114. package/lib/commonjs/features/queue/shuffle.js +1 -1
  115. package/lib/commonjs/features/remoteControls.js +13 -92
  116. package/lib/commonjs/features/remoteControls.js.map +1 -1
  117. package/lib/commonjs/features/sleepTimer.js +13 -1
  118. package/lib/commonjs/features/sleepTimer.js.map +1 -1
  119. package/lib/commonjs/test-utils/reactNativeStub.js +65 -0
  120. package/lib/commonjs/test-utils/reactNativeStub.js.map +1 -0
  121. package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
  122. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  123. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  124. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  125. package/lib/commonjs/web/browser/BrowserManager.js +1 -1
  126. package/lib/module/AudioBrowser.js +1 -1
  127. package/lib/module/AudioBrowser.js.map +1 -1
  128. package/lib/module/CLAUDE.md +52 -0
  129. package/lib/module/features/browser.js +1 -1
  130. package/lib/module/features/browser.js.map +1 -1
  131. package/lib/module/features/carConnection.js +1 -1
  132. package/lib/module/features/equalizer.js +1 -1
  133. package/lib/module/features/errors.js +3 -3
  134. package/lib/module/features/favorites.js +1 -1
  135. package/lib/module/features/gate.js +16 -1
  136. package/lib/module/features/gate.js.map +1 -1
  137. package/lib/module/features/metadata.js +20 -3
  138. package/lib/module/features/metadata.js.map +1 -1
  139. package/lib/module/features/network.js +1 -1
  140. package/lib/module/features/nowPlaying.js +1 -1
  141. package/lib/module/features/output.js +1 -1
  142. package/lib/module/features/playback/playWhenReady.js +1 -1
  143. package/lib/module/features/playback/playing.js +1 -1
  144. package/lib/module/features/playback/progress.js +1 -1
  145. package/lib/module/features/playback/state.js +1 -1
  146. package/lib/module/features/playback/trackPlaybackTime.js +0 -5
  147. package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
  148. package/lib/module/features/playback/volume.js +1 -1
  149. package/lib/module/features/player/options.js +47 -32
  150. package/lib/module/features/player/options.js.map +1 -1
  151. package/lib/module/features/player/setup.js +14 -7
  152. package/lib/module/features/player/setup.js.map +1 -1
  153. package/lib/module/features/player/validateOptions.js +17 -0
  154. package/lib/module/features/player/validateOptions.js.map +1 -0
  155. package/lib/module/features/queue/activeTrack.js +1 -1
  156. package/lib/module/features/queue/queue.js +2 -2
  157. package/lib/module/features/queue/repeatMode.js +1 -1
  158. package/lib/module/features/queue/shuffle.js +1 -1
  159. package/lib/module/features/remoteControls.js +13 -92
  160. package/lib/module/features/remoteControls.js.map +1 -1
  161. package/lib/module/features/sleepTimer.js +13 -1
  162. package/lib/module/features/sleepTimer.js.map +1 -1
  163. package/lib/module/test-utils/reactNativeStub.js +58 -0
  164. package/lib/module/test-utils/reactNativeStub.js.map +1 -0
  165. package/lib/module/utils/LazyNativeEmitter.js +8 -5
  166. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  167. package/lib/module/web/NativeAudioBrowser.js +1 -1
  168. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  169. package/lib/module/web/browser/BrowserManager.js +1 -1
  170. package/lib/typescript/src/AudioBrowser.d.ts +0 -1
  171. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  172. package/lib/typescript/src/features/carConnection.d.ts +1 -1
  173. package/lib/typescript/src/features/equalizer.d.ts +1 -1
  174. package/lib/typescript/src/features/errors.d.ts +3 -3
  175. package/lib/typescript/src/features/favorites.d.ts +1 -1
  176. package/lib/typescript/src/features/gate.d.ts +18 -2
  177. package/lib/typescript/src/features/gate.d.ts.map +1 -1
  178. package/lib/typescript/src/features/metadata.d.ts +20 -3
  179. package/lib/typescript/src/features/metadata.d.ts.map +1 -1
  180. package/lib/typescript/src/features/network.d.ts +1 -1
  181. package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
  182. package/lib/typescript/src/features/output.d.ts +1 -1
  183. package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
  184. package/lib/typescript/src/features/playback/playing.d.ts +1 -1
  185. package/lib/typescript/src/features/playback/progress.d.ts +1 -1
  186. package/lib/typescript/src/features/playback/state.d.ts +1 -1
  187. package/lib/typescript/src/features/playback/volume.d.ts +1 -1
  188. package/lib/typescript/src/features/player/options.d.ts +87 -37
  189. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  190. package/lib/typescript/src/features/player/setup.d.ts +14 -4
  191. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  192. package/lib/typescript/src/features/player/validateOptions.d.ts +9 -0
  193. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
  194. package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
  195. package/lib/typescript/src/features/queue/queue.d.ts +2 -2
  196. package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
  197. package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
  198. package/lib/typescript/src/features/remoteControls.d.ts +23 -22
  199. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  200. package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
  201. package/lib/typescript/src/features/sleepTimer.d.ts.map +1 -1
  202. package/lib/typescript/src/test-utils/reactNativeStub.d.ts +36 -0
  203. package/lib/typescript/src/test-utils/reactNativeStub.d.ts.map +1 -0
  204. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
  205. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  206. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -1
  207. package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
  208. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +5 -5
  209. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +11 -11
  210. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +1 -1
  211. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +5 -5
  212. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +5 -5
  213. package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
  214. package/nitrogen/generated/android/c++/JOptions.hpp +5 -5
  215. package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
  216. package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
  217. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
  218. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
  219. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
  220. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +1 -1
  221. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
  222. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
  223. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
  224. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
  225. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +36 -51
  226. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +6 -6
  227. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +8 -8
  228. package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
  229. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +1 -1
  230. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +2 -2
  231. package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
  232. package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
  233. package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
  234. package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
  235. package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
  236. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +1 -1
  237. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +1 -1
  238. package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
  239. package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
  240. package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
  241. package/package.json +12 -4
  242. package/src/AudioBrowser.ts +1 -1
  243. package/src/CLAUDE.md +52 -0
  244. package/src/features/battery.test.ts +79 -0
  245. package/src/features/browser.ts +1 -1
  246. package/src/features/carConnection.ts +1 -1
  247. package/src/features/equalizer.ts +1 -1
  248. package/src/features/errors.ts +3 -3
  249. package/src/features/favorites.ts +1 -1
  250. package/src/features/gate.ts +16 -1
  251. package/src/features/hook-wiring.test.ts +254 -0
  252. package/src/features/metadata.ts +20 -3
  253. package/src/features/network.ts +1 -1
  254. package/src/features/nowPlaying.ts +1 -1
  255. package/src/features/output.ts +1 -1
  256. package/src/features/playback/playWhenReady.ts +1 -1
  257. package/src/features/playback/playing.ts +1 -1
  258. package/src/features/playback/progress.test.ts +169 -0
  259. package/src/features/playback/progress.ts +1 -1
  260. package/src/features/playback/state.ts +1 -1
  261. package/src/features/playback/trackPlaybackTime.test.ts +16 -6
  262. package/src/features/playback/trackPlaybackTime.ts +0 -5
  263. package/src/features/playback/volume.ts +1 -1
  264. package/src/features/player/options.ts +99 -57
  265. package/src/features/player/setup.test.ts +12 -4
  266. package/src/features/player/setup.ts +19 -9
  267. package/src/features/player/validateOptions.ts +19 -0
  268. package/src/features/queue/activeTrack.ts +1 -1
  269. package/src/features/queue/queue.ts +2 -2
  270. package/src/features/queue/repeatMode.ts +1 -1
  271. package/src/features/queue/shuffle.ts +1 -1
  272. package/src/features/remoteControls.ts +13 -92
  273. package/src/features/sleepTimer.test.ts +172 -0
  274. package/src/features/sleepTimer.ts +9 -1
  275. package/src/nitro-stub-drift.test.ts +237 -0
  276. package/src/public-surface.test.ts +107 -0
  277. package/src/specs/audio-browser.nitro.ts +1 -1
  278. package/src/test-utils/reactNativeStub.ts +60 -0
  279. package/src/utils/LazyNativeEmitter.ts +9 -6
  280. package/src/utils/useDebug.test.ts +182 -0
  281. package/src/utils/useNativeUpdatedValue.test.ts +103 -0
  282. package/src/web/NativeAudioBrowser.ts +1 -1
  283. package/src/web/browser/BrowserManager.ts +1 -1
  284. package/ios/Model/MediaURL.swift +0 -67
  285. package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
  286. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
  287. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
  289. package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
  290. 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
@@ -1,6 +1,7 @@
1
1
  package com.audiobrowser.tls
2
2
 
3
3
  import java.security.cert.X509Certificate
4
+ import javax.security.auth.x500.X500Principal
4
5
 
5
6
  /**
6
7
  * Fills in missing intermediate CA certificates by following the Authority Information Access (AIA)
@@ -14,6 +15,18 @@ import java.security.cert.X509Certificate
14
15
  * found".
15
16
  */
16
17
  object AiaCertChaser {
18
+ /** How many distinct CA-Issuers URLs from one certificate are worth trying. */
19
+ const val MAX_URLS_PER_CERT = 3
20
+
21
+ /**
22
+ * Wall-clock ceiling on one chain-completion attempt, across all of its fetches. The deadline it
23
+ * implies is handed to every fetch, whose connects, handshakes, reads and redirects are all
24
+ * clipped to the time remaining — so the whole walk ends at the deadline. The one thing it cannot
25
+ * cut short is a DNS lookup already in flight (Java exposes no timeout for resolution), so the
26
+ * true bound is this budget plus at most one lookup's duration.
27
+ */
28
+ const val MAX_CHASE_MILLIS = 20_000L
29
+
17
30
  /** Authority Information Access extension. */
18
31
  private const val AIA_OID = "1.3.6.1.5.5.7.1.1"
19
32
 
@@ -69,8 +82,9 @@ object AiaCertChaser {
69
82
 
70
83
  /**
71
84
  * Walks up from the presented chain, fetching each missing issuer via its predecessor's AIA "CA
72
- * Issuers" pointer, until it reaches a self-signed certificate, an issuer that cannot be fetched,
73
- * or [maxIntermediates] additions. Returns the (possibly extended) chain.
85
+ * Issuers" pointer, until it reaches a certificate issued by one of [anchorSubjects], a
86
+ * self-signed certificate, an issuer that cannot be fetched, or [maxIntermediates] additions.
87
+ * Returns the (possibly extended) chain.
74
88
  *
75
89
  * Only ever *adds* certificates — the caller is still responsible for validating the completed
76
90
  * chain against the system trust anchors, so this can never cause an untrusted chain to be
@@ -78,25 +92,52 @@ object AiaCertChaser {
78
92
  * selected (defends against an AIA pointer to an unrelated certificate, and picks the right one
79
93
  * out of a multi-certificate PKCS#7 bundle).
80
94
  *
95
+ * @param anchorSubjects the subjects of the caller's trust anchors. A chain whose top is already
96
+ * issued by one of them is not missing anything, so nothing is fetched — chasing could not help
97
+ * and the fetch would be a blocking round trip to a host named by a certificate that just
98
+ * failed validation. This is what keeps an expired-but-complete chain, a hostname mismatch or
99
+ * any other non-path failure from triggering a chase, and it stops the genuine
100
+ * missing-intermediate case one hop early, where the chain reaches a root the caller already
101
+ * trusts. Empty means "unknown", and the walk runs to one of the other stopping conditions.
102
+ * @param maxUrlsPerCert how many of a certificate's distinct CA-Issuers URLs are tried before
103
+ * giving up on it. The extension is authored by the same server that failed validation and
104
+ * nothing limits how many URLs it may list — a leaf can carry hundreds — while each one costs a
105
+ * blocking connect on the handshake thread. Real CAs publish one. Duplicates are collapsed
106
+ * before the cap, so the cap buys distinct hosts, not repeat visits to one dead one.
107
+ * @param budgetMs wall-clock ceiling on the whole walk. It is checked before each fetch, and the
108
+ * deadline it implies is passed to [fetch], which is expected to clip its own connects,
109
+ * handshakes, reads and redirects to the time remaining — so the budget bounds the fetches
110
+ * themselves, not just how many of them start.
81
111
  * @param fetch resolves a CA-Issuers URL to its candidate certificates (e.g. every certificate in
82
- * a PKCS#7 bundle); empty if nothing could be retrieved.
112
+ * a PKCS#7 bundle), giving up no later than `deadlineNanos` (absolute, [nowNanos] origin);
113
+ * empty if nothing could be retrieved.
83
114
  */
84
115
  fun completeChain(
85
116
  presented: List<X509Certificate>,
117
+ anchorSubjects: Set<X500Principal> = emptySet(),
86
118
  maxIntermediates: Int = 5,
87
- fetch: (url: String) -> List<X509Certificate>,
119
+ maxUrlsPerCert: Int = MAX_URLS_PER_CERT,
120
+ budgetMs: Long = MAX_CHASE_MILLIS,
121
+ nowNanos: () -> Long = System::nanoTime,
122
+ fetch: (url: String, deadlineNanos: Long) -> List<X509Certificate>,
88
123
  ): List<X509Certificate> {
89
124
  if (presented.isEmpty()) return presented
90
125
  val chain = presented.toMutableList()
91
126
  val seen = HashSet(chain)
127
+ val deadline = nowNanos() + budgetMs * 1_000_000
92
128
  var added = 0
93
129
  while (added < maxIntermediates) {
94
130
  val last = chain.last()
95
131
  if (last.subjectX500Principal == last.issuerX500Principal) break // self-signed root
132
+ if (last.issuerX500Principal in anchorSubjects) break // already reaches a trust anchor
96
133
  val issuer =
97
134
  extractCaIssuerUrls(last)
98
135
  .asSequence()
99
- .flatMap { url -> runCatching { fetch(url) }.getOrElse { emptyList() } }
136
+ .distinct()
137
+ .take(maxUrlsPerCert)
138
+ // Subtraction, not `>`: overflow-safe across nanoTime's arbitrary origin.
139
+ .takeWhile { nowNanos() - deadline < 0 }
140
+ .flatMap { url -> runCatching { fetch(url, deadline) }.getOrElse { emptyList() } }
100
141
  .firstOrNull { it.subjectX500Principal == last.issuerX500Principal } ?: break
101
142
  if (!seen.add(issuer)) break // cycle guard
102
143
  chain.add(issuer)
@@ -106,9 +147,13 @@ object AiaCertChaser {
106
147
  }
107
148
 
108
149
  /**
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".
150
+ * Minimal DER TLV reader over a byte buffer. Malformed input throws [IllegalArgumentException],
151
+ * which the callers above expect and turn into "no AIA".
152
+ *
153
+ * Every read is bounds-checked, and a declared length is rejected unless it is non-negative and
154
+ * within the buffer. Both matter: the extension bytes come from a server-presented certificate,
155
+ * so a length that shifts into a negative Int would rewind `pos` and spin [extractCaIssuerUrls]'s
156
+ * loop forever on the handshake thread rather than failing.
112
157
  */
113
158
  private class DerReader(private val buf: ByteArray) {
114
159
  private var pos = 0
@@ -116,17 +161,28 @@ object AiaCertChaser {
116
161
  fun hasNext(): Boolean = pos < buf.size
117
162
 
118
163
  fun read(): Tlv {
119
- val tag = buf[pos++].toInt() and 0xFF
120
- var len = buf[pos++].toInt() and 0xFF
164
+ val tag = readByte()
165
+ var len = readByte()
121
166
  if (len and 0x80 != 0) {
167
+ // Long form: the low bits give the number of length bytes. Four already covers any
168
+ // buffer that fits in memory; more would overflow the Int we shift them into. The
169
+ // indefinite form (0x80, i.e. zero length bytes) is not valid DER.
122
170
  val numBytes = len and 0x7F
171
+ require(numBytes in 1..4) { "unsupported DER length form: $numBytes length bytes" }
123
172
  len = 0
124
- repeat(numBytes) { len = (len shl 8) or (buf[pos++].toInt() and 0xFF) }
173
+ repeat(numBytes) { len = (len shl 8) or readByte() }
174
+ require(len >= 0) { "DER length overflows Int" }
125
175
  }
176
+ require(len <= buf.size - pos) { "DER length $len runs past the end of the buffer" }
126
177
  val tlv = Tlv(buf, tag, pos, len)
127
178
  pos += len
128
179
  return tlv
129
180
  }
181
+
182
+ private fun readByte(): Int {
183
+ require(pos < buf.size) { "truncated DER" }
184
+ return buf[pos++].toInt() and 0xFF
185
+ }
130
186
  }
131
187
 
132
188
  /** A parsed tag-length-value: the tag and a view of its content bytes. */
@@ -0,0 +1,168 @@
1
+ package com.audiobrowser.tls
2
+
3
+ import android.os.Build
4
+ import androidx.annotation.RequiresApi
5
+ import java.lang.reflect.InvocationTargetException
6
+ import java.lang.reflect.Method
7
+ import java.net.Socket
8
+ import java.security.cert.CertificateException
9
+ import java.security.cert.X509Certificate
10
+ import javax.net.ssl.SSLEngine
11
+ import javax.net.ssl.X509ExtendedTrustManager
12
+ import javax.net.ssl.X509TrustManager
13
+ import javax.security.auth.x500.X500Principal
14
+
15
+ /**
16
+ * The [AiaChasingTrustManager] behaviour, as an [X509ExtendedTrustManager].
17
+ *
18
+ * The socket- and engine-aware overloads are the ones the platform actually calls, and they are not
19
+ * optional. Android's default trust manager is a `RootTrustManager`, which extends
20
+ * [X509ExtendedTrustManager] and whose two-argument `checkServerTrusted` throws unconditionally
21
+ * once the app declares any `<domain-config>` in its network security config:
22
+ * > Domain specific configurations require that hostname aware
23
+ * > checkServerTrusted(X509Certificate[], String, String) is used
24
+ *
25
+ * Wrapping it in a plain [X509TrustManager] hides those overloads, so conscrypt falls through to
26
+ * the two-argument form and *every* HTTPS connection in the process fails. The socket overload is
27
+ * also where endpoint identification happens for a raw `SSLSocket` configured with
28
+ * `endpointIdentificationAlgorithm = "HTTPS"`; skipping it skips hostname verification.
29
+ *
30
+ * [X509ExtendedTrustManager] is API 24+, hence the split from [AiaChasingTrustManager] — see
31
+ * [AiaTls.trustManager], which picks by SDK level. Per-domain network security configs are
32
+ * themselves API 24+, so the plain variant on 23 loses nothing.
33
+ *
34
+ * @param delegate the wrapped trust manager, normally the platform default.
35
+ * @param fetch resolves a CA-Issuers URL to its candidate certificates, giving up no later than
36
+ * `deadlineNanos`.
37
+ */
38
+ @RequiresApi(Build.VERSION_CODES.N)
39
+ class AiaChasingExtendedTrustManager(
40
+ private val delegate: X509TrustManager,
41
+ private val fetch: (url: String, deadlineNanos: Long) -> List<X509Certificate>,
42
+ ) : X509ExtendedTrustManager() {
43
+
44
+ /** The delegate's own extended overloads, when it has them. */
45
+ private val extended = delegate as? X509ExtendedTrustManager
46
+
47
+ private val anchorSubjects: Set<X500Principal> by lazy { delegate.anchorSubjects() }
48
+
49
+ /**
50
+ * The delegate's Android-specific hostname-aware check, if it has one.
51
+ *
52
+ * `android.net.http.X509TrustManagerExtensions` — how an HTTP client reaches per-host certificate
53
+ * pinning — locates this method reflectively and refuses a trust manager that lacks it. It is not
54
+ * part of [X509ExtendedTrustManager], and `RootTrustManager` is not a public type, so it is
55
+ * forwarded reflectively rather than overridden.
56
+ */
57
+ private val hostAwareCheck: Method? by lazy {
58
+ runCatching {
59
+ delegate.javaClass.getMethod(
60
+ "checkServerTrusted",
61
+ Array<X509Certificate>::class.java,
62
+ String::class.java,
63
+ String::class.java,
64
+ )
65
+ }
66
+ .getOrNull()
67
+ }
68
+
69
+ override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) =
70
+ withAiaRetry(chain, anchorSubjects, fetch) { delegate.checkServerTrusted(it, authType) }
71
+
72
+ override fun checkServerTrusted(
73
+ chain: Array<X509Certificate>,
74
+ authType: String,
75
+ socket: Socket?,
76
+ ) =
77
+ withAiaRetry(chain, anchorSubjects, fetch) {
78
+ // A delegate without the extended overloads cannot be hostname-aware in the first place,
79
+ // so falling back to its two-argument form loses nothing it could have offered.
80
+ extended?.checkServerTrusted(it, authType, socket)
81
+ ?: delegate.checkServerTrusted(it, authType)
82
+ }
83
+
84
+ override fun checkServerTrusted(
85
+ chain: Array<X509Certificate>,
86
+ authType: String,
87
+ engine: SSLEngine?,
88
+ ) =
89
+ withAiaRetry(chain, anchorSubjects, fetch) {
90
+ extended?.checkServerTrusted(it, authType, engine)
91
+ ?: delegate.checkServerTrusted(it, authType)
92
+ }
93
+
94
+ /**
95
+ * The signature `X509TrustManagerExtensions` looks for: validates against [host] and returns the
96
+ * chain it accepted. Kept in step with the other overloads, AIA retry included, so installing
97
+ * this wrapper does not cost a consumer its certificate pinning.
98
+ */
99
+ @Suppress("unused") // called reflectively
100
+ fun checkServerTrusted(
101
+ chain: Array<X509Certificate>,
102
+ authType: String,
103
+ host: String,
104
+ ): List<X509Certificate> {
105
+ var accepted: List<X509Certificate> = emptyList()
106
+ val method = hostAwareCheck
107
+ withAiaRetry(chain, anchorSubjects, fetch) { checked ->
108
+ // Whichever chain passed is the one to report — reporting the originally presented chain
109
+ // would hand a pinning consumer a chain missing the intermediate that made it validate.
110
+ // `ifEmpty` guards a delegate that reports nothing on success; the checked chain is what
111
+ // it accepted, so that is the answer.
112
+ accepted =
113
+ if (method != null)
114
+ invokeHostAware(method, checked, authType, host).ifEmpty { checked.toList() }
115
+ else {
116
+ // Same routing as the other overloads: an extended delegate may refuse its
117
+ // two-argument form outright (the domain-config case), so prefer its socket overload.
118
+ extended?.checkServerTrusted(checked, authType, null as Socket?)
119
+ ?: delegate.checkServerTrusted(checked, authType)
120
+ checked.toList()
121
+ }
122
+ }
123
+ return accepted
124
+ }
125
+
126
+ @Suppress("UNCHECKED_CAST")
127
+ private fun invokeHostAware(
128
+ method: Method,
129
+ chain: Array<X509Certificate>,
130
+ authType: String,
131
+ host: String,
132
+ ): List<X509Certificate> =
133
+ try {
134
+ method.invoke(delegate, chain, authType, host) as List<X509Certificate>
135
+ } catch (e: InvocationTargetException) {
136
+ // Unwrap, so a rejection still reaches withAiaRetry as the CertificateException it is.
137
+ when (val cause = e.targetException) {
138
+ is CertificateException -> throw cause
139
+ is RuntimeException -> throw cause
140
+ else -> throw CertificateException(cause)
141
+ }
142
+ }
143
+
144
+ // Client certificates are the app's own; there is nothing to chase.
145
+
146
+ override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) =
147
+ delegate.checkClientTrusted(chain, authType)
148
+
149
+ override fun checkClientTrusted(
150
+ chain: Array<X509Certificate>,
151
+ authType: String,
152
+ socket: Socket?,
153
+ ) {
154
+ extended?.checkClientTrusted(chain, authType, socket)
155
+ ?: delegate.checkClientTrusted(chain, authType)
156
+ }
157
+
158
+ override fun checkClientTrusted(
159
+ chain: Array<X509Certificate>,
160
+ authType: String,
161
+ engine: SSLEngine?,
162
+ ) {
163
+ extended?.checkClientTrusted(chain, authType, engine)
164
+ ?: delegate.checkClientTrusted(chain, authType)
165
+ }
166
+
167
+ override fun getAcceptedIssuers(): Array<X509Certificate> = delegate.acceptedIssuers
168
+ }
@@ -3,6 +3,7 @@ package com.audiobrowser.tls
3
3
  import java.security.cert.CertificateException
4
4
  import java.security.cert.X509Certificate
5
5
  import javax.net.ssl.X509TrustManager
6
+ import javax.security.auth.x500.X500Principal
6
7
 
7
8
  /**
8
9
  * An [X509TrustManager] that retries validation after filling in missing intermediate certificates
@@ -10,28 +11,71 @@ import javax.net.ssl.X509TrustManager
10
11
  *
11
12
  * Wraps a [delegate] (typically the platform default trust manager). On the happy path the delegate
12
13
  * accepts the server-presented chain on the first try and this adds nothing. Only when the delegate
13
- * rejects the chain does it attempt AIA chasing — and the *completed* chain is still validated by
14
- * the same delegate against the system trust anchors, so trust can never be weakened: an untrusted
15
- * root still fails. See [AiaCertChaser].
14
+ * rejects the chain *and* the top of the chain is not already issued by one of the delegate's
15
+ * anchors does it attempt AIA chasing. That gate is an issuer-name test, not a diagnosis: it rules
16
+ * out the common non-path failures (an expired leaf, a hostname mismatch on a complete chain), but
17
+ * a chain topping at a root the device does not carry is still chased whatever its real problem
18
+ * was. Either way the *completed* chain is still validated by the same delegate against the system
19
+ * trust anchors, so trust can never be weakened: an untrusted root still fails. See
20
+ * [AiaCertChaser].
21
+ *
22
+ * This is the API 23 variant. On 24+ [AiaChasingExtendedTrustManager] is used instead, and must be:
23
+ * see its documentation for why a plain [X509TrustManager] is not a safe wrapper there.
16
24
  */
17
25
  class AiaChasingTrustManager(
18
26
  private val delegate: X509TrustManager,
19
- private val fetch: (url: String) -> List<X509Certificate>,
27
+ private val fetch: (url: String, deadlineNanos: Long) -> List<X509Certificate>,
20
28
  ) : X509TrustManager {
21
29
 
22
- override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
23
- try {
24
- delegate.checkServerTrusted(chain, authType)
25
- } catch (original: CertificateException) {
26
- val completed = AiaCertChaser.completeChain(chain.toList(), fetch = fetch)
27
- // Nothing could be added — the original failure stands (don't re-check an unchanged chain).
28
- if (completed.size == chain.size) throw original
29
- delegate.checkServerTrusted(completed.toTypedArray(), authType)
30
- }
31
- }
30
+ private val anchorSubjects: Set<X500Principal> by lazy { delegate.anchorSubjects() }
31
+
32
+ override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) =
33
+ withAiaRetry(chain, anchorSubjects, fetch) { delegate.checkServerTrusted(it, authType) }
32
34
 
33
35
  override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) =
34
36
  delegate.checkClientTrusted(chain, authType)
35
37
 
36
38
  override fun getAcceptedIssuers(): Array<X509Certificate> = delegate.acceptedIssuers
37
39
  }
40
+
41
+ /** The subjects of a trust manager's accepted issuers; empty if it will not say. */
42
+ internal fun X509TrustManager.anchorSubjects(): Set<X500Principal> =
43
+ runCatching { acceptedIssuers.mapTo(HashSet()) { it.subjectX500Principal } }
44
+ .getOrElse { emptySet() }
45
+
46
+ /**
47
+ * Runs [check], and on rejection retries it once against the AIA-completed chain.
48
+ *
49
+ * Nothing is fetched when the presented chain already reaches one of the caller's anchors — see
50
+ * [AiaCertChaser.completeChain]'s `anchorSubjects`. Without that test *every* failed handshake in
51
+ * the process, whatever the cause, would make a blocking outbound request to a host named by the
52
+ * certificate that just failed: an expired certificate, a hostname mismatch and a
53
+ * certificate-transparency refusal all arrive here as a [CertificateException] from a delegate that
54
+ * is perfectly happy with the path. (The test is on issuer names, so a chain topping at an unknown
55
+ * root is still chased even when its real failure was something else.)
56
+ *
57
+ * The completed chain goes through the same [check], so trust is never weakened: whatever the
58
+ * delegate would have refused, it still refuses.
59
+ */
60
+ internal inline fun withAiaRetry(
61
+ chain: Array<X509Certificate>,
62
+ anchorSubjects: Set<X500Principal>,
63
+ noinline fetch: (url: String, deadlineNanos: Long) -> List<X509Certificate>,
64
+ check: (Array<X509Certificate>) -> Unit,
65
+ ) {
66
+ try {
67
+ check(chain)
68
+ } catch (original: CertificateException) {
69
+ val completed = AiaCertChaser.completeChain(chain.toList(), anchorSubjects, fetch = fetch)
70
+ // Nothing could be added — the original failure stands (don't re-check an unchanged chain).
71
+ if (completed.size == chain.size) throw original
72
+ try {
73
+ check(completed.toTypedArray())
74
+ } catch (retried: CertificateException) {
75
+ // The chase can change which failure the caller sees (a missing intermediate can turn out
76
+ // to hide an expiry). Keep the first one attached rather than losing it.
77
+ retried.addSuppressed(original)
78
+ throw retried
79
+ }
80
+ }
81
+ }
@@ -1,9 +1,11 @@
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
6
7
  import javax.net.ssl.TrustManagerFactory
8
+ import javax.net.ssl.X509ExtendedTrustManager
7
9
  import javax.net.ssl.X509TrustManager
8
10
 
9
11
  /**
@@ -11,11 +13,24 @@ import javax.net.ssl.X509TrustManager
11
13
  * following the leaf's Authority Information Access (AIA) "CA Issuers" pointer — the behaviour
12
14
  * Apple's Secure Transport has by default but Android's lacks.
13
15
  *
14
- * The returned components are side-effect-free: they wrap the platform default trust manager and
15
- * only ever *add* a missing intermediate before re-validating against the same system trust
16
- * anchors, so they cannot weaken trust. Installing them as a process default (e.g.
17
- * `HttpsURLConnection.setDefaultSSLSocketFactory(AiaTls.socketFactory())`) is the caller's
18
- * decision.
16
+ * The returned components wrap the platform default trust manager and only ever *add* a missing
17
+ * intermediate before re-validating against the same system trust anchors, so they cannot weaken
18
+ * trust: whatever the platform would have refused, it still refuses.
19
+ *
20
+ * What they are not is side-effect-free. Completing a chain means fetching the missing intermediate
21
+ * from a URL named by the certificate that just failed to validate — so a rejected handshake can
22
+ * make an outbound request to a host, port and path of that server's choosing, over cleartext, not
23
+ * subject to the app's `NetworkSecurityPolicy`. That is inherent to AIA chasing and is what
24
+ * browsers and Apple's Secure Transport do too, but it is a real consequence of installing this,
25
+ * and it is bounded rather than absent: see [CachingCertificateFetcher] for the per-fetch limits
26
+ * and [AiaCertChaser.MAX_CHASE_MILLIS] for the wall-clock ceiling on the whole chase. The fetch is
27
+ * skipped when the chain already reaches one of the system anchors — an issuer-name test, so it
28
+ * spares the common expired-leaf and hostname-mismatch failures, but a chain topping at a root the
29
+ * device does not carry is still chased whatever its real problem was.
30
+ *
31
+ * Installing them as a process default (e.g.
32
+ * `HttpsURLConnection.setDefaultSSLSocketFactory(AiaTls.socketFactory())`) applies that to every
33
+ * connection in the process, and is the caller's decision.
19
34
  */
20
35
  object AiaTls {
21
36
 
@@ -27,10 +42,23 @@ object AiaTls {
27
42
  fun socketFactory(): SSLSocketFactory =
28
43
  SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager()), null) }.socketFactory
29
44
 
30
- /** An [X509TrustManager] wrapping the platform default with AIA intermediate chasing. */
45
+ /**
46
+ * An [X509TrustManager] wrapping the platform default with AIA intermediate chasing.
47
+ *
48
+ * On API 24+ this is an [X509ExtendedTrustManager]. That is not cosmetic: the platform's default
49
+ * trust manager rejects the two-argument `checkServerTrusted` outright once the app declares any
50
+ * `<domain-config>`, so a plain wrapper would break every HTTPS connection in the process. See
51
+ * [AiaChasingExtendedTrustManager].
52
+ */
31
53
  fun trustManager(): X509TrustManager {
32
54
  val fetcher = CachingCertificateFetcher()
33
- return AiaChasingTrustManager(systemTrustManager()) { fetcher.fetch(it) }
55
+ val fetch = { url: String, deadlineNanos: Long -> fetcher.fetch(url, deadlineNanos) }
56
+ val system = systemTrustManager()
57
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
58
+ AiaChasingExtendedTrustManager(system, fetch)
59
+ } else {
60
+ AiaChasingTrustManager(system, fetch)
61
+ }
34
62
  }
35
63
 
36
64
  private fun systemTrustManager(): X509TrustManager {