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.
- package/README.md +22 -18
- package/android/build.gradle +13 -10
- package/android/gradle.properties +2 -2
- 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 +67 -11
- package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +168 -0
- package/android/src/main/java/com/audiobrowser/tls/AiaChasingTrustManager.kt +58 -14
- package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +35 -7
- package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +278 -59
- 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 +52 -1
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +6 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +167 -8
- 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 +197 -7
- package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +325 -4
- package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherCallSiteTest.kt +322 -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/NitroTypeStubs.swift +18 -2
- 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 +50 -4
- 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 +12 -4
- 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/nitro-stub-drift.test.ts +237 -0
- 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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
package com.audiobrowser.tls
|
|
2
|
+
|
|
3
|
+
import java.net.InetAddress
|
|
4
|
+
import java.net.ServerSocket
|
|
5
|
+
import java.security.cert.X509Certificate
|
|
6
|
+
import java.util.concurrent.CountDownLatch
|
|
7
|
+
import java.util.concurrent.TimeUnit
|
|
8
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
9
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
10
|
+
import javax.security.auth.x500.X500Principal
|
|
11
|
+
import kotlin.concurrent.thread
|
|
12
|
+
import org.junit.After
|
|
13
|
+
import org.junit.Assert.assertEquals
|
|
14
|
+
import org.junit.Assert.assertFalse
|
|
15
|
+
import org.junit.Assert.assertTrue
|
|
16
|
+
import org.junit.Test
|
|
17
|
+
import org.mockito.Mockito.mock
|
|
18
|
+
import org.mockito.Mockito.`when`
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Exercises [CachingCertificateFetcher.fetch] against a real loopback server.
|
|
22
|
+
*
|
|
23
|
+
* The safety helpers have their own unit tests, but a predicate that is never consulted protects
|
|
24
|
+
* nothing: these cover the *wiring*, so that removing the `isSafeUrl` guard or swapping
|
|
25
|
+
* `readCapped` back for a plain read-to-EOF fails a test rather than passing silently.
|
|
26
|
+
*/
|
|
27
|
+
class CachingCertificateFetcherCallSiteTest {
|
|
28
|
+
|
|
29
|
+
private var server: ServerSocket? = null
|
|
30
|
+
|
|
31
|
+
@After
|
|
32
|
+
fun tearDown() {
|
|
33
|
+
server?.close()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Serves [response] to every connection, recording the request line of the first. [handled]
|
|
38
|
+
* counts accepted connections.
|
|
39
|
+
*/
|
|
40
|
+
private fun startServer(
|
|
41
|
+
response: ByteArray = "HTTP/1.0 404 Not Found\r\n\r\n".toByteArray(),
|
|
42
|
+
handled: AtomicInteger = AtomicInteger(),
|
|
43
|
+
firstRequest: AtomicReference<String> = AtomicReference(),
|
|
44
|
+
): Int {
|
|
45
|
+
val socket = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
|
|
46
|
+
server = socket
|
|
47
|
+
thread(isDaemon = true) {
|
|
48
|
+
while (!socket.isClosed) {
|
|
49
|
+
try {
|
|
50
|
+
socket.accept().use { client ->
|
|
51
|
+
handled.incrementAndGet()
|
|
52
|
+
val request = ByteArray(1024)
|
|
53
|
+
val read = client.getInputStream().read(request)
|
|
54
|
+
firstRequest.compareAndSet(null, String(request, 0, maxOf(read, 0)))
|
|
55
|
+
client.getOutputStream().apply {
|
|
56
|
+
write(response)
|
|
57
|
+
flush()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e: Exception) {
|
|
61
|
+
return@thread // socket closed, or the client hung up
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return socket.localPort
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Test
|
|
69
|
+
fun `a URL carrying CRLF never reaches the network`() {
|
|
70
|
+
val handled = AtomicInteger()
|
|
71
|
+
val port = startServer(handled = handled)
|
|
72
|
+
|
|
73
|
+
// Without the isSafeUrl guard this connects and emits "SET foo bar" as its own request line.
|
|
74
|
+
val certs = CachingCertificateFetcher().fetch("http://127.0.0.1:$port/\r\nSET foo bar\r\n")
|
|
75
|
+
|
|
76
|
+
assertTrue(certs.isEmpty())
|
|
77
|
+
assertEquals("the request must not be sent at all", 0, handled.get())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Test
|
|
81
|
+
fun `a well-formed URL does reach the network, so the guard is not just refusing everything`() {
|
|
82
|
+
val handled = AtomicInteger()
|
|
83
|
+
val request = AtomicReference<String>()
|
|
84
|
+
val port = startServer(handled = handled, firstRequest = request)
|
|
85
|
+
|
|
86
|
+
CachingCertificateFetcher().fetch("http://127.0.0.1:$port/issuer.crt")
|
|
87
|
+
|
|
88
|
+
assertEquals(1, handled.get())
|
|
89
|
+
assertTrue("request line was: ${request.get()}", request.get().startsWith("GET /issuer.crt "))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@Test
|
|
93
|
+
fun `a non-default port is carried in the Host header`() {
|
|
94
|
+
val request = AtomicReference<String>()
|
|
95
|
+
val port = startServer(firstRequest = request)
|
|
96
|
+
|
|
97
|
+
CachingCertificateFetcher().fetch("http://127.0.0.1:$port/issuer.crt")
|
|
98
|
+
|
|
99
|
+
assertTrue(
|
|
100
|
+
"headers were: ${request.get()}",
|
|
101
|
+
request.get().contains("Host: 127.0.0.1:$port\r\n"),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@Test(timeout = 30_000)
|
|
106
|
+
fun `an oversized response is abandoned rather than read to EOF`() {
|
|
107
|
+
// Asserting only that the fetch fails would prove nothing — 4 MiB crosses loopback in
|
|
108
|
+
// milliseconds and would fail to parse either way. What has to be true is that the client
|
|
109
|
+
// stopped reading, which shows up as the server's write not draining.
|
|
110
|
+
val socket = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
|
|
111
|
+
server = socket
|
|
112
|
+
val written = AtomicInteger()
|
|
113
|
+
val finished = CountDownLatch(1)
|
|
114
|
+
thread(isDaemon = true) {
|
|
115
|
+
runCatching {
|
|
116
|
+
socket.accept().use { client ->
|
|
117
|
+
client.getInputStream().read(ByteArray(1024))
|
|
118
|
+
val out = client.getOutputStream()
|
|
119
|
+
out.write("HTTP/1.0 200 OK\r\n\r\n".toByteArray())
|
|
120
|
+
val chunk = ByteArray(64 * 1024)
|
|
121
|
+
// 64 MiB — far past the 1 MiB cap. Once the client stops reading and closes, this
|
|
122
|
+
// throws a broken pipe, which is the signal we want.
|
|
123
|
+
repeat(1024) {
|
|
124
|
+
out.write(chunk)
|
|
125
|
+
written.addAndGet(chunk.size)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
finished.countDown()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
val certs = CachingCertificateFetcher().fetch("http://127.0.0.1:${socket.localPort}/big.crt")
|
|
133
|
+
|
|
134
|
+
assertTrue(certs.isEmpty())
|
|
135
|
+
assertTrue(finished.await(20, TimeUnit.SECONDS))
|
|
136
|
+
// Socket buffers mean the server gets somewhat past the cap before the write fails, but it
|
|
137
|
+
// must be nowhere near the 64 MiB it wanted to send.
|
|
138
|
+
assertTrue(
|
|
139
|
+
"server wrote ${written.get()} bytes; the read was not cut off",
|
|
140
|
+
written.get() < 16 shl 20,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@Test(timeout = 30_000)
|
|
145
|
+
fun `a real certificate is fetched, parsed and then served from cache`() {
|
|
146
|
+
// The success path, end to end over a socket: every other wire test serves a failure, so a
|
|
147
|
+
// broken body offset or a broken cache write would not show up anywhere.
|
|
148
|
+
val der = CertFixtures.bytes("letsencrypt-r13.der")
|
|
149
|
+
val handled = AtomicInteger()
|
|
150
|
+
val port =
|
|
151
|
+
startServer(
|
|
152
|
+
response =
|
|
153
|
+
("HTTP/1.0 200 OK\r\nContent-Type: application/pkix-cert\r\n\r\n").toByteArray() + der,
|
|
154
|
+
handled = handled,
|
|
155
|
+
)
|
|
156
|
+
val fetcher = CachingCertificateFetcher()
|
|
157
|
+
val url = "http://127.0.0.1:$port/r13.der"
|
|
158
|
+
|
|
159
|
+
val certs = fetcher.fetch(url)
|
|
160
|
+
|
|
161
|
+
assertEquals(1, certs.size)
|
|
162
|
+
assertEquals("CN=R13,O=Let's Encrypt,C=US", certs.first().subjectX500Principal.name)
|
|
163
|
+
|
|
164
|
+
// Second call must be served from the cache, not the network.
|
|
165
|
+
assertEquals(certs, fetcher.fetch(url))
|
|
166
|
+
assertEquals("a successful fetch must be cached", 1, handled.get())
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A mock certificate whose AIA extension lists [url] as its one CA-Issuers location, so
|
|
171
|
+
* [AiaCertChaser.completeChain] chases it with this fetcher — the real wiring, end to end.
|
|
172
|
+
*/
|
|
173
|
+
private fun certPointingAt(url: String): X509Certificate {
|
|
174
|
+
// Short-form DER lengths only; a loopback URL is far under 128 bytes.
|
|
175
|
+
fun der(tag: Int, content: ByteArray) =
|
|
176
|
+
byteArrayOf(tag.toByte(), content.size.toByte()) + content
|
|
177
|
+
val caIssuersOid = byteArrayOf(0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x02)
|
|
178
|
+
val aia =
|
|
179
|
+
der(
|
|
180
|
+
0x04,
|
|
181
|
+
der(
|
|
182
|
+
0x30,
|
|
183
|
+
der(0x30, der(0x06, caIssuersOid) + der(0x86, url.toByteArray(Charsets.US_ASCII))),
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
return mock(X509Certificate::class.java).also {
|
|
187
|
+
`when`(it.getExtensionValue("1.3.6.1.5.5.7.1.1")).thenReturn(aia)
|
|
188
|
+
// Distinct principals, or the chaser reads the mock as a self-signed root and never fetches.
|
|
189
|
+
`when`(it.subjectX500Principal).thenReturn(X500Principal("CN=leaf"))
|
|
190
|
+
`when`(it.issuerX500Principal).thenReturn(X500Principal("CN=issuer"))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@Test(timeout = 30_000)
|
|
195
|
+
fun `a drip-feeding server is cut off by the chase deadline, not only the per-fetch caps`() {
|
|
196
|
+
// One byte every 100 ms: each read lands well inside the socket timeout, so per-read limits
|
|
197
|
+
// never fire, and the per-fetch read budget alone would let this run for its full 10 s. The
|
|
198
|
+
// walk's 1 s budget must cut the fetch *itself* — not merely decline to start the next one.
|
|
199
|
+
val socket = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
|
|
200
|
+
server = socket
|
|
201
|
+
thread(isDaemon = true) {
|
|
202
|
+
runCatching {
|
|
203
|
+
socket.accept().use { client ->
|
|
204
|
+
client.getInputStream().read(ByteArray(1024))
|
|
205
|
+
val out = client.getOutputStream()
|
|
206
|
+
out.write("HTTP/1.0 200 OK\r\n\r\n".toByteArray())
|
|
207
|
+
out.flush()
|
|
208
|
+
repeat(300) { // 30 s of drip on offer; the client must not stay for it
|
|
209
|
+
out.write(0)
|
|
210
|
+
out.flush()
|
|
211
|
+
Thread.sleep(100)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
val fetcher = CachingCertificateFetcher()
|
|
217
|
+
val cert = certPointingAt("http://127.0.0.1:${socket.localPort}/drip.crt")
|
|
218
|
+
|
|
219
|
+
val started = System.nanoTime()
|
|
220
|
+
val completed =
|
|
221
|
+
AiaCertChaser.completeChain(listOf(cert), budgetMs = 1_000) { url, deadline ->
|
|
222
|
+
fetcher.fetch(url, deadline)
|
|
223
|
+
}
|
|
224
|
+
val elapsedMs = (System.nanoTime() - started) / 1_000_000
|
|
225
|
+
|
|
226
|
+
assertEquals(listOf(cert), completed)
|
|
227
|
+
// Well past the 1 s budget to absorb scheduling noise, but far short of the 10 s the
|
|
228
|
+
// per-fetch read budget alone would have allowed.
|
|
229
|
+
assertTrue("chase ran for $elapsedMs ms", elapsedMs < 8_000)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@Test(timeout = 30_000)
|
|
233
|
+
fun `the chase deadline spans redirect hops, not just fetch starts`() {
|
|
234
|
+
// Every hop stalls 1 s and then redirects back to itself. Each hop sits comfortably inside
|
|
235
|
+
// the per-operation timeouts, so only a deadline threaded through the redirect recursion
|
|
236
|
+
// can end the fetch — without one it walks all 5 redirects, ~6 s here.
|
|
237
|
+
val socket = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
|
|
238
|
+
server = socket
|
|
239
|
+
thread(isDaemon = true) {
|
|
240
|
+
while (!socket.isClosed) {
|
|
241
|
+
try {
|
|
242
|
+
socket.accept().use { client ->
|
|
243
|
+
client.getInputStream().read(ByteArray(1024))
|
|
244
|
+
Thread.sleep(1_000)
|
|
245
|
+
client.getOutputStream().apply {
|
|
246
|
+
write("HTTP/1.0 302 Found\r\nLocation: /again.crt\r\n\r\n".toByteArray())
|
|
247
|
+
flush()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch (e: Exception) {
|
|
251
|
+
return@thread
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
val fetcher = CachingCertificateFetcher()
|
|
256
|
+
|
|
257
|
+
val started = System.nanoTime()
|
|
258
|
+
val certs =
|
|
259
|
+
fetcher.fetch(
|
|
260
|
+
"http://127.0.0.1:${socket.localPort}/loop.crt",
|
|
261
|
+
deadlineNanos = System.nanoTime() + 1_500 * 1_000_000L,
|
|
262
|
+
)
|
|
263
|
+
val elapsedMs = (System.nanoTime() - started) / 1_000_000
|
|
264
|
+
|
|
265
|
+
assertTrue(certs.isEmpty())
|
|
266
|
+
assertTrue("fetch ran for $elapsedMs ms across redirect hops", elapsedMs < 4_000)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
@Test(timeout = 30_000)
|
|
270
|
+
fun `a server that accepts and never answers is bounded by the read timeout`() {
|
|
271
|
+
val socket = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
|
|
272
|
+
server = socket
|
|
273
|
+
val accepted = CountDownLatch(1)
|
|
274
|
+
// Accept, then hold the connection open saying nothing at all.
|
|
275
|
+
thread(isDaemon = true) {
|
|
276
|
+
runCatching {
|
|
277
|
+
val client = socket.accept()
|
|
278
|
+
accepted.countDown()
|
|
279
|
+
Thread.sleep(60_000)
|
|
280
|
+
client.close()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
val certs =
|
|
285
|
+
CachingCertificateFetcher(connectTimeoutMs = 2_000, readTimeoutMs = 1_000)
|
|
286
|
+
.fetch("http://127.0.0.1:${socket.localPort}/hangs.crt")
|
|
287
|
+
|
|
288
|
+
assertTrue(accepted.await(10, TimeUnit.SECONDS))
|
|
289
|
+
assertTrue(certs.isEmpty())
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@Test
|
|
293
|
+
fun `a non-http scheme is never fetched`() {
|
|
294
|
+
val handled = AtomicInteger()
|
|
295
|
+
val port = startServer(handled = handled)
|
|
296
|
+
|
|
297
|
+
assertTrue(CachingCertificateFetcher().fetch("ftp://127.0.0.1:$port/issuer.crt").isEmpty())
|
|
298
|
+
assertTrue(CachingCertificateFetcher().fetch("file:///etc/hosts").isEmpty())
|
|
299
|
+
assertEquals(0, handled.get())
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@Test
|
|
303
|
+
fun `a failed fetch is not cached, so it stays retryable`() {
|
|
304
|
+
val handled = AtomicInteger()
|
|
305
|
+
val port = startServer(handled = handled)
|
|
306
|
+
val fetcher = CachingCertificateFetcher()
|
|
307
|
+
val url = "http://127.0.0.1:$port/issuer.crt"
|
|
308
|
+
|
|
309
|
+
fetcher.fetch(url)
|
|
310
|
+
fetcher.fetch(url)
|
|
311
|
+
|
|
312
|
+
assertEquals("both attempts must hit the network", 2, handled.get())
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
@Test
|
|
316
|
+
fun `the cache is bounded`() {
|
|
317
|
+
// Far more distinct URLs than MAX_CACHE_ENTRIES; nothing is cached here (every fetch fails),
|
|
318
|
+
// but the bound is what stops an attacker-chosen URL space from growing without limit.
|
|
319
|
+
assertFalse(CachingCertificateFetcher.MAX_CACHE_ENTRIES <= 0)
|
|
320
|
+
assertTrue(CachingCertificateFetcher.MAX_CACHE_ENTRIES <= 128)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package com.audiobrowser.tls
|
|
2
2
|
|
|
3
|
+
import java.io.ByteArrayInputStream
|
|
4
|
+
import java.io.InputStream
|
|
3
5
|
import org.junit.Assert.assertArrayEquals
|
|
4
6
|
import org.junit.Assert.assertEquals
|
|
5
7
|
import org.junit.Assert.assertNull
|
|
@@ -72,4 +74,85 @@ class CachingCertificateFetcherTest {
|
|
|
72
74
|
CachingCertificateFetcher.parseHttpResponse("garbage without a header break".toByteArray())
|
|
73
75
|
)
|
|
74
76
|
}
|
|
77
|
+
|
|
78
|
+
// -- response cap (the AIA URL, so the host serving it, comes from the presented cert) --
|
|
79
|
+
|
|
80
|
+
// -- URL validation (the AIA URL is attacker-supplied text from the presented cert) --
|
|
81
|
+
|
|
82
|
+
@Test
|
|
83
|
+
fun `accepts the URL form CAs actually serve`() {
|
|
84
|
+
assertTrue(CachingCertificateFetcher.isSafeUrl("http://r13.i.lencr.org/"))
|
|
85
|
+
assertTrue(CachingCertificateFetcher.isSafeUrl("http://ca.example:8080/issuer.crt?v=2"))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test
|
|
89
|
+
fun `rejects a URL carrying CRLF, which would inject request lines`() {
|
|
90
|
+
// java.net.URL keeps the CRLF in `file`, and the cleartext fetch writes `file` straight
|
|
91
|
+
// into "GET $path HTTP/1.0\r\n" on a raw socket.
|
|
92
|
+
assertTrue(!CachingCertificateFetcher.isSafeUrl("http://127.0.0.1:6379/\r\nSET foo bar\r\n"))
|
|
93
|
+
assertTrue(!CachingCertificateFetcher.isSafeUrl("http://ca.example/a\nX-Injected: 1"))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
fun `rejects a URL carrying a space, which would split the request line`() {
|
|
98
|
+
assertTrue(!CachingCertificateFetcher.isSafeUrl("http://ca.example/a b"))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
fun `reads a response that fits within the cap`() {
|
|
103
|
+
val body = ByteArray(64) { it.toByte() }
|
|
104
|
+
|
|
105
|
+
assertArrayEquals(
|
|
106
|
+
body,
|
|
107
|
+
CachingCertificateFetcher.readCapped(ByteArrayInputStream(body), limit = 128),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Test
|
|
112
|
+
fun `returns null for a response that exceeds the cap`() {
|
|
113
|
+
val body = ByteArray(129)
|
|
114
|
+
|
|
115
|
+
assertNull(CachingCertificateFetcher.readCapped(ByteArrayInputStream(body), limit = 128))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Test
|
|
119
|
+
fun `a stream that never ends does not read without bound`() {
|
|
120
|
+
// Endless: read() always fills the buffer and never signals EOF, the shape a slow-drip
|
|
121
|
+
// server takes — the per-read timeout never fires because every read succeeds.
|
|
122
|
+
val endless =
|
|
123
|
+
object : InputStream() {
|
|
124
|
+
override fun read() = 0
|
|
125
|
+
|
|
126
|
+
override fun read(b: ByteArray, off: Int, len: Int): Int = len
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
assertNull(CachingCertificateFetcher.readCapped(endless, limit = 1 shl 16))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@Test
|
|
133
|
+
fun `a slow drip is cut off by the deadline, not left to exhaust the byte cap`() {
|
|
134
|
+
// One byte per read, each one landing just inside the socket's read timeout: the byte cap
|
|
135
|
+
// alone would let this run for a million reads. The clock advances 1s per read, so the
|
|
136
|
+
// 10s budget must end it after ~10 — far short of the 1 MiB cap.
|
|
137
|
+
var reads = 0
|
|
138
|
+
val drip =
|
|
139
|
+
object : InputStream() {
|
|
140
|
+
override fun read() = 0
|
|
141
|
+
|
|
142
|
+
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
|
143
|
+
reads++
|
|
144
|
+
return 1
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
var nanos = 0L
|
|
148
|
+
|
|
149
|
+
val result =
|
|
150
|
+
CachingCertificateFetcher.readCapped(drip, budgetMs = 10_000) {
|
|
151
|
+
nanos += 1_000_000_000
|
|
152
|
+
nanos
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
assertNull(result)
|
|
156
|
+
assertTrue("gave up after $reads reads", reads < 20)
|
|
157
|
+
}
|
|
75
158
|
}
|
|
@@ -64,9 +64,6 @@ final class BrowserManager {
|
|
|
64
64
|
private let router = SimpleRouter()
|
|
65
65
|
private let httpClient = HttpClient()
|
|
66
66
|
|
|
67
|
-
// LRU cache for individual tracks - keyed by both url and src for O(1) lookup
|
|
68
|
-
private let trackCache = LRUCache<String, Track>(maxSize: 3000)
|
|
69
|
-
|
|
70
67
|
// LRU cache for resolved content - keyed by path
|
|
71
68
|
private let contentCache = LRUCache<String, ResolvedTrack>(maxSize: 20)
|
|
72
69
|
|
|
@@ -223,22 +220,6 @@ final class BrowserManager {
|
|
|
223
220
|
return resolvedTrack.copying(children: hydratedChildren)
|
|
224
221
|
}
|
|
225
222
|
|
|
226
|
-
// MARK: - Track Cache
|
|
227
|
-
|
|
228
|
-
/// Cache a track by both url and src for O(1) lookup from either key.
|
|
229
|
-
private func cacheTrack(_ track: Track) {
|
|
230
|
-
if let url = track.url {
|
|
231
|
-
trackCache.set(url, value: track)
|
|
232
|
-
}
|
|
233
|
-
if let src = track.src {
|
|
234
|
-
trackCache.set(src, value: track)
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
private func cacheChildren(_ resolvedTrack: ResolvedTrack) {
|
|
239
|
-
resolvedTrack.children?.forEach { cacheTrack($0) }
|
|
240
|
-
}
|
|
241
|
-
|
|
242
223
|
// MARK: - Navigation
|
|
243
224
|
|
|
244
225
|
/// Main navigation method - updates path and resolves content.
|
|
@@ -276,9 +257,6 @@ final class BrowserManager {
|
|
|
276
257
|
// Cache the resolved content for future navigation
|
|
277
258
|
contentCache.set(normalizedPath, value: resolvedTrack)
|
|
278
259
|
|
|
279
|
-
// Cache children for track lookups
|
|
280
|
-
cacheChildren(resolvedTrack)
|
|
281
|
-
|
|
282
260
|
return hydrateChildren(resolvedTrack)
|
|
283
261
|
}
|
|
284
262
|
|
|
@@ -682,9 +660,6 @@ final class BrowserManager {
|
|
|
682
660
|
lastSearchQuery = query
|
|
683
661
|
lastSearchResults = hydratedResults
|
|
684
662
|
|
|
685
|
-
// Cache individual tracks
|
|
686
|
-
hydratedResults.forEach { cacheTrack($0) }
|
|
687
|
-
|
|
688
663
|
return makeSearchResult(query: query, results: hydratedResults)
|
|
689
664
|
}
|
|
690
665
|
|
|
@@ -770,9 +745,9 @@ final class BrowserManager {
|
|
|
770
745
|
// silently queueing the changed list would resume the wrong station.
|
|
771
746
|
let sectionTracks: [Track]
|
|
772
747
|
switch SectionScope.section(of: children, containing: trackId) {
|
|
773
|
-
case .imageRow(
|
|
748
|
+
case let .imageRow(items):
|
|
774
749
|
sectionTracks = items.map { $0.toTrack() }
|
|
775
|
-
case .run(
|
|
750
|
+
case let .run(tracks):
|
|
776
751
|
sectionTracks = tracks
|
|
777
752
|
case nil:
|
|
778
753
|
return nil
|
|
@@ -20,7 +20,7 @@ enum SearchDrillIn {
|
|
|
20
20
|
/// yields nothing. Returns nil when nothing is playable.
|
|
21
21
|
static func playable<Item: PlayableSearchItem>(
|
|
22
22
|
from children: [Item],
|
|
23
|
-
resolveChildren: sending (String) async throws -> [Item]
|
|
23
|
+
resolveChildren: sending (String) async throws -> [Item],
|
|
24
24
|
) async throws -> [Item]? {
|
|
25
25
|
guard !children.isEmpty else { return nil }
|
|
26
26
|
|
|
@@ -22,8 +22,8 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
|
|
|
22
22
|
private static let logger = Logger(subsystem: "com.audiobrowser", category: "MediaAddHandler")
|
|
23
23
|
|
|
24
24
|
func handle(
|
|
25
|
-
intent: INAddMediaIntent,
|
|
26
|
-
completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void
|
|
25
|
+
intent _: INAddMediaIntent,
|
|
26
|
+
completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void,
|
|
27
27
|
) {
|
|
28
28
|
Self.logger.info("Add media — favorite current track")
|
|
29
29
|
|
|
@@ -57,7 +57,7 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
|
|
|
57
57
|
@objc(resolveMediaItemsForAddMedia:withCompletion:)
|
|
58
58
|
func resolveMediaItems(
|
|
59
59
|
for intent: INAddMediaIntent,
|
|
60
|
-
with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void
|
|
60
|
+
with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void,
|
|
61
61
|
) {
|
|
62
62
|
let name = intent.mediaSearch?.mediaName ?? ""
|
|
63
63
|
let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
|
|
@@ -66,10 +66,10 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
|
|
|
66
66
|
|
|
67
67
|
@objc(resolveMediaDestinationForAddMedia:withCompletion:)
|
|
68
68
|
func resolveMediaDestination(
|
|
69
|
-
for
|
|
70
|
-
with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void
|
|
69
|
+
for _: INAddMediaIntent,
|
|
70
|
+
with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void,
|
|
71
71
|
) {
|
|
72
72
|
// One flat favorites collection — always the user's library, no playlists.
|
|
73
|
-
|
|
73
|
+
completion(.success(with: INMediaDestination.library))
|
|
74
74
|
}
|
|
75
75
|
}
|
|
@@ -21,7 +21,7 @@ class RNABMediaAffinityHandler: NSObject, INUpdateMediaAffinityIntentHandling {
|
|
|
21
21
|
|
|
22
22
|
func handle(
|
|
23
23
|
intent: INUpdateMediaAffinityIntent,
|
|
24
|
-
completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void
|
|
24
|
+
completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void,
|
|
25
25
|
) {
|
|
26
26
|
let affinity = intent.affinityType
|
|
27
27
|
Self.logger.info("Update media affinity — type=\(affinity.rawValue)")
|
|
@@ -60,7 +60,7 @@ class RNABMediaAffinityHandler: NSObject, INUpdateMediaAffinityIntentHandling {
|
|
|
60
60
|
@objc(resolveMediaItemsForUpdateMediaAffinity:withCompletion:)
|
|
61
61
|
func resolveMediaItems(
|
|
62
62
|
for intent: INUpdateMediaAffinityIntent,
|
|
63
|
-
with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void
|
|
63
|
+
with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void,
|
|
64
64
|
) {
|
|
65
65
|
let name = intent.mediaSearch?.mediaName ?? ""
|
|
66
66
|
let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
|
|
@@ -21,7 +21,7 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
|
|
|
21
21
|
mediaTypeMode: Self.mediaTypeMode(s?.mediaType ?? .unknown),
|
|
22
22
|
reference: Self.reference(s?.reference ?? .unknown),
|
|
23
23
|
hasMediaType: (s?.mediaType ?? .unknown) != .unknown,
|
|
24
|
-
appName: Self.hostAppName()
|
|
24
|
+
appName: Self.hostAppName(),
|
|
25
25
|
)
|
|
26
26
|
Self.logger.info("Play media intent — query=\(criteria.query) matchesApp=\(criteria.matchesAppName) resume=\(criteria.isResume)")
|
|
27
27
|
|
|
@@ -37,18 +37,18 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
|
|
|
37
37
|
/// `album` are filters, not verticals — they yield no mode.
|
|
38
38
|
private static func mediaTypeMode(_ type: INMediaItemType) -> String? {
|
|
39
39
|
switch type {
|
|
40
|
-
case .station, .radioStation, .algorithmicRadioStation, .musicStation:
|
|
41
|
-
case .podcastShow, .podcastEpisode, .podcastPlaylist, .podcastStation:
|
|
42
|
-
case .audioBook:
|
|
43
|
-
case .news:
|
|
44
|
-
case .music:
|
|
45
|
-
case .song:
|
|
46
|
-
case .playlist:
|
|
47
|
-
case .musicVideo:
|
|
48
|
-
case .movie:
|
|
49
|
-
case .tvShow:
|
|
50
|
-
case .tvShowEpisode:
|
|
51
|
-
default:
|
|
40
|
+
case .station, .radioStation, .algorithmicRadioStation, .musicStation: "station"
|
|
41
|
+
case .podcastShow, .podcastEpisode, .podcastPlaylist, .podcastStation: "podcast"
|
|
42
|
+
case .audioBook: "audiobook"
|
|
43
|
+
case .news: "news"
|
|
44
|
+
case .music: "music"
|
|
45
|
+
case .song: "song"
|
|
46
|
+
case .playlist: "playlist"
|
|
47
|
+
case .musicVideo: "music-video"
|
|
48
|
+
case .movie: "movie"
|
|
49
|
+
case .tvShow: "tv-show"
|
|
50
|
+
case .tvShowEpisode: "tv-show-episode"
|
|
51
|
+
default: nil // album/artist/genre/unknown
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -56,9 +56,9 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
|
|
|
56
56
|
/// routes to native resume; `.my` to the consumer; everything else is unknown.
|
|
57
57
|
private static func reference(_ ref: INMediaReference) -> MediaIntentCriteria.Reference {
|
|
58
58
|
switch ref {
|
|
59
|
-
case .currentlyPlaying:
|
|
60
|
-
case .my:
|
|
61
|
-
default:
|
|
59
|
+
case .currentlyPlaying: .currentlyPlaying
|
|
60
|
+
case .my: .my
|
|
61
|
+
default: .unknown
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|