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.
- package/README.md +22 -18
- package/android/build.gradle +7 -5
- package/android/gradle.properties +1 -1
- package/android/src/main/AndroidManifest.xml +3 -1
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +76 -21
- package/android/src/main/java/com/audiobrowser/AudioBrowserLifecycleModule.kt +38 -0
- package/android/src/main/java/com/audiobrowser/AudioBrowserPackage.kt +18 -2
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +48 -0
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +1 -2
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +1 -2
- package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +6 -7
- package/android/src/main/java/com/audiobrowser/http/HttpClient.kt +20 -3
- package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +11 -11
- package/android/src/main/java/com/audiobrowser/player/CapabilityControls.kt +111 -58
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +9 -5
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCommandManager.kt +218 -252
- package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +0 -1
- package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -4
- package/android/src/main/java/com/audiobrowser/player/Player.kt +30 -8
- package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
- package/android/src/main/java/com/audiobrowser/player/TransformingDataSource.kt +2 -1
- package/android/src/main/java/com/audiobrowser/tls/AiaCertChaser.kt +21 -6
- package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +109 -0
- package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +16 -2
- package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +59 -3
- package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
- package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +87 -0
- package/android/src/test/java/com/audiobrowser/TestFixtures.kt +0 -1
- package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
- package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
- package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +100 -56
- package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
- package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
- package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
- package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +46 -0
- package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +113 -0
- package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherTest.kt +83 -0
- package/ios/Browser/BrowserManager.swift +2 -27
- package/ios/Browser/SearchDrillIn.swift +1 -1
- package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
- package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
- package/ios/HybridAudioBrowser.swift +87 -56
- package/ios/Model/RemoteCommand.swift +18 -0
- package/ios/Model/TrackPlayerError.swift +2 -2
- package/ios/Option/SessionCategories.swift +3 -2
- package/ios/Player/MediaIntentCriteria.swift +4 -4
- package/ios/Player/MediaLoader.swift +3 -1
- package/ios/Player/PlaybackCoordinator.swift +4 -1
- package/ios/Player/PlaybackErrorHandler.swift +4 -4
- package/ios/Player/QueueManager.swift +10 -3
- package/ios/Player/RetryManager.swift +79 -18
- package/ios/Player/ShuffleOrder.swift +10 -0
- package/ios/Player/SleepTimerManager.swift +43 -15
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
- package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
- package/ios/Tests/LRUCacheTests.swift +111 -0
- package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
- package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
- package/ios/Tests/MediaLoaderTests.swift +10 -13
- package/ios/Tests/PlaybackCoordinatorTests.swift +10 -7
- package/ios/Tests/PlaybackErrorHandlerTests.swift +3 -1
- package/ios/Tests/PlayerTimeObserverTests.swift +37 -0
- package/ios/Tests/QueueManagerTests.swift +46 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
- package/ios/Tests/RemoteCommandTests.swift +67 -0
- package/ios/Tests/RetryManagerTests.swift +89 -0
- package/ios/Tests/SectionScopeTests.swift +2 -2
- package/ios/Tests/SleepTimerManagerTests.swift +22 -19
- package/ios/Tests/Support/ManualSleepTimerScheduler.swift +47 -0
- package/ios/Tests/Support/TestTypes.swift +1 -1
- package/ios/Tests/TabBarEntriesTests.swift +7 -7
- package/ios/TrackPlayer.swift +6 -1
- package/ios/TrackPlayerCallbacks.swift +5 -38
- package/ios/Util/LRUCache.swift +4 -1
- package/lib/commonjs/AudioBrowser.js +0 -11
- package/lib/commonjs/AudioBrowser.js.map +1 -1
- package/lib/commonjs/CLAUDE.md +52 -0
- package/lib/commonjs/features/browser.js +1 -1
- package/lib/commonjs/features/browser.js.map +1 -1
- package/lib/commonjs/features/carConnection.js +1 -1
- package/lib/commonjs/features/equalizer.js +1 -1
- package/lib/commonjs/features/errors.js +3 -3
- package/lib/commonjs/features/favorites.js +1 -1
- package/lib/commonjs/features/gate.js +16 -1
- package/lib/commonjs/features/gate.js.map +1 -1
- package/lib/commonjs/features/metadata.js +20 -3
- package/lib/commonjs/features/metadata.js.map +1 -1
- package/lib/commonjs/features/network.js +1 -1
- package/lib/commonjs/features/nowPlaying.js +1 -1
- package/lib/commonjs/features/output.js +1 -1
- package/lib/commonjs/features/playback/playWhenReady.js +1 -1
- package/lib/commonjs/features/playback/playing.js +1 -1
- package/lib/commonjs/features/playback/progress.js +1 -1
- package/lib/commonjs/features/playback/state.js +1 -1
- package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
- package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
- package/lib/commonjs/features/playback/volume.js +1 -1
- package/lib/commonjs/features/player/options.js +48 -34
- package/lib/commonjs/features/player/options.js.map +1 -1
- package/lib/commonjs/features/player/setup.js +15 -8
- package/lib/commonjs/features/player/setup.js.map +1 -1
- package/lib/commonjs/features/player/validateOptions.js +21 -0
- package/lib/commonjs/features/player/validateOptions.js.map +1 -0
- package/lib/commonjs/features/queue/activeTrack.js +1 -1
- package/lib/commonjs/features/queue/queue.js +2 -2
- package/lib/commonjs/features/queue/repeatMode.js +1 -1
- package/lib/commonjs/features/queue/shuffle.js +1 -1
- package/lib/commonjs/features/remoteControls.js +13 -92
- package/lib/commonjs/features/remoteControls.js.map +1 -1
- package/lib/commonjs/features/sleepTimer.js +13 -1
- package/lib/commonjs/features/sleepTimer.js.map +1 -1
- package/lib/commonjs/test-utils/reactNativeStub.js +65 -0
- package/lib/commonjs/test-utils/reactNativeStub.js.map +1 -0
- package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
- package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
- package/lib/commonjs/web/browser/BrowserManager.js +1 -1
- package/lib/module/AudioBrowser.js +1 -1
- package/lib/module/AudioBrowser.js.map +1 -1
- package/lib/module/CLAUDE.md +52 -0
- package/lib/module/features/browser.js +1 -1
- package/lib/module/features/browser.js.map +1 -1
- package/lib/module/features/carConnection.js +1 -1
- package/lib/module/features/equalizer.js +1 -1
- package/lib/module/features/errors.js +3 -3
- package/lib/module/features/favorites.js +1 -1
- package/lib/module/features/gate.js +16 -1
- package/lib/module/features/gate.js.map +1 -1
- package/lib/module/features/metadata.js +20 -3
- package/lib/module/features/metadata.js.map +1 -1
- package/lib/module/features/network.js +1 -1
- package/lib/module/features/nowPlaying.js +1 -1
- package/lib/module/features/output.js +1 -1
- package/lib/module/features/playback/playWhenReady.js +1 -1
- package/lib/module/features/playback/playing.js +1 -1
- package/lib/module/features/playback/progress.js +1 -1
- package/lib/module/features/playback/state.js +1 -1
- package/lib/module/features/playback/trackPlaybackTime.js +0 -5
- package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
- package/lib/module/features/playback/volume.js +1 -1
- package/lib/module/features/player/options.js +47 -32
- package/lib/module/features/player/options.js.map +1 -1
- package/lib/module/features/player/setup.js +14 -7
- package/lib/module/features/player/setup.js.map +1 -1
- package/lib/module/features/player/validateOptions.js +17 -0
- package/lib/module/features/player/validateOptions.js.map +1 -0
- package/lib/module/features/queue/activeTrack.js +1 -1
- package/lib/module/features/queue/queue.js +2 -2
- package/lib/module/features/queue/repeatMode.js +1 -1
- package/lib/module/features/queue/shuffle.js +1 -1
- package/lib/module/features/remoteControls.js +13 -92
- package/lib/module/features/remoteControls.js.map +1 -1
- package/lib/module/features/sleepTimer.js +13 -1
- package/lib/module/features/sleepTimer.js.map +1 -1
- package/lib/module/test-utils/reactNativeStub.js +58 -0
- package/lib/module/test-utils/reactNativeStub.js.map +1 -0
- package/lib/module/utils/LazyNativeEmitter.js +8 -5
- package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js.map +1 -1
- package/lib/module/web/browser/BrowserManager.js +1 -1
- package/lib/typescript/src/AudioBrowser.d.ts +0 -1
- package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
- package/lib/typescript/src/features/carConnection.d.ts +1 -1
- package/lib/typescript/src/features/equalizer.d.ts +1 -1
- package/lib/typescript/src/features/errors.d.ts +3 -3
- package/lib/typescript/src/features/favorites.d.ts +1 -1
- package/lib/typescript/src/features/gate.d.ts +18 -2
- package/lib/typescript/src/features/gate.d.ts.map +1 -1
- package/lib/typescript/src/features/metadata.d.ts +20 -3
- package/lib/typescript/src/features/metadata.d.ts.map +1 -1
- package/lib/typescript/src/features/network.d.ts +1 -1
- package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
- package/lib/typescript/src/features/output.d.ts +1 -1
- package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
- package/lib/typescript/src/features/playback/playing.d.ts +1 -1
- package/lib/typescript/src/features/playback/progress.d.ts +1 -1
- package/lib/typescript/src/features/playback/state.d.ts +1 -1
- package/lib/typescript/src/features/playback/volume.d.ts +1 -1
- package/lib/typescript/src/features/player/options.d.ts +87 -37
- package/lib/typescript/src/features/player/options.d.ts.map +1 -1
- package/lib/typescript/src/features/player/setup.d.ts +14 -4
- package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
- package/lib/typescript/src/features/player/validateOptions.d.ts +9 -0
- package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
- package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
- package/lib/typescript/src/features/queue/queue.d.ts +2 -2
- package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
- package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
- package/lib/typescript/src/features/remoteControls.d.ts +23 -22
- package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
- package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
- package/lib/typescript/src/features/sleepTimer.d.ts.map +1 -1
- package/lib/typescript/src/test-utils/reactNativeStub.d.ts +36 -0
- package/lib/typescript/src/test-utils/reactNativeStub.d.ts.map +1 -0
- package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
- package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
- package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -1
- package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
- package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +5 -5
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +11 -11
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +1 -1
- package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +5 -5
- package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +5 -5
- package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
- package/nitrogen/generated/android/c++/JOptions.hpp +5 -5
- package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
- package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +1 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +36 -51
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +6 -6
- package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +8 -8
- package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
- package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +1 -1
- package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +2 -2
- package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
- package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
- package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
- package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
- package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
- package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +1 -1
- package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +1 -1
- package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
- package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
- package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
- package/package.json +10 -3
- package/src/AudioBrowser.ts +1 -1
- package/src/CLAUDE.md +52 -0
- package/src/features/battery.test.ts +79 -0
- package/src/features/browser.ts +1 -1
- package/src/features/carConnection.ts +1 -1
- package/src/features/equalizer.ts +1 -1
- package/src/features/errors.ts +3 -3
- package/src/features/favorites.ts +1 -1
- package/src/features/gate.ts +16 -1
- package/src/features/hook-wiring.test.ts +254 -0
- package/src/features/metadata.ts +20 -3
- package/src/features/network.ts +1 -1
- package/src/features/nowPlaying.ts +1 -1
- package/src/features/output.ts +1 -1
- package/src/features/playback/playWhenReady.ts +1 -1
- package/src/features/playback/playing.ts +1 -1
- package/src/features/playback/progress.test.ts +169 -0
- package/src/features/playback/progress.ts +1 -1
- package/src/features/playback/state.ts +1 -1
- package/src/features/playback/trackPlaybackTime.test.ts +16 -6
- package/src/features/playback/trackPlaybackTime.ts +0 -5
- package/src/features/playback/volume.ts +1 -1
- package/src/features/player/options.ts +99 -57
- package/src/features/player/setup.test.ts +12 -4
- package/src/features/player/setup.ts +19 -9
- package/src/features/player/validateOptions.ts +19 -0
- package/src/features/queue/activeTrack.ts +1 -1
- package/src/features/queue/queue.ts +2 -2
- package/src/features/queue/repeatMode.ts +1 -1
- package/src/features/queue/shuffle.ts +1 -1
- package/src/features/remoteControls.ts +13 -92
- package/src/features/sleepTimer.test.ts +172 -0
- package/src/features/sleepTimer.ts +9 -1
- package/src/public-surface.test.ts +107 -0
- package/src/specs/audio-browser.nitro.ts +1 -1
- package/src/test-utils/reactNativeStub.ts +60 -0
- package/src/utils/LazyNativeEmitter.ts +9 -6
- package/src/utils/useDebug.test.ts +182 -0
- package/src/utils/useNativeUpdatedValue.test.ts +103 -0
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/browser/BrowserManager.ts +1 -1
- package/ios/Model/MediaURL.swift +0 -67
- package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
- package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
- package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
- package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
- 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.
|
|
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
|
-
*
|
|
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
|
|
1219
|
-
previousOptions.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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 =
|
|
120
|
-
var len =
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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 =
|
|
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
|
+
}
|
|
@@ -10,7 +10,8 @@ class JsonArtworkTest {
|
|
|
10
10
|
|
|
11
11
|
@Test
|
|
12
12
|
fun `decodes a single url`() {
|
|
13
|
-
val track =
|
|
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
|
|