diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies deleted file mode 100644 index c3cd397..0000000 --- a/.flutter-plugins-dependencies +++ /dev/null @@ -1 +0,0 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"/Users/admin/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"android":[{"name":"jni","path":"/Users/admin/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/Users/admin/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/Users/admin/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false}],"macos":[{"name":"path_provider_foundation","path":"/Users/admin/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"jni","path":"/Users/admin/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/Users/admin/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"jni","path":"/Users/admin/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/Users/admin/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]}],"date_created":"2026-04-15 16:33:53.616771","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d2cf31e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Print versions + run: flutter --version + + - name: Install dependencies + run: flutter pub get + + - name: Run tests + run: flutter test + + # Static analysis is informative but kept non-blocking so style lints in + # tests don't mask the test result. + - name: Analyze + if: always() + continue-on-error: true + run: flutter analyze diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa89a3..b029465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## 0.1.0 + +This release significantly simplifies cache management. A new `getCacheUrl` API automates the full lifecycle of cache streams, eliminating the need to create or manage `HttpCacheStream` instances for most integrations. + +### Breaking Changes + +- **`HttpCacheServer` has been removed.** Use `HttpCacheManager.getCacheUrl` instead. It covers all previous use cases, including HLS/DASH and other dynamic URL patterns, with full lifecycle automation. +- **`InvalidCacheLengthException` has been renamed to `InvalidCacheSizeException`**, which now exposes the expected and actual cache size. + +### New Features + +**`HttpCacheManager`** + +- Added `getCacheUrl(Uri sourceUrl)`. Returns a local cache URL that can be passed directly to any media player. Cache streams are created lazily on first request and their lifecycle is managed automatically by the cache manager. +- Added an optional `port` parameter to `HttpCacheManager.init`. Specifying a custom port allows cache urls to work across app restarts. + +**`HttpCacheStream`** + +- Added `release()`. Hands the stream back to the cache manager for lifecycle-managed teardown, governed by `StreamLifecycleConfig`. Use `release()` in place of `dispose()` when the stream may be reused. `dispose()` continues to work as before and bypasses the lifecycle configuration. +- Added `CacheState`, a richer alternative to the plain progress value. It tracks both the current cache position (bytes on disk) and the total source length. Access the latest snapshot via `cacheStream.cacheState`, or subscribe to changes via `cacheStream.cacheStateStream`. +- Added `StreamLifecycleConfig` to `CacheConfiguration`. Controls the inactive-stream behavior applied after `release()` is called. By default: downloads are paused after 10 seconds of inactivity, cancelled after the configured `readTimeout`, and the stream is fully disposed after 5 minutes. + +### Improvements + +- Cache headers are now read using async I/O. + ## 0.0.6 * Add `onStreamCreated` callback to `HttpCacheManager`. diff --git a/README.md b/README.md index 6311477..09bc55a 100644 --- a/README.md +++ b/README.md @@ -21,23 +21,22 @@ Using http_cache_stream to simultaneously cache and stream a video file: ```dart import 'package:http_cache_stream/http_cache_stream.dart'; -// Initialize the cache manager +// Initialize the cache manager once, at app startup await HttpCacheManager.init(); -// Create a stream for a specific URL +// Get a local cache URL for any remote resource final sourceUrl = Uri.parse('https://example.com/video.mp4'); -final cacheStream = HttpCacheManager.instance.createStream(sourceUrl); - -// Get the local cache URL to pass to your media player -final cacheUrl = cacheStream.cacheUrl; +final cacheUrl = HttpCacheManager.instance.getCacheUrl(sourceUrl); // Example output: http://127.0.0.1:4612/example.com/video.mp4 -// Use with any player -final videoPlayerController = VideoPlayerController.network(cacheUrl); +// Pass it directly to your media player +final videoPlayerController = VideoPlayerController.networkUrl(cacheUrl); await videoPlayerController.initialize(); videoPlayerController.play(); ``` +The cache stream is created and managed automatically — no lifecycle management required. For direct access to the `HttpCacheStream` instance (e.g., to monitor download progress or configure per-stream settings), use `createStream` instead. See [HttpCacheStream](#httpcachestream) below. + See the example project to learn how to use http_cache_stream with [video_player](https://pub.dev/packages/video_player), [audioplayers](https://pub.dev/packages/audioplayers), and [just_audio](https://pub.dev/packages/just_audio) ## Platform configuration @@ -85,54 +84,42 @@ See the example project for a full example. The central manager for all cache streams: ```dart -// Initialize once +// Initialize once, at app startup await HttpCacheManager.init(); final cacheManager = HttpCacheManager.instance; +// Get a cache URL — the recommended way to integrate with media players +final cacheUrl = cacheManager.getCacheUrl(sourceUrl); + +// Pre-cache files: +await cacheManager.preCacheUrl(Uri.parse('https://example.com/file.mp3')); + // Configure global settings cacheManager.config.requestHeaders[HttpHeaders.userAgentHeader] = 'MyApp/1.0'; -// Create streams -final cacheStream = cacheManager.createStream(url); - -// Manage cache -await cacheManager.deleteCache(); // Clear inactive cache +// Delete inactive cache files +await cacheManager.deleteCache(); ``` ## HttpCacheStream -Manages downloading, caching, and streaming for a specific URL: +Provides direct access to the download, cache, and streaming state for a specific URL. Use `createStream` when you need to monitor progress or configure per-stream settings. ```dart final cacheStream = cacheManager.createStream(Uri.parse('https://example.com/file.mp3')); -// Start download explicitly (optional) +// Start download explicitly (optional — begins automatically on first request) cacheStream.download(); -// Get download progress -cacheStream.progressStream.listen((progress) { - print('Download progress: ${(progress * 100).toStringAsFixed(1)}%'); +// Monitor CacheState for position and source length +cacheStream.cacheStateStream.listen((state) { + print('${state.position} / ${state.sourceLength} bytes'); }); -cacheStream.dispose(); // Release when done -``` - -## HttpCacheServer - -Creates a local proxy server that dynamically handles URLs from the same source. Ideal for HLS/DASH streaming where a master playlist references multiple segment files. Cache streams are automatically created and disposed. - -```dart -// Create a server for a base URL -final sourceUri = Uri.parse('https://example.com/'); -final cacheServer = await cacheManager.createServer(sourceUri); - -// The URI of the cache server. Requests to this URI will be fulfilled from the source URI. -final serverUri = cacheServer.uri; - -// Manually obtain a cache url -final hlsCacheUrl = cacheServer.getCacheUrl(Uri.parse('https://example.com/video.m3u8')); - -cacheServer.dispose(); // Release when done +// Release when done — the stream will be paused and eventually disposed +// automatically according to StreamLifecycleConfig. +// Call dispose() instead to tear down immediately. +cacheStream.release(); ``` @@ -176,6 +163,21 @@ cacheStream.config.copyCachedResponseHeaders = true; ``` +### Stream Lifecycle + +When `createStream` is used, call `release()` instead of `dispose()` to hand the stream back to the cache manager. The behavior after release is controlled by `StreamLifecycleConfig`: + +```dart +// Configure globally (applies to all streams) +HttpCacheManager.instance.config.lifecycleConfig = StreamLifecycleConfig( + pauseAfter: Duration(seconds: 10), // Pause the download after 10s of inactivity + disposeAfter: Duration(minutes: 5), // Fully dispose the stream after 5 minutes +); +``` + +Call `dispose()` to tear down a stream immediately, bypassing the lifecycle configuration. + + ### Range Request Controls When a client requests a byte range that's significantly ahead of what's currently cached, the library can initiate a separate direct connection to the source, rather than waiting for the sequential cache download to reach that position. The `rangeRequestSplitThreshold` setting controls this behavior by defining how many bytes ahead a request must be to trigger a separate connection. diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index e549ee2..620e46e 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 563de25..64cf43c 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,49 +1,16 @@ PODS: - - audio_session (0.0.1): - - Flutter - - audioplayers_darwin (0.0.1): - - Flutter - Flutter (1.0.0) - - just_audio (0.0.1): - - Flutter - - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - video_player_avfoundation (0.0.1): - - Flutter - - FlutterMacOS DEPENDENCIES: - - audio_session (from `.symlinks/plugins/audio_session/ios`) - - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`) - Flutter (from `Flutter`) - - just_audio (from `.symlinks/plugins/just_audio/darwin`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) EXTERNAL SOURCES: - audio_session: - :path: ".symlinks/plugins/audio_session/ios" - audioplayers_darwin: - :path: ".symlinks/plugins/audioplayers_darwin/ios" Flutter: :path: Flutter - just_audio: - :path: ".symlinks/plugins/just_audio/darwin" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - video_player_avfoundation: - :path: ".symlinks/plugins/video_player_avfoundation/darwin" SPEC CHECKSUMS: - audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe - audioplayers_darwin: 877d9a4d06331c5c374595e46e16453ac7eafa40 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 -PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index f186b69..c3e24e2 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -52,6 +53,7 @@ 511BAEB2CDB143F2F0CA690D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 921BCB6ED5B245FDA091615F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -80,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 10FECA1D154572E08E5E73F9 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -112,6 +115,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -198,13 +202,15 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - D5F582AABA8F541DF0C0D951 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -238,6 +244,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -323,23 +332,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - D5F582AABA8F541DF0C0D951 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; E526F1AAFB0242452D860C25 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -455,7 +447,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -585,7 +577,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -636,7 +628,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -726,6 +718,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15cada4..c3fedb2 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 1d95d12..1c38c50 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,11 +2,8 @@ - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -29,6 +26,34 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -46,9 +71,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/lib/examples/audio_players.dart b/example/lib/examples/audio_players.dart index c28c2cd..b264820 100644 --- a/example/lib/examples/audio_players.dart +++ b/example/lib/examples/audio_players.dart @@ -1,9 +1,8 @@ -//Example of how to use the audioplayers package (https://pub.dev/packages/audioplayers) to play audio files. +//Example of how to use the audioplayers package (https://pub.dev/packages/audioplayers) to play audio files import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:http_cache_stream/http_cache_stream.dart'; -import 'package:http_cache_stream_example/widgets/cache_progress_bar.dart'; import 'package:http_cache_stream_example/widgets/seek_bar.dart'; import 'package:rxdart/rxdart.dart'; @@ -17,9 +16,6 @@ class AudioPlayersExample extends StatefulWidget { class _AudioPlayersExampleState extends State { final _player = AudioPlayer(); - late final httpCacheStream = HttpCacheManager.instance.createStream( - widget.sourceUrl, - ); @override void initState() { @@ -28,18 +24,15 @@ class _AudioPlayersExampleState extends State { } void _init() async { - final cachedUrl = httpCacheStream.cacheUrl.toString(); - _player.play(UrlSource(cachedUrl)); - print('Playing from: $cachedUrl | $httpCacheStream'); + final cachedUrl = HttpCacheManager.instance.getCacheUrl(widget.sourceUrl); + print('Playing from: $cachedUrl'); + _player.play(UrlSource(cachedUrl.toString())); } @override void dispose() { super.dispose(); - _player.dispose().whenComplete(() { - return httpCacheStream - .dispose(); //Dispose the cache stream after the player is disposed - }); + _player.dispose(); } Stream get _positionDataStream => @@ -71,7 +64,6 @@ class _AudioPlayersExampleState extends State { ); }, ), - CacheProgressBar(httpCacheStream), ], ); } diff --git a/example/lib/examples/hls_video.dart b/example/lib/examples/hls_video.dart index 09b7136..1322be6 100644 --- a/example/lib/examples/hls_video.dart +++ b/example/lib/examples/hls_video.dart @@ -14,7 +14,6 @@ class HLSVideoExample extends StatefulWidget { class _VideoPlayerExampleState extends State { VideoPlayerController? _controller; - HttpCacheServer? _cacheServer; @override void initState() { @@ -24,18 +23,7 @@ class _VideoPlayerExampleState extends State { void _init() async { final sourceUrl = widget.sourceUrl; - final source = Uri( - host: sourceUrl.host, - port: sourceUrl.port, - scheme: sourceUrl.scheme, - ); - final cacheServer = - _cacheServer = await HttpCacheManager.instance.createServer(source); - if (!mounted) { - cacheServer.dispose(); - return; - } - final cacheUrl = cacheServer.getCacheUrl(sourceUrl); + final cacheUrl = HttpCacheManager.instance.getCacheUrl(sourceUrl); print('Playing from: $cacheUrl'); final controller = _controller = VideoPlayerController.networkUrl(cacheUrl); await controller.initialize(); @@ -47,7 +35,6 @@ class _VideoPlayerExampleState extends State { @override void dispose() { - _cacheServer?.dispose(); _controller?.dispose(); super.dispose(); } diff --git a/example/lib/examples/just_audio.dart b/example/lib/examples/just_audio.dart index 5ae9b24..558244a 100644 --- a/example/lib/examples/just_audio.dart +++ b/example/lib/examples/just_audio.dart @@ -1,4 +1,4 @@ -//Example of how to use the just_audio package (https://pub.dev/packages/just_audio) to play audio files. +//Example of how to use the just_audio package (https://pub.dev/packages/just_audio) to play audio files by manually creating a cache stream. import 'package:flutter/material.dart'; import 'package:http_cache_stream/http_cache_stream.dart'; @@ -43,10 +43,9 @@ class _JustAudioExampleState extends State { @override void dispose() { super.dispose(); - _player.dispose().whenComplete(() { - return httpCacheStream - .dispose(); //Dispose the cache stream after the player is disposed - }); + httpCacheStream + .release(); //Release the cache stream when the widget is disposed. + _player.dispose(); } Stream get _positionDataStream => diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake index e9abb91..f4bf1dc 100644 --- a/example/linux/flutter/generated_plugins.cmake +++ b/example/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 2ed0df5..86fd6e3 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,13 +8,11 @@ import Foundation import audio_session import audioplayers_darwin import just_audio -import path_provider_foundation import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) } diff --git a/example/pubspec.lock b/example/pubspec.lock index 2917d58..2b8b58b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,14 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.1" audio_session: dependency: transitive description: @@ -21,58 +29,58 @@ packages: dependency: "direct main" description: name: audioplayers - sha256: a5341380a4f1d3a10a4edde5bb75de5127fe31e0faa8c4d860e64d2f91ad84c7 + sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.6.0" audioplayers_android: dependency: transitive description: name: audioplayers_android - sha256: f8c90823a45b475d2c129f85bbda9c029c8d4450b172f62e066564c6e170f69a + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.2.1" audioplayers_darwin: dependency: transitive description: name: audioplayers_darwin - sha256: "405cdbd53ebdb4623f1c5af69f275dad4f930ce895512d5261c07cd95d23e778" + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "6.4.0" audioplayers_linux: dependency: transitive description: name: audioplayers_linux - sha256: "7e0d081a6a527c53aef9539691258a08ff69a7dc15ef6335fbea1b4b03ebbef0" + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.2.1" audioplayers_platform_interface: dependency: transitive description: name: audioplayers_platform_interface - sha256: "77e5fa20fb4a64709158391c75c1cca69a481d35dc879b519e350a05ff520373" + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" url: "https://pub.dev" source: hosted - version: "7.1.0" + version: "7.1.1" audioplayers_web: dependency: transitive description: name: audioplayers_web - sha256: bd99d8821114747682a2be0adcdb70233d4697af989b549d3a20a0f49f6c9b13 + sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "5.2.0" audioplayers_windows: dependency: transitive description: name: audioplayers_windows - sha256: "871d3831c25cd2408ddc552600fd4b32fba675943e319a41284704ee038ad563" + sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.3.0" boolean_selector: dependency: transitive description: @@ -97,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f + url: "https://pub.dev" + source: hosted + version: "1.1.0" collection: dependency: transitive description: @@ -109,10 +125,10 @@ packages: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -133,10 +149,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -176,22 +192,30 @@ packages: description: flutter source: sdk version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + url: "https://pub.dev" + source: hosted + version: "2.0.0" html: dependency: transitive description: name: html - sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.5" + version: "0.15.6" http: dependency: transitive description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.6.0" http_cache_stream: dependency: "direct main" description: @@ -207,6 +231,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" just_audio: dependency: "direct main" description: @@ -219,18 +259,18 @@ packages: dependency: transitive description: name: just_audio_platform_interface - sha256: "271b93b484c6f494ecd72a107fffbdb26b425f170c665b9777a0a24a726f2f24" + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.6.0" just_audio_web: dependency: transitive description: name: just_audio_web - sha256: "58915be64509a7683c44bf11cd1a23c15a48de104927bee116e3c63c8eeea0d4" + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" url: "https://pub.dev" source: hosted - version: "0.4.14" + version: "0.4.16" leak_tracker: dependency: transitive description: @@ -263,6 +303,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -283,10 +331,26 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -307,18 +371,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.16" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -359,6 +423,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" rxdart: dependency: "direct main" description: @@ -376,18 +456,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -416,10 +488,10 @@ packages: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -432,10 +504,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: @@ -448,10 +520,10 @@ packages: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.3" vector_math: dependency: transitive description: @@ -464,50 +536,50 @@ packages: dependency: "direct main" description: name: video_player - sha256: "48941c8b05732f9582116b1c01850b74dbee1d8520cd7e34ad4609d6df666845" + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" url: "https://pub.dev" source: hosted - version: "2.9.3" + version: "2.11.1" video_player_android: dependency: transitive description: name: video_player_android - sha256: ae7d4f1b41e3ac6d24dd9b9d5d6831b52d74a61bdd90a7a6262a33d8bb97c29a + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.9.5" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "84b4752745eeccb6e75865c9aab39b3d28eb27ba5726d352d45db8297fbd75bc" + sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.9.7" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - sha256: df534476c341ab2c6a835078066fc681b8265048addd853a1e3c78740316a844 + sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.7.0" video_player_web: dependency: transitive description: name: video_player_web - sha256: "3ef40ea6d72434edbfdba4624b90fd3a80a0740d260667d91e7ecd2d79e13476" + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.4.0" vm_service: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.2.0" web: dependency: transitive description: @@ -524,6 +596,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 375535c..53f4ef8 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/lib/http_cache_stream.dart b/lib/http_cache_stream.dart index 0eeac64..dc907d9 100644 --- a/lib/http_cache_stream.dart +++ b/lib/http_cache_stream.dart @@ -14,21 +14,23 @@ library; export 'src/cache_manager/http_cache_manager.dart'; -export 'src/cache_server/http_cache_server.dart'; export 'src/cache_stream/http_cache_stream.dart'; +export 'src/models/cache_config/cache_config.dart'; +export 'src/models/cache_config/global_cache_config.dart'; +export 'src/models/cache_config/stream_cache_config.dart'; +export 'src/models/cache_config/stream_lifecycle_config.dart'; +export 'src/models/cache_files/cache_file_resolver.dart'; export 'src/models/cache_files/cache_file_type.dart'; export 'src/models/cache_files/cache_files.dart'; -export 'src/models/config/cache_config.dart'; -export 'src/models/config/cache_file_resolver.dart'; -export 'src/models/config/global_cache_config.dart'; -export 'src/models/config/stream_cache_config.dart'; +export 'src/models/cache_state/cache_state.dart'; export 'src/models/exceptions/http_exceptions.dart'; export 'src/models/exceptions/invalid_cache_exceptions.dart'; +export 'src/models/exceptions/state_errors.dart'; export 'src/models/exceptions/stream_response_exceptions.dart'; +export 'src/models/http_range/http_range.dart'; +export 'src/models/http_range/http_range_request.dart'; +export 'src/models/http_range/http_range_response.dart'; export 'src/models/metadata/cache_metadata.dart'; export 'src/models/metadata/cached_response_headers.dart'; export 'src/models/stream_requests/int_range.dart'; export 'src/models/stream_response/stream_response.dart'; -export 'src/models/http_range/http_range.dart'; -export 'src/models/http_range/http_range_request.dart'; -export 'src/models/http_range/http_range_response.dart'; diff --git a/lib/src/cache_manager/http_cache_manager.dart b/lib/src/cache_manager/http_cache_manager.dart index acdee11..1384cbf 100644 --- a/lib/src/cache_manager/http_cache_manager.dart +++ b/lib/src/cache_manager/http_cache_manager.dart @@ -6,8 +6,8 @@ import 'package:http_cache_stream/src/cache_server/local_cache_server.dart'; import 'package:http_cache_stream/src/etc/extensions/uri_extensions.dart'; import '../../http_cache_stream.dart'; -import '../etc/callback_helpers.dart'; import '../etc/extensions/future_extensions.dart'; +import '../etc/helpers.dart'; /// Manages the local HTTP server and `HttpCacheStream` instances. /// @@ -18,90 +18,72 @@ class HttpCacheManager { /// The global configuration used for all streams managed by this manager. final GlobalCacheConfig config; - final Map _streams = {}; - final List _cacheServers = []; + final Map _streams = {}; + final Map _customCacheFiles = {}; HttpCacheManager._(this._server, this.config) { - _server.start((request) { - final cacheStream = getExistingStream(request.uri); - if (cacheStream != null) { - return request.stream(cacheStream); - } else { - request.close(HttpStatus.serviceUnavailable); - return Future.value(); - } - }); + _server.start(createStream); + } + + /// Gets the cache URL for the given source URL. + /// If a custom cache file is provided, it will be saved and used for the cache stream. + Uri getCacheUrl(final Uri sourceUrl, {final File? file}) { + _checkDisposed(); + if (file != null) { + _customCacheFiles[sourceUrl.requestKey] = file; + } + return _server.encodeSourceUrl(sourceUrl); } /// Create a [HttpCacheStream] instance for the given URL. If an instance already exists, the existing instance will be returned. - /// Use [file] to specify the output file to save the downloaded content to. If not provided, a file will be created in the cache directory (recommended). + /// Use [file] to specify the output file to save the downloaded content to. If not provided, a file will be created in the cache directory. + /// Prefer [getCacheUrl] unless if you need access to the `HttpCacheStream` instance. HttpCacheStream createStream( final Uri sourceUrl, { final File? file, final StreamCacheConfig? config, }) { - assert(!isDisposed, - 'HttpCacheManager is disposed. Cannot create new streams.'); - final existingStream = getExistingStream(sourceUrl); + _checkDisposed(); + final requestKey = sourceUrl.requestKey; + + final existingStream = _streams[requestKey]; if (existingStream != null && !existingStream.isDisposed) { existingStream - .retain(); //Retain the stream to prevent it from being disposed + .retain(); //Retain the stream to prevent it from being disposed while in use return existingStream; } + + CacheFiles cacheFiles; + if (file != null) { + _customCacheFiles[requestKey] = file; + cacheFiles = CacheFiles.fromFile(file); + } else { + cacheFiles = _resolveCacheFiles(sourceUrl); + } + final cacheStream = HttpCacheStream( sourceUrl: sourceUrl, - cacheUrl: _server.getCacheUrl(sourceUrl), - files: _resolveCacheFiles(sourceUrl, file), + cacheUrl: _server.encodeSourceUrl(sourceUrl), + files: cacheFiles, config: config ?? createStreamConfig(), ); - final key = sourceUrl.requestKey; - - ///Remove when stream is disposed - cacheStream.future.onComplete(() => _streams.remove(key)); ///Add to the stream map - _streams[key] = cacheStream; + _streams[requestKey] = cacheStream; + + ///Remove when stream is disposed + cacheStream.future.onComplete(() { + _streams.remove(requestKey); + }); if (_onStreamCreated case final streamCreatedCallback?) { fireUserCallback(() => streamCreatedCallback(cacheStream)); } - return cacheStream; - } - /// Creates a [HttpCacheServer] instance for a source Uri. This server will redirect requests to the given source and create [HttpCacheStream] instances for each request. - /// - /// [autoDisposeDelay] is the delay before a stream is disposed after all requests are done. - /// Optionally, you can provide a [StreamCacheConfig] to be used for the streams created by this server. - /// This feature is experimental. - Future createServer( - final Uri source, { - final Duration autoDisposeDelay = const Duration(seconds: 15), - final StreamCacheConfig? config, - }) async { - final cacheServer = HttpCacheServer( - Uri( - scheme: source.scheme, - host: source.host, - port: source.port, - ), - await LocalCacheServer.init(), - autoDisposeDelay, - config ?? createStreamConfig(), - createStream, - ); - _cacheServers.add(cacheServer); - cacheServer.future.onComplete(() => _cacheServers.remove(cacheServer)); - return cacheServer; + return cacheStream; } - /// Downloads URL to file without creating a stream. - /// - /// Useful for pre-caching content. + /// Downloads the content of the given URL and saves it to a cache file. Returns the downloaded file. Future preCacheUrl(final Uri sourceUrl, {final File? cacheFile}) async { - final completeCacheFile = getCacheFiles(sourceUrl, cacheFile).complete; - if (completeCacheFile.existsSync()) { - return completeCacheFile; - } - final cacheStream = createStream(sourceUrl, file: cacheFile); try { return await cacheStream.download(); @@ -170,39 +152,31 @@ class HttpCacheManager { } ///Get the [CacheMetadata] for the given URL or input [cacheFile]. Returns null if the metadata does not exist. - CacheMetadata? getCacheMetadata(final Uri sourceUrl, [File? cacheFile]) { - return getExistingStream(sourceUrl)?.metadata ?? - CacheMetadata.fromCacheFiles(_resolveCacheFiles(sourceUrl, cacheFile)); + CacheMetadata? getCacheMetadata(Uri url, [File? cacheFile]) { + return getExistingStream(url)?.metadata ?? + CacheMetadata.fromCacheFiles(_resolveCacheFiles(url, cacheFile)); } ///Gets [CacheFiles] for the given URL or input [cacheFile]. Does not check if any cache files exists. - CacheFiles getCacheFiles(final Uri sourceUrl, [File? cacheFile]) { - return getExistingStream(sourceUrl)?.files ?? - _resolveCacheFiles(sourceUrl, cacheFile); + CacheFiles getCacheFiles(Uri url, [File? cacheFile]) { + return getExistingStream(url)?.files ?? _resolveCacheFiles(url, cacheFile); } /// Returns the existing [HttpCacheStream] for the given URL, or null if it doesn't exist. /// The input [url] can either be [sourceUrl] or [cacheUrl]. - HttpCacheStream? getExistingStream(final Uri url) { + HttpCacheStream? getExistingStream(Uri url) { + _checkDisposed(); + url = _server.decodeSourceUrl(url) ?? url; return _streams[url.requestKey]; } - ///Returns the existing [HttpCacheServer] for the given source URL, or null if it doesn't exist. - HttpCacheServer? getExistingServer(final Uri source) { - for (final cacheServer in _cacheServers) { - final serverSource = cacheServer.source; - if (serverSource.host == source.host && - serverSource.port == source.port && - serverSource.scheme == source.scheme) { - return cacheServer; - } + CacheFiles _resolveCacheFiles(Uri sourceUrl, [File? cacheFile]) { + if (cacheFile == null) { + sourceUrl = _server.decodeSourceUrl(sourceUrl) ?? sourceUrl; + cacheFile = _customCacheFiles[sourceUrl.requestKey] ?? + config.cacheFileResolver(config.cacheDirectory, sourceUrl); } - return null; - } - - CacheFiles _resolveCacheFiles(Uri sourceUrl, [File? file]) { - file ??= config.cacheFileResolver(config.cacheDirectory, sourceUrl); - return CacheFiles.fromFile(file); + return CacheFiles.fromFile(cacheFile); } ///Create a [StreamCacheConfig] that inherits the current [GlobalCacheConfig]. This config is used to create [HttpCacheStream] instances. @@ -213,20 +187,16 @@ class HttpCacheManager { if (_disposed) return; _disposed = true; HttpCacheManager._instance = null; + _onStreamCreated = null; try { - await _server.close(); + await _server.close(force: true); } finally { + _customCacheFiles.clear(); for (final stream in _streams.values.toList()) { - stream.dispose(force: true).ignore(); + stream.dispose().ignore(); } _streams.clear(); - - for (final httpCacheServer in _cacheServers.toList()) { - httpCacheServer.dispose().ignore(); - } - _cacheServers.clear(); - if (config.customHttpClient == null) { config.httpClient.close(); // Close the default http client only } @@ -235,9 +205,16 @@ class HttpCacheManager { /// Set a callback to be fired when a new [HttpCacheStream] is created. set onStreamCreated(HttpCacheStreamCreatedCallback? callback) { + _checkDisposed(); _onStreamCreated = callback; } + void _checkDisposed() { + if (isDisposed) { + throw CacheManagerDisposedException(); + } + } + HttpCacheStreamCreatedCallback? _onStreamCreated; Directory get cacheDir => config.cacheDirectory; Iterable get allStreams => _streams.values; @@ -250,10 +227,12 @@ class HttpCacheManager { /// the default cache directory will be used (see [GlobalCacheConfig.defaultCacheDirectory]). /// [customHttpClient] is the custom http client to use. If null, a default http client will be used. /// You can also provide [GlobalCacheConfig] for the initial configuration. + /// Use `port` to specify the port for the local server. If not provided, a random available port will be used. static Future init({ final Directory? cacheDir, final http.Client? customHttpClient, final GlobalCacheConfig? config, + final int? port, }) { assert(config == null || (cacheDir == null && customHttpClient == null), 'Cannot set cacheDir or httpClient when config is provided. Set them in the config instead.'); @@ -268,7 +247,7 @@ class HttpCacheManager { cacheDir ?? await GlobalCacheConfig.defaultCacheDirectory(), customHttpClient: customHttpClient, ); - final httpCacheServer = await LocalCacheServer.init(); + final httpCacheServer = await LocalCacheServer.init(port: port); return _instance = HttpCacheManager._(httpCacheServer, cacheConfig); } finally { _initFuture = null; diff --git a/lib/src/cache_server/http_cache_server.dart b/lib/src/cache_server/http_cache_server.dart deleted file mode 100644 index c8c5355..0000000 --- a/lib/src/cache_server/http_cache_server.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'dart:async'; - -import 'package:http_cache_stream/src/cache_server/local_cache_server.dart'; - -import '../../http_cache_stream.dart'; - -/// A server that redirects requests to a source and automatically creates -/// [HttpCacheStream] instances. -class HttpCacheServer { - /// The base source URI for this server. - final Uri source; - - final LocalCacheServer _localCacheServer; - - /// The delay before a stream is disposed after all requests are completed. - final Duration autoDisposeDelay; - - /// The configuration for each generated stream. - final StreamCacheConfig config; - final HttpCacheStream Function(Uri sourceUrl, {StreamCacheConfig config}) - _createCacheStream; - HttpCacheServer(this.source, this._localCacheServer, this.autoDisposeDelay, - this.config, this._createCacheStream) { - _localCacheServer.start((request) { - final sourceUrl = getSourceUrl(request.uri); - final cacheStream = _createCacheStream(sourceUrl, config: config); - - return request.stream(cacheStream).whenComplete(() { - if (isDisposed) { - cacheStream - .dispose() - .ignore(); // Decrease retainCount immediately if the server is disposed - } else { - Timer( - autoDisposeDelay, - () => cacheStream - .dispose() - .ignore()); // Decrease the stream's retainCount for autoDispose - } - }); - }); - } - - /// Returns the cache URL for a given source URL. - Uri getCacheUrl(Uri sourceUrl) { - if (sourceUrl.scheme != source.scheme || - sourceUrl.host != source.host || - sourceUrl.port != source.port) { - throw ArgumentError('Invalid source URL: $sourceUrl'); - } - return _localCacheServer.getCacheUrl(sourceUrl); - } - - /// Returns the source URL for a given cache URL. - Uri getSourceUrl(Uri cacheUrl) { - return cacheUrl.replace( - scheme: source.scheme, - host: source.host, - port: source.port, - ); - } - - /// The URI of the local cache server. - /// - /// Requests to this URI will be redirected to the source URL. - Uri get uri => _localCacheServer.serverUri; - - /// Disposes this [HttpCacheServer] and closes the local server. - Future dispose() { - if (_completer.isCompleted) { - return _completer.future; - } else { - _completer.complete(); - return _localCacheServer.close(); - } - } - - final _completer = Completer(); - - /// Whether the server has been disposed. - bool get isDisposed => _completer.isCompleted; - - /// A future that completes when the server is disposed. - Future get future => _completer.future; -} diff --git a/lib/src/etc/keep_alive_server.dart b/lib/src/cache_server/keep_alive_server.dart similarity index 100% rename from lib/src/etc/keep_alive_server.dart rename to lib/src/cache_server/keep_alive_server.dart diff --git a/lib/src/cache_server/local_cache_server.dart b/lib/src/cache_server/local_cache_server.dart index 89520cd..6396f24 100644 --- a/lib/src/cache_server/local_cache_server.dart +++ b/lib/src/cache_server/local_cache_server.dart @@ -1,7 +1,9 @@ import 'dart:io'; -import '../etc/keep_alive_server.dart'; +import '../../http_cache_stream.dart'; +import '../etc/extensions/uri_extensions.dart'; import '../request_handler/request_handler.dart'; +import 'keep_alive_server.dart'; class LocalCacheServer { final KeepAliveServer _httpServer; @@ -13,24 +15,33 @@ class LocalCacheServer { port: _httpServer.port, ); - static Future init() async { + static Future init({int? port}) async { final httpServer = - await KeepAliveServer.bind(InternetAddress.loopbackIPv4, 0); + await KeepAliveServer.bind(InternetAddress.loopbackIPv4, port ?? 0); return LocalCacheServer._(httpServer); } - void start( - final Future Function(RequestHandler handler) processRequest) { + void start(final HttpCacheStream Function(Uri sourceUrl) getCacheStream) { _httpServer.listen( (request) async { + HttpCacheStream? cacheStream; final requestHandler = RequestHandler(request); + try { - await processRequest(requestHandler); + final sourceUrl = decodeSourceUrl(request.uri); + if (sourceUrl == null) { + requestHandler.close(HttpStatus.badRequest); + return; + } + cacheStream = getCacheStream(sourceUrl); + await requestHandler.stream(cacheStream); } catch (e) { requestHandler.closeWithError(e); } finally { assert(requestHandler.isClosed, 'RequestHandler should be closed after processing the request'); + cacheStream + ?.release(); //Release the stream after handling the request } }, onError: (_) {}, @@ -38,13 +49,75 @@ class LocalCacheServer { ); } - Future ensureActive() => _httpServer.ensureActive(); + Uri? decodeSourceUrl(Uri requestUri) { + final segments = requestUri.pathSegments; + if (segments.length < 2) return null; + final scheme = segments[0]; + final defaultPort = switch (scheme) { + 'https' => 443, + 'http' => 80, + _ => null, + }; + if (defaultPort == null) return null; + + final hostSegment = segments[1]; + String host = hostSegment; + int port = defaultPort; + + final colonIdx = hostSegment.lastIndexOf(':'); + if (colonIdx > 0) { + host = hostSegment.substring(0, colonIdx); + port = int.tryParse(hostSegment.substring(colonIdx + 1)) ?? defaultPort; + } + + return requestUri.replace( + scheme: scheme, + host: host, + port: port, + pathSegments: segments.skip(2), + ); + } + + Uri encodeSourceUrl(Uri sourceUrl) { + if (sourceUrl.host == serverUri.host) { + if (!validateCacheUrl(sourceUrl)) { + throw ArgumentError( + 'Invalid source URL: $sourceUrl. The host matches the cache server host but the URL is not a valid cache URL.'); + } + return sourceUrl; //Already encoded + } - Uri getCacheUrl(Uri sourceUrl) { - return sourceUrl.replace( - scheme: serverUri.scheme, host: serverUri.host, port: serverUri.port); + final defaultPort = switch (sourceUrl.scheme) { + 'https' => 443, + 'http' => 80, + _ => throw ArgumentError( + 'Unsupported URI scheme: ${sourceUrl.scheme}. Only http and https are supported.'), + }; + + String hostSegment = sourceUrl.host; + + if (sourceUrl.hasPort && sourceUrl.port != defaultPort) { + hostSegment += ':${sourceUrl.port}'; + } + + final encodedUrl = sourceUrl.replace( + scheme: serverUri.scheme, + host: serverUri.host, + port: serverUri.port, + pathSegments: [sourceUrl.scheme, hostSegment, ...sourceUrl.pathSegments], + ); + assert( + validateCacheUrl(encodedUrl), 'Encoded URL is not valid: $encodedUrl'); + return encodedUrl; } + bool validateCacheUrl(Uri url) { + if (!url.originEquals(serverUri)) return false; + return decodeSourceUrl(url) != null; + } + + Future ensureActive() => _httpServer.ensureActive(); + Future close({bool force = true}) { return _httpServer.close(force: force); } diff --git a/lib/src/cache_stream/cache_downloader/buffered_io_sink.dart b/lib/src/cache_stream/cache_downloader/buffered_io_sink.dart index 52e3f38..37e5ab1 100644 --- a/lib/src/cache_stream/cache_downloader/buffered_io_sink.dart +++ b/lib/src/cache_stream/cache_downloader/buffered_io_sink.dart @@ -1,18 +1,18 @@ +import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; /// An IO sink that supports adding data while flushing to disk asynchronously. class BufferedIOSink { final File file; - BufferedIOSink(this.file, final int initialPosition) - : _initialPosition = initialPosition >= 0 ? initialPosition : 0; - + BufferedIOSink(this.file, int initialPosition) + : _flushedBytes = initialPosition; + int _flushedBytes; final _buffer = BytesBuilder(copy: false); - final int _initialPosition; RandomAccessFile? _openedRAF; - int _flushedBytes = 0; bool _isClosed = false; Future? _flushFuture; + final List<({int position, Completer completer})> _positionWaiters = []; void add(List data) { if (_isClosed) { @@ -28,27 +28,69 @@ class BufferedIOSink { if (_buffer.isEmpty) return Future.value(); return _flushFuture = () async { - RandomAccessFile? raf = _openedRAF; + try { + RandomAccessFile? raf = _openedRAF; + + if (raf == null) { + FileMode fileMode = FileMode.append; - if (raf == null) { - FileMode fileMode = FileMode.append; + if (flushedBytes == 0) { + await file.parent.create(recursive: true); //Ensure directory exists + fileMode = FileMode.write; //Overwrite existing + } - if (_initialPosition == 0 && _flushedBytes == 0) { - await file.parent.create(recursive: true); //Ensure directory exists - fileMode = FileMode.write; //Overwrite existing + raf = _openedRAF = await file.open(mode: fileMode); } - raf = _openedRAF = await file.open(mode: fileMode); + while (_buffer.isNotEmpty) { + final bytes = _buffer.takeBytes(); + await raf.writeFrom(bytes, 0, bytes.length); + _flushedBytes += bytes.length; + _notifyPositionWaiters(); + } + _flushFuture = null; + } catch (e) { + _failPositionWaiters(e); + rethrow; } + }(); + } - while (_buffer.isNotEmpty) { - final bytes = _buffer.takeBytes(); - await raf.writeFrom(bytes, 0, bytes.length); - _flushedBytes += bytes.length; + /// Returns a [Future] that completes once [flushedBytes] reaches or exceeds [minFlushedBytes]. + /// Completes immediately if the position is already reached. + /// Fails if the sink is closed or a flush error occurs before the position is reached. + Future waitForPosition(int minFlushedBytes, + [Duration timeout = const Duration(seconds: 30)]) { + if (_flushedBytes >= minFlushedBytes) return Future.value(); + if (_isClosed) { + return Future.error(StateError( + 'BufferedIOSink closed before reaching position $minFlushedBytes')); + } + final completer = Completer(); + _positionWaiters.add((position: minFlushedBytes, completer: completer)); + return completer.future.timeout(timeout, onTimeout: () { + _positionWaiters.removeWhere((w) => w.completer == completer); + throw TimeoutException( + 'Timeout while waiting for flushedBytes to reach $minFlushedBytes', + timeout); + }); + } + + void _notifyPositionWaiters() { + if (_positionWaiters.isEmpty) return; + for (int i = _positionWaiters.length - 1; i >= 0; i--) { + if (_flushedBytes >= _positionWaiters[i].position) { + _positionWaiters.removeAt(i).completer.complete(); } + } + } - _flushFuture = null; - }(); + void _failPositionWaiters(Object error) { + if (_positionWaiters.isEmpty) return; + for (final w in _positionWaiters) { + w.completer.completeError(error); + } + _positionWaiters.clear(); } Future close({final bool flushBuffer = true}) async { @@ -61,6 +103,7 @@ class BufferedIOSink { } await flush(); //Even if !flushBuffer, ongoing flush must complete before RAF can be closed } finally { + _failPositionWaiters(StateError('BufferedIOSink closed')); _buffer.clear(); if (_openedRAF case final RandomAccessFile raf) { _openedRAF = null; diff --git a/lib/src/cache_stream/cache_downloader/cache_downloader.dart b/lib/src/cache_stream/cache_downloader/cache_downloader.dart index 05839bd..74160f4 100644 --- a/lib/src/cache_stream/cache_downloader/cache_downloader.dart +++ b/lib/src/cache_stream/cache_downloader/cache_downloader.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import '../../etc/extensions/file_extensions.dart'; -import '../../etc/extensions/list_extensions.dart'; +import 'package:http_cache_stream/src/etc/extensions/file_extensions.dart'; + +import '../../models/cache_config/stream_cache_config.dart'; import '../../models/cache_files/cache_files.dart'; -import '../../models/config/stream_cache_config.dart'; import '../../models/exceptions/http_exceptions.dart'; import '../../models/exceptions/invalid_cache_exceptions.dart'; import '../../models/metadata/cache_metadata.dart'; @@ -15,19 +15,24 @@ import 'buffered_io_sink.dart'; import 'downloader.dart'; class CacheDownloader { - final int startPosition; final CacheFiles _cacheFiles; final Downloader _downloader; final BufferedIOSink _sink; final _streamController = StreamController>.broadcast(sync: true); final _completer = Completer(); - CacheDownloader._(final CacheMetadata cacheMetadata, this.startPosition, - this._downloader, this._sink) - : _cacheFiles = cacheMetadata.cacheFiles, - _cachedHeaders = cacheMetadata.headers; - int _receivedBytes = 0; //Total bytes received from downloader + int _position; int _pendingStreamBytes = 0; //Bytes received but not added to stream yet. These bytes will be added within the current event loop. + CachedResponseHeaders? _cachedHeaders; + bool _paused = false; + CacheDownloader._( + final CacheMetadata cacheMetadata, + final int startPosition, + this._downloader, + ) : _cacheFiles = cacheMetadata.cacheFiles, + _position = startPosition, + _sink = BufferedIOSink(cacheMetadata.partialCacheFile, startPosition), + _cachedHeaders = startPosition > 0 ? cacheMetadata.headers : null; factory CacheDownloader.construct( final CacheMetadata cacheMetadata, @@ -36,7 +41,7 @@ class CacheDownloader { final partialCacheFile = cacheMetadata.partialCacheFile; int startPosition = 0; - if (cacheMetadata.headers?.canResumeDownload() == true) { + if (cacheMetadata.headers?.canResumeDownload() ?? false) { startPosition = partialCacheFile.lengthSyncOrNull() ?? 0; } @@ -44,7 +49,6 @@ class CacheDownloader { cacheMetadata, startPosition, Downloader(cacheMetadata.sourceUrl, cacheConfig), - BufferedIOSink(partialCacheFile, startPosition), ); } @@ -52,7 +56,7 @@ class CacheDownloader { required final void Function(Object e) onError, required final void Function(CachedResponseHeaders headers) onHeaders, required final void Function(int position) onPosition, - required final Future Function() onComplete, + required final Future Function(int sourceLength) onComplete, }) async { final int maxBufferSize = _downloader.streamConfig.maxBufferSize; @@ -65,13 +69,12 @@ class CacheDownloader { _streamController.addError(error); }, onHeaders: (cacheHttpHeaders) { - if (downloadPosition > 0) { - final prevHeaders = _cachedHeaders; - if (prevHeaders != null && - !CachedResponseHeaders.validateCacheResponse( - prevHeaders, cacheHttpHeaders)) { - throw CacheSourceChangedException(sourceUrl); - } + final prevHeaders = _cachedHeaders; + if (prevHeaders != null && + downloadPosition > 0 && + !CachedResponseHeaders.validateCacheResponse( + prevHeaders, cacheHttpHeaders)) { + throw CacheSourceChangedException(sourceUrl); } _cachedHeaders = cacheHttpHeaders; @@ -80,26 +83,31 @@ class CacheDownloader { downloadPosition); //Emit current position to update progress and process queued requests }, onData: (data) { - assert(data.isNotEmpty); - assert(!_isProcessingRequests); - _receivedBytes += data.length; + _position += data.length; _sink.add(data); - - if (_sink.bufferSize > maxBufferSize) { - _downloader - .pause(); //Pause upstream if we are receiving more data than we can write - _sink.flush().then((_) => _downloader.resume(), - onError: cancel); //Resume upstream after flushing - } else if (!_sink.isFlushing) { - _sink.flush().catchError(cancel); - } - _pendingStreamBytes = data.length; onPosition( downloadPosition); //Emit current position to update progress and synchronously process queued requests _streamController.add( data); //Add after processing queued requests. Requests may be fulfilled from the data. _pendingStreamBytes = 0; + + if (_sink.bufferSize > maxBufferSize) { + _downloader + .pause(); //Pause upstream if we are receiving more data than we can write + _sink.flush().then( + (_) { + _downloader.resume(); + }, + onError: (e) { + cancel(e); + }, + ); + } else if (!_sink.isFlushing) { + _sink.flush().catchError((e) { + cancel(e); + }); + } }, ); } on InvalidCacheException { @@ -116,17 +124,17 @@ class CacheDownloader { onError(e); } final partialCacheLength = (await _sink.file.stat()).size; + + InvalidCacheSizeException.validate( + sourceUrl, + partialCacheLength, + downloadPosition, + ); + final sourceLength = _cachedHeaders?.sourceLength ?? (_downloader.isDone ? downloadPosition : null); - if (partialCacheLength == sourceLength) { - await onComplete(); - } else if (partialCacheLength != downloadPosition && - (partialCacheLength != -1 || downloadPosition != 0)) { - throw InvalidCacheLengthException( - sourceUrl, - partialCacheLength, - downloadPosition, - ); + if (sourceLength != null && partialCacheLength == sourceLength) { + await onComplete(sourceLength); } } finally { if (!_completer.isCompleted) { @@ -145,107 +153,76 @@ class CacheDownloader { } } - /// Cancels the download and closes the stream. An error must be provided to indicate the reason for cancellation. - Future cancel(final Object error) { - _downloader.close(error); + /// Cancels the download and closes the stream. An optional [error] can be provided to indicate the reason for cancellation. + Future cancel([Object? exception]) async { + _downloader.close(exception); + _paused = false; return _completer.future; } - bool processRequest(final StreamRequest request) { - if (request.start > downloadPosition) { - return false; - } - final cachedHeaders = _cachedHeaders; - if (cachedHeaders == null) { - return false; //Headers required to process request - } + void pause() { + if (_paused || !_downloader.isActive) return; + _paused = true; + _downloader.pause(); + } - final requestEnd = request.end ?? sourceLength; - if (requestEnd != null && filePosition >= requestEnd) { - ///We have enough buffered data in the file to fulfill the request - request.complete(() => - StreamResponse.fromFile(request.range, _cacheFiles, cachedHeaders)); - return true; - } - if (!_downloader.isActive) { - return false; //Cannot fulfill request end if downloader is not active - } - if (request.start >= streamPosition) { - ///We can fulfill the request from the stream alone - request.complete( - () => StreamResponse.fromStream( - request.range, - cachedHeaders, - _streamController.stream, - streamPosition, - _downloader.streamConfig, - ), - ); - return true; - } else if (filePosition == streamPosition) { - ///File and stream are already aligned, we can fulfill the request by combining them - request.complete( - () => StreamResponse.fromFileAndStream( + void resume() { + if (!_paused) return; + _paused = false; + _downloader.resume(); + } + + bool processRequest(final StreamRequest request) { + assert(!_paused); + if (request.start > downloadPosition) return false; + if (!_downloader.isActive) return false; + final headers = _cachedHeaders; + if (headers == null) return false; + + request.complete(() async { + if (request.start >= streamPosition) { + return StreamResponse.fromStream( request.range, - cachedHeaders, - _cacheFiles, + headers, _streamController.stream, streamPosition, _downloader.streamConfig, - ), - ); - return true; - } else { - //Synchronize file and stream positions before fulfilling the request - _processCombinedRequests(request, cachedHeaders); - return true; - } - } + ); + } - ///Processes requests that start before the current download position by combining file and stream data - void _processCombinedRequests( - final StreamRequest request, final CachedResponseHeaders headers) async { - _processingRequests.add(request); - if (_isProcessingRequests) return; - _isProcessingRequests = true; + final effectiveEnd = request.end ?? headers.sourceLength; + if (effectiveEnd != null && downloadPosition >= effectiveEnd) { + await _sink.waitForPosition(effectiveEnd); + return StreamResponse.fromFile(request.range, _cacheFiles, headers); + } - try { - _downloader - .pause(); //Pause download. The download stream must begin where the file ends. - await _sink.flush(); //Ensure all data is written to the cache file + final dataStreamPosition = streamPosition; + final combinedCacheResponse = StreamResponse.combined( + request.range, + headers, + _cacheFiles, + _streamController.stream, + dataStreamPosition, + _downloader.streamConfig, + ); - if (_downloader.isClosed) { - throw DownloadStoppedException(sourceUrl); + try { + await _sink.waitForPosition(dataStreamPosition); + return combinedCacheResponse; + } catch (_) { + combinedCacheResponse.cancel(); + rethrow; } - _processingRequests.processAndRemove((request) { - request.complete( - () => StreamResponse.fromFileAndStream( - request.range, - headers, - _cacheFiles, - _streamController.stream, - streamPosition, - _downloader.streamConfig, - ), - ); - }); - } catch (e) { - _processingRequests.processAndRemove((request) { - request.completeError(e); - }); - } finally { - _isProcessingRequests = false; - _downloader.resume(); - } + }); + + return true; } - bool _isProcessingRequests = false; - final List _processingRequests = []; int? get sourceLength => _cachedHeaders?.sourceLength; - int get downloadPosition => startPosition + _receivedBytes; + int get downloadPosition => _position; int get streamPosition => downloadPosition - _pendingStreamBytes; - int get filePosition => startPosition + _sink.flushedBytes; + int get filePosition => _sink.flushedBytes; Uri get sourceUrl => _downloader.sourceUrl; bool get isClosed => _completer.isCompleted; - CachedResponseHeaders? _cachedHeaders; + bool get isPaused => _paused; } diff --git a/lib/src/cache_stream/cache_downloader/download_response_listener.dart b/lib/src/cache_stream/cache_downloader/download_response_listener.dart index dcc5540..e8e79d9 100644 --- a/lib/src/cache_stream/cache_downloader/download_response_listener.dart +++ b/lib/src/cache_stream/cache_downloader/download_response_listener.dart @@ -2,7 +2,7 @@ import 'dart:async'; import '../../etc/chunked_bytes_buffer.dart'; import '../../etc/timeout_timer.dart'; -import '../../models/config/stream_cache_config.dart'; +import '../../models/cache_config/stream_cache_config.dart'; import '../../models/exceptions/http_exceptions.dart'; class DownloadResponseListener { @@ -50,9 +50,12 @@ class DownloadResponseListener { _completer.completeError(error); } - void pause() { + void pause({final bool flushBuffer = true}) { _timeoutTimer.reset(); _subscription.pause(); + if (flushBuffer) { + _buffer.flush(); + } } void resume() { diff --git a/lib/src/cache_stream/cache_downloader/downloader.dart b/lib/src/cache_stream/cache_downloader/downloader.dart index d7fa3ce..28d5752 100644 --- a/lib/src/cache_stream/cache_downloader/downloader.dart +++ b/lib/src/cache_stream/cache_downloader/downloader.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:http_cache_stream/src/cache_stream/response_streams/download_stream.dart'; import 'package:http_cache_stream/src/models/exceptions/invalid_cache_exceptions.dart'; -import '../../etc/pause_counter.dart'; -import '../../models/config/stream_cache_config.dart'; +import '../../etc/counters/pause_counter.dart'; +import '../../models/cache_config/stream_cache_config.dart'; import '../../models/exceptions/http_exceptions.dart'; import '../../models/metadata/cached_response_headers.dart'; import '../../models/stream_requests/int_range.dart'; @@ -56,12 +56,15 @@ class Downloader { } checkActive(); onHeaders(downloadStream.responseHeaders); - _done = await (_responseListener = DownloadResponseListener( - sourceUrl, downloadStream, onData, streamConfig)) - .done; - _responseListener = null; + final responseListener = DownloadResponseListener( + sourceUrl, downloadStream, onData, streamConfig); + _responseListener = responseListener; + try { + _done = await responseListener.done; + } finally { + _responseListener = null; + } } catch (e) { - _responseListener = null; downloadStream?.cancel(); if (e is InvalidCacheException) { rethrow; @@ -69,8 +72,9 @@ class Downloader { break; } else { onError(e); - await Future.delayed( - const Duration(seconds: 5)); //Wait before retrying + await (_pauseCounter.isPaused + ? _pauseCounter.onResume + : Future.delayed(const Duration(seconds: 5))); } } } @@ -79,20 +83,21 @@ class Downloader { } } - void close([Object? error]) { + void close([Object? exception]) { _closed = true; final responseListener = _responseListener; if (responseListener != null) { _responseListener = null; - responseListener.cancel(error ?? DownloadStoppedException(sourceUrl)); + responseListener.cancel(exception ?? DownloadStoppedException(sourceUrl), + flushBuffer: exception is! InvalidCacheException); } _pauseCounter.resume(force: true); //Break any pauses } - void pause() { + void pause({bool flushBuffer = true}) { if (_closed) return; _pauseCounter.pause(); - _responseListener?.pause(); + _responseListener?.pause(flushBuffer: flushBuffer); } void resume() { diff --git a/lib/src/cache_stream/http_cache_stream.dart b/lib/src/cache_stream/http_cache_stream.dart index cbeb9be..0c95e76 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -1,18 +1,22 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:http_cache_stream/src/cache_stream/cache_downloader/cache_downloader.dart'; +import 'package:http_cache_stream/src/models/cache_config/stream_cache_config.dart'; import 'package:http_cache_stream/src/models/cache_files/cache_files.dart'; -import 'package:http_cache_stream/src/models/config/stream_cache_config.dart'; import 'package:http_cache_stream/src/models/metadata/cached_response_headers.dart'; import 'package:http_cache_stream/src/models/stream_requests/int_range.dart'; +import 'package:rxdart/subjects.dart'; import 'package:synchronized/synchronized.dart'; -import '../etc/callback_helpers.dart'; +import '../etc/counters/retain_counter.dart'; import '../etc/extensions/list_extensions.dart'; +import '../etc/future_runner.dart'; +import '../etc/helpers.dart'; +import '../models/cache_state/cache_state.dart'; import '../models/exceptions/http_exceptions.dart'; import '../models/exceptions/invalid_cache_exceptions.dart'; +import '../models/exceptions/state_errors.dart'; import '../models/exceptions/stream_response_exceptions.dart'; import '../models/metadata/cache_metadata.dart'; import '../models/stream_requests/stream_request.dart'; @@ -26,7 +30,7 @@ class HttpCacheStream { /// The source Url of the file to be downloaded (e.g., https://example.com/file.mp3) final Uri sourceUrl; - /// The Url of the cached stream (e.g., http://127.0.0.1:8080/file.mp3) + /// The Url of the cached stream served by the local cache server (e.g., http://localhost:8080/http/example.com/file.mp3) final Uri cacheUrl; /// The complete, partial, and metadata files used for the cache. @@ -37,28 +41,45 @@ class HttpCacheStream { final List _queuedRequests = []; - final _progressController = StreamController.broadcast(); + final _stateController = BehaviorSubject(); + final _retainCounter = RetainCounter(); CacheDownloader? _cacheDownloader; //The active cache downloader, if any. This can be used to cancel the download. - int _retainCount = 1; //The number of times the stream has been retained - Future? _downloadFuture; //The future for the current download, if any. - Future? _validateCacheFuture; - double? _lastProgress; //The last progress value emitted by the stream - Object? _lastError; //The last error emitted by the stream - late final _writeLock = Lock(); //Lock for modifying cache files + final _downloadFuture = FutureRunner(); + late final _downloadHeadersFuture = FutureRunner(); + final _validateCacheFuture = FutureRunner(); + final _initFuture = FutureRunner(); + Timer? _lifeCycleTimer; //Timer for auto-disposing the stream after release + late final _fileLock = Lock(); //Lock for modifying cache files final _disposeCompleter = Completer(); //Completer for the dispose future - CacheMetadata _cacheMetadata; //The metadata for the cache + CachedResponseHeaders? + _cachedResponseHeaders; //The cached response headers, if any HttpCacheStream({ required this.sourceUrl, required this.cacheUrl, required this.files, required this.config, - }) : _cacheMetadata = CacheMetadata.construct(files, sourceUrl) { - if (config.validateOutdatedCache) { - validateCache(force: false, resetInvalid: true).ignore(); - } + }) { + _initFuture.run(() async { + try { + _cachedResponseHeaders = + await CachedResponseHeaders.fromCacheFilesAsync(files); + } catch (e) { + _addError(e, closeRequests: false); + } finally { + await refreshCacheState(); + if (config.validateOutdatedCache) { + validateCache(force: false, resetInvalid: true).ignore(); + } + } + }); + } + + Future _ensureInit() async { + if (_initFuture.isRunning) await _initFuture(); + if (_validateCacheFuture.isRunning) await _validateCacheFuture(); } /// Requests a [StreamResponse] for the given byte range. @@ -71,14 +92,17 @@ class HttpCacheStream { start: start, end: end); //Requested range is empty, return only headers } - if (_validateCacheFuture != null) { - await _validateCacheFuture!; - } + await _ensureInit(); _checkDisposed(); - final range = IntRange.validate(start, end, metadata.sourceLength); - if (isCached) { - return StreamResponse.fromFile(range, files, metadata.headers!); + final responseHeaders = _cachedResponseHeaders; + final range = IntRange.validate(start, end, responseHeaders?.sourceLength); + + if (responseHeaders != null && cacheState.isComplete) { + final verifiedCacheState = await refreshCacheState(); + if (verifiedCacheState.isComplete) { + return StreamResponse.fromFile(range, files, responseHeaders); + } } final rangeThreshold = config.rangeRequestSplitThreshold; @@ -119,26 +143,18 @@ class HttpCacheStream { Future validateCache({ final bool force = false, final bool resetInvalid = false, - }) async { - if (_validateCacheFuture != null) { - return _validateCacheFuture; - } - _checkDisposed(); - if (isDownloading || !cacheFile.existsSync()) { - return null; //Cache does not exist or is downloading - } - final currentHeaders = - metadata.headers ?? CachedResponseHeaders.fromFile(cacheFile)!; - if (!force && currentHeaders.shouldRevalidate() == false) return true; - - return _validateCacheFuture = () async { + }) { + return _validateCacheFuture.run(() async { + await _initFuture(); + _checkDisposed(); + if (isDownloading || !cacheState.isComplete) { + return null; //Cache does not exist or is downloading + } + final currentHeaders = + _cachedResponseHeaders ??= CachedResponseHeaders.fromFile(cacheFile)!; + if (!force && currentHeaders.shouldRevalidate() == false) return true; try { - final latestHeaders = await CachedResponseHeaders.fromUrl( - sourceUrl, - httpClient: config.httpClient, - requestHeaders: config.combinedRequestHeaders(), - ).timeout(config.requestTimeout); - + final latestHeaders = await downloadHeaders(save: false); if (CachedResponseHeaders.validateCacheResponse( currentHeaders, latestHeaders) == true) { @@ -154,33 +170,18 @@ class HttpCacheStream { _addError(e, closeRequests: false); rethrow; } finally { - _validateCacheFuture = null; - _calculateCacheProgress(); + await refreshCacheState(); } - }(); + }); } /// Requests only the headers for the given byte range. Future head({final int? start, final int? end}) async { - if (_validateCacheFuture != null) { - await _validateCacheFuture!; - } + await _ensureInit(); _checkDisposed(); - final responseHeaders = metadata.headers ?? - await CachedResponseHeaders.fromUrl( - sourceUrl, - httpClient: config.httpClient, - requestHeaders: config.combinedRequestHeaders(), - ).then((headers) { - _setCachedResponseHeaders(headers); - return headers; - }).timeout( - config.requestTimeout, - onTimeout: () => - throw StreamRequestTimedOutException(config.requestTimeout), - ); - + final responseHeaders = + _cachedResponseHeaders ?? await downloadHeaders(save: true); final range = IntRange.validate(start, end, responseHeaders.sourceLength); return HeaderStreamResponse(range, responseHeaders); } @@ -188,138 +189,138 @@ class HttpCacheStream { /// Downloads and returns [cacheFile]. If the file already exists, returns immediately. If a download is already in progress, returns the same future. /// /// This method will return [DownloadStoppedException] if the cache stream is disposed before the download is complete. Other errors will be emitted to the [progressStream]. - Future download() async { - if (_downloadFuture != null) { - return _downloadFuture!; - } - _checkDisposed(); - final downloadCompleter = Completer(); - _downloadFuture = downloadCompleter.future; - - bool isComplete() { - if (downloadCompleter.isCompleted) return true; - final completed = _calculateCacheProgress() == 1.0; - if (completed) { - downloadCompleter.complete(cacheFile); - } - return completed; - } - - while (isRetained && !isComplete()) { - try { - final downloader = - _cacheDownloader = CacheDownloader.construct(metadata, config); - await downloader.download( - onPosition: (position) { - const double maxProgressBeforeCompletion = - 0.99; //To avoid setting progress to 1.0 before complete cache is ready - final int? sourceLength = downloader.sourceLength; - double? progress; - - if (sourceLength != null) { - progress = ((position / sourceLength * 100).round() / - 100); //Round to 2 decimal places - if (progress >= maxProgressBeforeCompletion) { - _updateProgressStream(maxProgressBeforeCompletion); - return; //Avoid processing queued requests until download is complete + Future download() { + return _downloadFuture.run(() async { + await _ensureInit(); + _checkDisposed(); + + while (true) { + if ((await refreshCacheState()).isComplete) { + return files.complete; + } + if (!isRetained) { + throw DownloadStoppedException(sourceUrl); + } + try { + final downloader = + _cacheDownloader = CacheDownloader.construct(metadata, config); + await downloader.download( + onPosition: (position) { + _updateCacheState( + CacheState.incomplete(position, downloader.sourceLength)); + while (_queuedRequests.isNotEmpty && + downloader.processRequest(_queuedRequests.first)) { + _queuedRequests.removeAt(0); } - } - - _updateProgressStream(progress); - while (_queuedRequests.isNotEmpty && - downloader.processRequest(_queuedRequests.first)) { - _queuedRequests.removeAt(0); - } - }, - onComplete: () async { - final completedCacheFile = - await files.partial.rename(files.complete.path); - final cachedHeaders = metadata.headers!; - if (cachedHeaders.sourceLength != downloader.downloadPosition || - !cachedHeaders.acceptsRangeRequests) { - _setCachedResponseHeaders( - cachedHeaders.setSourceLength(downloader.downloadPosition)); - } - _updateProgressStream(1.0); - downloadCompleter.complete(completedCacheFile); - fireUserCallback( - () => config.onCacheComplete(this, completedCacheFile)); - }, - onHeaders: (responseHeaders) { - _setCachedResponseHeaders(responseHeaders); - }, - onError: (e) { - assert(e is! InvalidCacheException); + }, + onComplete: (sourceLength) async { + await _fileLock.synchronized( + () => files.partial.rename(files.complete.path)); + final cachedHeaders = _cachedResponseHeaders!; + if (cachedHeaders.sourceLength != sourceLength || + !cachedHeaders.acceptsRangeRequests) { + _setCachedResponseHeaders( + cachedHeaders.setSourceLength(sourceLength)); + } + _updateCacheState(CacheState.complete(sourceLength)); + config.handleCacheCompletion(this, files.complete); + }, + onHeaders: (responseHeaders) { + _setCachedResponseHeaders(responseHeaders); + }, + onError: (e) { + assert(e is! InvalidCacheException); + _addError(e, closeRequests: true); + }, + ); + } catch (e) { + if (e is InvalidCacheException) { + await _resetCache(e); + } else { _addError(e, closeRequests: true); - }, - ); - } catch (e) { - _cacheDownloader = null; - if (e is InvalidCacheException) { - await _resetCache(e); - } else if (isRetained) { - _addError(e, closeRequests: true); + } + if (!isRetained) rethrow; await Future.delayed(const Duration(seconds: 5)); + } finally { + _cacheDownloader = null; } } - } - _cacheDownloader = null; - _downloadFuture = null; - if (!isComplete()) { - final error = isRetained - ? DownloadStoppedException(sourceUrl) - : CacheStreamDisposedException(sourceUrl); - downloadCompleter.future - .ignore(); // Prevent unhandled error during completion - downloadCompleter.completeError(error); - _addError(error, closeRequests: true); - } - return downloadCompleter.future; + }); + } + + Future downloadHeaders({bool save = true}) { + return _downloadHeadersFuture.run(() async { + final latestHeaders = await CachedResponseHeaders.fromUrl( + sourceUrl, + httpClient: config.httpClient, + requestHeaders: config.combinedRequestHeaders(), + ).timeout( + config.requestTimeout, + onTimeout: () => throw RequestTimedOutException( + sourceUrl, + config.requestTimeout, + ), + ); + if (save && !isDisposed) { + _setCachedResponseHeaders(latestHeaders); + } + + return latestHeaders; + }); } - /// Disposes this [HttpCacheStream]. This method should be called when you are done with the stream. + /// Disposes this [HttpCacheStream]. /// /// If [force] is true, the stream will be disposed immediately, regardless of the [retain] count. - /// [retain] is incremented when the stream is obtained using [HttpCacheManager.createStream]. + /// Prefer using [release] instead when the stream may be reused again /// Returns a future that completes when the stream is disposed. Future dispose({final bool force = false}) { - if (_retainCount > 0 && !isDisposed) { - _retainCount = force ? 0 : _retainCount - 1; - if (!isRetained) { - () async { - late final error = CacheStreamDisposedException(sourceUrl); - try { - final downloader = _cacheDownloader; - if (downloader != null) { - await downloader.cancel( - error); //Allow downloader to complete cleanly. Note that the stream can be retained again during this await. - if (isRetained) { - return; //Stream was retained again during download cancellation - } - } - if (!config.savePartialCache && progress != 1.0) { - await resetCache(); - } else if (!config.saveMetadata && - progress == 1.0 && - files.metadata.existsSync()) { - await _writeLock.synchronized(files.metadata.delete); - } - } catch (e) { - _addError(e, closeRequests: false); - } finally { - if (!_disposeCompleter.isCompleted && !isRetained) { - _disposeCompleter.complete(); - if (_queuedRequests.isNotEmpty) { - _addError(error, closeRequests: true); - } - _progressController.close().ignore(); - } + _disposeRequested = true; + _retainCounter.release(force: force); + _performDispose(); + return _disposeCompleter.future; + } + + bool _disposeRequested = false; //If dispose() has been requested + bool _disposing = false; + void _performDispose() async { + if (isDisposed || isRetained || _disposing) return; + _disposing = true; + + _lifeCycleTimer?.cancel(); + _lifeCycleTimer = null; + + try { + final downloader = _cacheDownloader; + if (downloader != null) { + await downloader.cancel(); + if (isRetained) { + return; //Stream was retained again during download cancellation + } + } + if (!config.savePartialCache && !cacheState.isComplete) { + await resetCache(); + } else if (!config.saveMetadata && cacheState.isComplete) { + _cachedResponseHeaders = null; + await _fileLock.synchronized(() async { + if (await files.metadata.exists()) { + await files.metadata.delete(); } - }(); + }); + } + } catch (e) { + _addError(e, closeRequests: false); + } finally { + _disposing = false; + if (!_disposeCompleter.isCompleted && !isRetained) { + _disposeCompleter.complete(); + if (_queuedRequests.isNotEmpty) { + _addError(CacheStreamDisposedException(sourceUrl), + closeRequests: true); + } + _stateController.close().ignore(); } } - - return _disposeCompleter.future; } /// Resets the cache files used by this [HttpCacheStream], interrupting any ongoing download. @@ -331,22 +332,19 @@ class HttpCacheStream { return downloader.cancel( exception); //Close the ongoing download, which will rethrow the exception and reset the cache } else { - return _writeLock.synchronized(() async { - if (progress != null || metadata.headers != null) { - try { - _cacheMetadata = _cacheMetadata.setHeaders(null); - _updateProgressStream(null); - if (exception is! CacheResetException) { - _addError(exception, closeRequests: false); - } - await files.delete(partialOnly: false); - } catch (e) { - _addError(e, closeRequests: false); - } finally { - if (_queuedRequests.isNotEmpty && !isDownloading && isRetained) { - download() - .ignore(); //Restart download to fulfill pending requests - } + return _fileLock.synchronized(() async { + try { + _cachedResponseHeaders = null; + _updateCacheState(const CacheState.zero()); + if (exception is! CacheResetException) { + _addError(exception, closeRequests: false); + } + await files.delete(partialOnly: false); + } catch (e) { + _addError(e, closeRequests: false); + } finally { + if (_queuedRequests.isNotEmpty && !isDownloading && isRetained) { + download().ignore(); //Restart download to fulfill pending requests } } }); @@ -357,51 +355,52 @@ class HttpCacheStream { if (!config.saveAllHeaders) { headers = headers.essentialHeaders(); } - _cacheMetadata = _cacheMetadata.setHeaders(headers); + _cachedResponseHeaders = headers; - _writeLock.synchronized(() async { + _fileLock.synchronized(() async { try { await files.metadata.parent.create(recursive: true); - await files.metadata.writeAsString(jsonEncode(_cacheMetadata.toJson())); + await files.metadata.writeAsBytes(jsonEncodeToBytes(metadata.toJson())); } catch (e) { _addError(e, closeRequests: false); } }); } - double? _calculateCacheProgress() { - double? cacheProgress; + Future refreshCacheState() async { + CacheState state; try { - cacheProgress = metadata.cacheProgress(); + state = await metadata.cacheState(); } catch (e) { - _addError(e, closeRequests: false); + state = const CacheState.zero(); + if (e is InvalidCacheException) { + _resetCache(e).ignore(); + } else { + _addError(e, closeRequests: false); + } } - _updateProgressStream(cacheProgress); - return cacheProgress; + _updateCacheState(state); + return state; } - void _updateProgressStream(final double? progress) { - if (progress != _lastProgress) { - _lastProgress = progress; - if (!_progressController.isClosed) { - _progressController.add(progress); - } + void _updateCacheState(final CacheState cacheState) { + if (!_stateController.isClosed) { + _stateController.add(cacheState); } - if (progress == 1.0 && + + if (cacheState.isComplete && _queuedRequests.isNotEmpty && - metadata.headers != null) { + headers != null) { _queuedRequests.processAndRemove((request) { - request.complete(() => - StreamResponse.fromFile(request.range, files, metadata.headers!)); + request.complete( + () => StreamResponse.fromFile(request.range, files, headers!)); }); } } void _addError(final Object error, {required final bool closeRequests}) { - _lastError = error; - if (!_progressController.isClosed && - (isRetained || _queuedRequests.isNotEmpty)) { - _progressController.addError(error); + if (!_stateController.isClosed && isRetained) { + _stateController.addError(error); } if (closeRequests) { _queuedRequests.processAndRemove((request) { @@ -416,66 +415,55 @@ class HttpCacheStream { } } - /// Returns a stream of download progress 0-1, rounded to 2 decimal places, and any errors that occur. - /// - /// Returns null if the source length is unknown. Returns 1.0 only if the cache file exists. - /// To get the latest progress value use the [progress] property. - Stream get progressStream => _progressController.stream; - - /// Returns true if the complete cache file and response headers exist. This indicates that the cache is fully available. - bool get isCached { - if (progress == 1.0) { - if (metadata.headers != null && cacheFile.existsSync()) { - return true; - } - _calculateCacheProgress(); //Cache file is missing or metadata is incomplete, recalculate progress - } - return false; - } + /// Returns a stream of download progress 0-1, Returns 1.0 only if the cache file exists. + /// See [cacheStateStream] for more detailed cache state updates. + late final Stream progressStream = + _stateController.stream.map((state) { + final p = state.progress; + if (p == null || p == 1.0) return p; + return (p * 100).round() / 100.0; + }).distinct(); + + /// Returns a stream of [CacheState] updates for this [HttpCacheStream]. + Stream get cacheStateStream => _stateController.stream; /// If this [HttpCacheStream] has been disposed. A disposed stream cannot be used. bool get isDisposed => _disposeCompleter.isCompleted; /// If this [HttpCacheStream] is actively downloading data to cache file. - bool get isDownloading => _downloadFuture != null; + bool get isDownloading => _downloadFuture.isRunning; - /// The current position of the cache file. - /// - /// If a download is in progress, returns the current download position. - /// Otherwise, returns the size of the cache file. - int get cachePosition { - final downloadPosition = _cacheDownloader?.downloadPosition; - if (downloadPosition != null) { - return downloadPosition; - } else if (progress != null && metadata.sourceLength != null) { - return (progress! * metadata.sourceLength!).round(); - } else { - return files.cacheFileSize() ?? 0; - } - } + /// Bytes currently available in the cache (downloaded or on disk). + /// For an active download, this may be ahead of the current read position. For a completed cache, this will match [sourceLength]. + int get cachePosition => + _cacheDownloader?.downloadPosition ?? cacheState.position; /// If this [HttpCacheStream] is retained. /// /// A retained stream will not be disposed until the [dispose] method is /// called the same number of times as [retain] was called. - bool get isRetained => _retainCount > 0; + bool get isRetained => _retainCounter.isRetained; - /// The number of times this [HttpCacheStream] has been retained. - /// - /// This is incremented when the stream is obtained using [HttpCacheManager.createStream], - /// and decremented when [dispose] is called. - int get retainCount => _retainCount; + /// The number of active holders of this stream. Starts at 1 when the stream is created. + /// Incremented by [retain] and decremented by [release] or [dispose]. + int get retainCount => _retainCounter.count; - /// The latest download progress 0-1, rounded to 2 decimal places. - /// + /// The latest download progress 0-1. /// Returns null if the source length is unknown. Returns 1.0 only if the cache file exists. - double? get progress => _lastProgress ?? _calculateCacheProgress(); + double? get progress => cacheState.progress; + + CacheState get cacheState => + _stateController.valueOrNull ?? const CacheState.zero(); /// Returns the last emitted error, or null if error events haven't yet been emitted. - Object? get lastErrorOrNull => _lastError; + Object? get lastErrorOrNull => _stateController.errorOrNull; /// The current [CacheMetadata] for this [HttpCacheStream]. - CacheMetadata get metadata => _cacheMetadata; + CacheMetadata get metadata => + CacheMetadata(files, sourceUrl, _cachedResponseHeaders); + + /// The cached response headers for this [HttpCacheStream], if available. + CachedResponseHeaders? get headers => _cachedResponseHeaders; /// The output cache file for this [HttpCacheStream]. /// @@ -486,11 +474,57 @@ class HttpCacheStream { /// /// This method is automatically called when the stream is obtained /// using [HttpCacheManager.createStream]. The stream will not be - /// disposed until the [dispose] method is called the same number of times + /// disposed until the [dispose] or [release] method is called the same number of times /// as this method. void retain() { _checkDisposed(); - _retainCount = _retainCount <= 0 ? 1 : _retainCount + 1; + _disposeRequested = false; //A new holder resurrects the stream + _retainCounter.retain(); + _lifeCycleTimer?.cancel(); + _lifeCycleTimer = null; + _cacheDownloader?.resume(); + } + + /// Releases this [HttpCacheStream] instance. + /// + /// Once released, [StreamLifecycleConfig] is used to manage how long before the stream is paused and disposed. If the stream is retained again before being disposed, it will resume as normal. + void release() { + if (!isRetained) return; + _retainCounter.release(); + _lifeCycleTimer?.cancel(); + + if (!isRetained) { + if (_disposeRequested) { + _performDispose(); //Honor a pending dispose() instead of deferring to the lifecycle + return; + } + + final lifecycleConfig = config.lifecycleConfig; + + _lifeCycleTimer = Timer(lifecycleConfig.pauseAfter, () { + final remainingAfterPause = + lifecycleConfig.disposeAfter - lifecycleConfig.pauseAfter; + if (remainingAfterPause <= Duration.zero) { + _performDispose(); + return; + } + + final downloader = _cacheDownloader; + downloader?.pause(); + + final cancelDelay = config.readTimeout; + final remainingAfterCancel = remainingAfterPause - cancelDelay; + + _lifeCycleTimer = Timer(cancelDelay, () { + downloader?.cancel().ignore(); + if (remainingAfterCancel > Duration.zero) { + _lifeCycleTimer = Timer(remainingAfterCancel, _performDispose); + } else { + _performDispose(); + } + }); + }); + } } /// Returns a future that completes when this [HttpCacheStream] is disposed. diff --git a/lib/src/cache_stream/response_streams/buffered_data_stream.dart b/lib/src/cache_stream/response_streams/buffered_data_stream.dart index 95d6867..fe04b63 100644 --- a/lib/src/cache_stream/response_streams/buffered_data_stream.dart +++ b/lib/src/cache_stream/response_streams/buffered_data_stream.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import '../../etc/extensions/stream_extensions.dart'; -import '../../models/config/stream_cache_config.dart'; +import '../../models/cache_config/stream_cache_config.dart'; import '../../models/exceptions/stream_response_exceptions.dart'; import '../../models/stream_response/stream_response_range.dart'; diff --git a/lib/src/cache_stream/response_streams/combined_data_stream.dart b/lib/src/cache_stream/response_streams/combined_data_stream.dart index 6f72006..9fec3ea 100644 --- a/lib/src/cache_stream/response_streams/combined_data_stream.dart +++ b/lib/src/cache_stream/response_streams/combined_data_stream.dart @@ -1,8 +1,8 @@ import 'dart:async'; import '../../etc/extensions/stream_extensions.dart'; +import '../../models/cache_config/stream_cache_config.dart'; import '../../models/cache_files/cache_files.dart'; -import '../../models/config/stream_cache_config.dart'; import '../../models/exceptions/stream_response_exceptions.dart'; import '../../models/stream_requests/int_range.dart'; import '../../models/stream_response/stream_response_range.dart'; @@ -29,15 +29,6 @@ class CombinedDataStream extends Stream> { final int? sourceLength, final StreamCacheConfig streamConfig, ) { - assert(() { - final cacheFileSize = cacheFiles.activeCacheFile().statSync().size; - if (cacheFileSize < dataStreamPosition) { - throw StateError( - 'CombinedDataStream: cacheFileSize ($cacheFileSize) is less than dataStreamPosition ($dataStreamPosition)'); - } - return true; - }()); - return CombinedDataStream._( CacheFileStream( StreamRange.validate(range.start, dataStreamPosition, diff --git a/lib/src/cache_stream/response_streams/download_stream.dart b/lib/src/cache_stream/response_streams/download_stream.dart index 8653347..bba2430 100644 --- a/lib/src/cache_stream/response_streams/download_stream.dart +++ b/lib/src/cache_stream/response_streams/download_stream.dart @@ -6,7 +6,7 @@ import 'package:http_cache_stream/src/models/exceptions/invalid_cache_exceptions import 'package:http_cache_stream/src/models/http_range/http_range_response.dart'; import '../../etc/extensions/http_extensions.dart'; -import '../../models/config/stream_cache_config.dart'; +import '../../models/cache_config/stream_cache_config.dart'; import '../../models/exceptions/http_exceptions.dart'; import '../../models/metadata/cached_response_headers.dart'; import '../../models/stream_requests/int_range.dart'; diff --git a/lib/src/etc/pause_counter.dart b/lib/src/etc/counters/pause_counter.dart similarity index 100% rename from lib/src/etc/pause_counter.dart rename to lib/src/etc/counters/pause_counter.dart diff --git a/lib/src/etc/counters/retain_counter.dart b/lib/src/etc/counters/retain_counter.dart new file mode 100644 index 0000000..a0e1dec --- /dev/null +++ b/lib/src/etc/counters/retain_counter.dart @@ -0,0 +1,16 @@ +class RetainCounter { + RetainCounter(); + int _retainCount = 1; + + void retain() { + _retainCount++; + } + + void release({bool force = false}) { + if (!isRetained) return; + _retainCount = force ? 0 : _retainCount - 1; + } + + bool get isRetained => _retainCount != 0; + int get count => _retainCount; +} diff --git a/lib/src/etc/extensions/file_extensions.dart b/lib/src/etc/extensions/file_extensions.dart index 0af27c5..4881e22 100644 --- a/lib/src/etc/extensions/file_extensions.dart +++ b/lib/src/etc/extensions/file_extensions.dart @@ -9,4 +9,13 @@ extension FileExtensions on File { return null; } } + + ///Returns the length of the file, or null if the file does not exist. + Future lengthOrNull() async { + try { + return await length(); + } on FileSystemException { + return null; + } + } } diff --git a/lib/src/etc/extensions/uri_extensions.dart b/lib/src/etc/extensions/uri_extensions.dart index 4387296..c994a39 100644 --- a/lib/src/etc/extensions/uri_extensions.dart +++ b/lib/src/etc/extensions/uri_extensions.dart @@ -1,7 +1,26 @@ extension UriExtensions on Uri { - //A helper method to get the path and query of a URI - //This is useful for creating a unique key to identify the request - String get requestKey { - return '$path?$query'; + RequestKey get requestKey { + return RequestKey(this); } + + Uri replaceOrigin(Uri newOrigin) { + return replace( + scheme: newOrigin.scheme, + host: newOrigin.host, + port: newOrigin.port, + ); + } + + Uri get originUri { + return Uri(scheme: scheme, host: host, port: port); + } + + bool originEquals(Uri other) { + return host == other.host && port == other.port && scheme == other.scheme; + } +} + +extension type const RequestKey._(String _value) implements String { + factory RequestKey(Uri uri) => + RequestKey._('${uri.host}${uri.path}?${uri.query}'); } diff --git a/lib/src/etc/future_runner.dart b/lib/src/etc/future_runner.dart new file mode 100644 index 0000000..e85393a --- /dev/null +++ b/lib/src/etc/future_runner.dart @@ -0,0 +1,19 @@ +class FutureRunner { + Future? _future; + + /// Whether a future is currently in flight. + bool get isRunning => _future != null; + + /// The currently running future, or null if no future is running. + Future? get future => _future; + + Future? call() => _future; + + /// Runs [factory] if idle, or returns the already-running future. + /// + /// The stored future is automatically cleared when it completes, + /// whether successfully or with an error. + Future run(Future Function() factory) { + return _future ??= factory().whenComplete(() => _future = null); + } +} diff --git a/lib/src/etc/callback_helpers.dart b/lib/src/etc/helpers.dart similarity index 56% rename from lib/src/etc/callback_helpers.dart rename to lib/src/etc/helpers.dart index 94ee4f5..e895a6d 100644 --- a/lib/src/etc/callback_helpers.dart +++ b/lib/src/etc/helpers.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; /// Helper function to execute user-provided callbacks safely, ensuring that any exceptions thrown are caught and handled by the current Zone's error handler. void fireUserCallback(void Function() callback) { @@ -8,3 +9,11 @@ void fireUserCallback(void Function() callback) { Zone.current.handleUncaughtError(e, st); } } + +dynamic jsonDecodeBytes(List bytes) { + return const Utf8Decoder().fuse(json.decoder).convert(bytes); +} + +List jsonEncodeToBytes(Object? object) { + return json.encoder.fuse(const Utf8Encoder()).convert(object); +} diff --git a/lib/src/models/config/cache_config.dart b/lib/src/models/cache_config/cache_config.dart similarity index 87% rename from lib/src/models/config/cache_config.dart rename to lib/src/models/cache_config/cache_config.dart index 6e7d4d2..f5c958a 100644 --- a/lib/src/models/config/cache_config.dart +++ b/lib/src/models/cache_config/cache_config.dart @@ -1,6 +1,10 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; +import '../../../http_cache_stream.dart'; + abstract interface class CacheConfiguration { ///Custom headers to be sent when downloading cache. Map get requestHeaders; @@ -80,6 +84,15 @@ abstract interface class CacheConfiguration { bool get saveAllHeaders; set saveAllHeaders(bool value); + /// The lifecycle configuration for the cache stream, which controls when the cache stream should be automatically disposed. + StreamLifecycleConfig get lifecycleConfig; + set lifecycleConfig(StreamLifecycleConfig config); + + /// Callback that is called when the cache is completely downloaded and written to disk. + CacheCompleteCallback? get onCacheDone; + set onCacheDone(CacheCompleteCallback? callback); + + @internal static int? validateRangeRequestSplitThreshold(int? value) { if (value == null) return null; return RangeError.checkNotNegative(value, 'RangeRequestSplitThreshold'); @@ -101,3 +114,6 @@ abstract interface class CacheConfiguration { return value; } } + +typedef CacheCompleteCallback = void Function( + HttpCacheStream stream, File completedCacheFile); diff --git a/lib/src/models/config/global_cache_config.dart b/lib/src/models/cache_config/global_cache_config.dart similarity index 83% rename from lib/src/models/config/global_cache_config.dart rename to lib/src/models/cache_config/global_cache_config.dart index c7dfefe..5940497 100644 --- a/lib/src/models/config/global_cache_config.dart +++ b/lib/src/models/cache_config/global_cache_config.dart @@ -1,12 +1,13 @@ import 'dart:io'; import 'package:http/http.dart'; -import 'package:http_cache_stream/src/models/config/cache_config.dart'; +import 'package:http_cache_stream/src/models/cache_config/cache_config.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../cache_stream/http_cache_stream.dart'; -import 'cache_file_resolver.dart'; +import '../cache_files/cache_file_resolver.dart'; +import 'stream_lifecycle_config.dart'; /// A configuration class for [HttpCacheManager]. /// @@ -20,6 +21,7 @@ class GlobalCacheConfig implements CacheConfiguration { int? rangeRequestSplitThreshold, Map? requestHeaders, Map? responseHeaders, + this.lifecycleConfig = const StreamLifecycleConfig(), this.customHttpClient, this.copyCachedResponseHeaders = false, this.validateOutdatedCache = false, @@ -108,13 +110,16 @@ class GlobalCacheConfig implements CacheConfiguration { @override bool saveAllHeaders; + @override + StreamLifecycleConfig lifecycleConfig; + /// A function that takes the cache directory and source URL, and returns a [File] where the cache should be stored. /// This allows for custom file naming and organization strategies. By default, it generates a file path based on the URL structure. /// This function is called for every cache stream, unless if a custom file is provided when creating the cache stream. final CacheFileResolver cacheFileResolver; - /// Callback function fired when a cache stream download is completed. - void Function(HttpCacheStream cacheStream, File cacheFile)? onCacheDone; + @override + CacheCompleteCallback? onCacheDone; /// Returns the default cache directory for the application. /// @@ -123,4 +128,15 @@ class GlobalCacheConfig implements CacheConfiguration { final temporaryDirectory = await getTemporaryDirectory(); return Directory(p.join(temporaryDirectory.path, 'http_cache_stream')); } + + /// Initializes a [GlobalCacheConfig] instance with the provided parameters, or default values if not provided. + static Future init({ + final Directory? cacheDir, + final Client? customHttpClient, + }) async { + return GlobalCacheConfig( + cacheDirectory: cacheDir ?? await defaultCacheDirectory(), + customHttpClient: customHttpClient, + ); + } } diff --git a/lib/src/models/config/stream_cache_config.dart b/lib/src/models/cache_config/stream_cache_config.dart similarity index 88% rename from lib/src/models/config/stream_cache_config.dart rename to lib/src/models/cache_config/stream_cache_config.dart index 8b71eb6..1d91e14 100644 --- a/lib/src/models/config/stream_cache_config.dart +++ b/lib/src/models/cache_config/stream_cache_config.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:http_cache_stream/http_cache_stream.dart'; +import '../../etc/helpers.dart'; + /// Cache configuration for a single [HttpCacheStream]. /// /// Values set here override the global values set in [GlobalCacheConfig]. @@ -49,6 +51,11 @@ class StreamCacheConfig implements CacheConfiguration { return _validateOutdatedCache ?? _global.validateOutdatedCache; } + @override + StreamLifecycleConfig get lifecycleConfig { + return _lifecycleConfig ?? _global.lifecycleConfig; + } + @override bool get savePartialCache { return _savePartialCache ?? _global.savePartialCache; @@ -144,9 +151,13 @@ class StreamCacheConfig implements CacheConfiguration { _saveAllHeaders = value; } - /// Register a callback to be called when this stream's cache is completely - /// downloaded and written to disk. - void Function(File cacheFile)? onCacheDone; + @override + set lifecycleConfig(StreamLifecycleConfig config) { + _lifecycleConfig = config; + } + + @override + CacheCompleteCallback? onCacheDone; /// Returns an immutable map of all custom request headers. Map combinedRequestHeaders() { @@ -169,9 +180,13 @@ class StreamCacheConfig implements CacheConfiguration { /// /// To register a callback, use [onCacheDone]. @internal - void onCacheComplete(HttpCacheStream stream, File cacheFile) { - onCacheDone?.call(cacheFile); - _global.onCacheDone?.call(stream, cacheFile); + void handleCacheCompletion(HttpCacheStream stream, File cacheFile) { + if (onCacheDone case final cacheDoneCallback?) { + fireUserCallback(() => cacheDoneCallback(stream, cacheFile)); + } + if (_global.onCacheDone case final globalCacheDoneCallback?) { + fireUserCallback(() => globalCacheDoneCallback(stream, cacheFile)); + } } Map _combineHeaders( @@ -194,6 +209,7 @@ class StreamCacheConfig implements CacheConfiguration { /// Stream-specific configuration bool _useGlobalRangeRequestSplitThreshold = true; + StreamLifecycleConfig? _lifecycleConfig; Duration? _readTimeout; Duration? _requestTimeout; bool? _copyCachedResponseHeaders; diff --git a/lib/src/models/cache_config/stream_lifecycle_config.dart b/lib/src/models/cache_config/stream_lifecycle_config.dart new file mode 100644 index 0000000..8c46dd7 --- /dev/null +++ b/lib/src/models/cache_config/stream_lifecycle_config.dart @@ -0,0 +1,40 @@ +/// Controls how an inactive `HttpCacheStream` is managed over time. +/// +/// `HttpCacheStream` instances are managed with a retain/release lifecycle. +/// Obtaining a stream retains it, and callers must release it when they are +/// finished. In most consumer flows, streams are used indirectly through +/// `getCacheUrl`, which automates retain/release handling. +/// +/// A stream is considered inactive when it is no longer retained. +/// Inactive streams are still eligible for pause and disposal according to this +/// configuration. +/// +/// Use this configuration to balance resource usage and resume behavior for +/// streams that are not actively being consumed. +/// +/// - `pauseAfter` determines how long an inactive stream may remain before any +/// active download is paused. Pausing preserves the connection for the +/// remaining `readTimeout` window so the stream can still resume without a +/// full reconnect. +/// - `disposeAfter` determines how long an inactive stream may remain before it +/// is fully disposed. Once disposed, the stream cannot be resumed and a new +/// request must be created. +class StreamLifecycleConfig { + /// The duration after which an inactive stream is paused. + /// + /// If a stream is no longer retained for this duration, the underlying + /// download will be paused and the connection is kept alive only long enough + /// to support resuming within the configured `readTimeout`. + final Duration pauseAfter; + + /// The duration after which an inactive stream is disposed. + /// + /// Once the stream has been disposed, it cannot be resumed and must be + /// recreated for subsequent requests. + final Duration disposeAfter; + + const StreamLifecycleConfig({ + this.pauseAfter = const Duration(seconds: 10), + this.disposeAfter = const Duration(minutes: 5), + }); +} diff --git a/lib/src/models/config/cache_file_resolver.dart b/lib/src/models/cache_files/cache_file_resolver.dart similarity index 100% rename from lib/src/models/config/cache_file_resolver.dart rename to lib/src/models/cache_files/cache_file_resolver.dart diff --git a/lib/src/models/cache_state/cache_state.dart b/lib/src/models/cache_state/cache_state.dart new file mode 100644 index 0000000..48c7a7c --- /dev/null +++ b/lib/src/models/cache_state/cache_state.dart @@ -0,0 +1,110 @@ +sealed class CacheState { + const CacheState(); + + const factory CacheState.zero() = IncompleteCacheState.zero; + + const factory CacheState.incomplete(int position, int? sourceLength) = + IncompleteCacheState; + + const factory CacheState.complete(int sourceLength) = CompleteCacheState; + + /// Bytes currently available in the cache (downloaded or on disk). + /// For an active download, this may be ahead of the current read position. For a completed cache, this will match [sourceLength]. + int get position; + + /// Total source size in bytes, or null if the server hasn't reported it yet. + int? get sourceLength; + + /// Whether the cache file has been fully downloaded and finalized (renamed). + bool get isComplete; + + /// Progress from 0.0 to 1.0. + /// + /// Returns null when [sourceLength] is unknown. + /// Returns exactly 1.0 only when the complete file is available on disk. Even if [position] has advanced to match [sourceLength], [progress] will still be less than 1.0 until the file is finalized, at which point a [CompleteCacheState] will be emitted. + double? get progress; + + /// Bytes remaining until the download is complete. + /// Null when [sourceLength] is unknown. + int? get remainingBytes; + + @override + String toString() => + 'CacheState(position: $position, sourceLength: $sourceLength, isComplete: $isComplete)'; +} + +/// Cache state while a download is in progress, paused, or not yet started. +final class IncompleteCacheState extends CacheState { + const IncompleteCacheState(this.position, this.sourceLength); + + const IncompleteCacheState.zero() : this(0, null); + + @override + final int position; + + @override + final int? sourceLength; + + @override + bool get isComplete => false; + + /// Capped at [maxProgress] — strictly less than 1.0 — because the cache + /// file is not yet finalized regardless of how far [position] has advanced. + @override + double? get progress { + final length = sourceLength; + if (length == null || length == 0) return null; + return (position / length).clamp(0.0, maxProgress); + } + + @override + int? get remainingBytes { + final length = sourceLength; + if (length == null) return null; + return (length - position).clamp(0, length); + } + + /// The maximum value [progress] will ever return for an incomplete state. + /// Progress only reaches 1.0 once [CompleteCacheState] is emitted. + static const double maxProgress = 0.99; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is IncompleteCacheState && + position == other.position && + sourceLength == other.sourceLength; + } + + @override + int get hashCode => Object.hash(position, sourceLength); +} + +/// Cache state once the file has been fully downloaded and renamed into place. +final class CompleteCacheState extends CacheState { + const CompleteCacheState(this.sourceLength); + + @override + final int sourceLength; + + @override + int get position => sourceLength; + + @override + bool get isComplete => true; + + @override + double get progress => 1.0; + + @override + int get remainingBytes => 0; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is CompleteCacheState && sourceLength == other.sourceLength; + } + + @override + int get hashCode => sourceLength.hashCode; +} diff --git a/lib/src/models/exceptions/http_exceptions.dart b/lib/src/models/exceptions/http_exceptions.dart index f1a1fae..b7fbd74 100644 --- a/lib/src/models/exceptions/http_exceptions.dart +++ b/lib/src/models/exceptions/http_exceptions.dart @@ -15,7 +15,7 @@ class DownloadStoppedException extends DownloadException { } class RequestTimedOutException extends DownloadException - implements TimeoutException { + implements TimeoutException, http.ClientException { @override final Duration duration; RequestTimedOutException(Uri uri, this.duration) @@ -28,7 +28,7 @@ class RequestTimedOutException extends DownloadException } class ReadTimedOutException extends DownloadException - implements TimeoutException { + implements TimeoutException, http.ClientException { @override final Duration duration; ReadTimedOutException(Uri uri, this.duration) diff --git a/lib/src/models/exceptions/invalid_cache_exceptions.dart b/lib/src/models/exceptions/invalid_cache_exceptions.dart index 4f05cf3..93773a5 100644 --- a/lib/src/models/exceptions/invalid_cache_exceptions.dart +++ b/lib/src/models/exceptions/invalid_cache_exceptions.dart @@ -3,26 +3,28 @@ import 'package:http_cache_stream/src/models/http_range/http_range.dart'; import '../http_range/http_range_request.dart'; import '../http_range/http_range_response.dart'; -class InvalidCacheException { +class InvalidCacheException implements Exception { final Uri uri; final String message; - InvalidCacheException(this.uri, this.message); + const InvalidCacheException(this.uri, this.message); @override String toString() => 'InvalidCacheException: $message'; } class CacheResetException extends InvalidCacheException { - CacheResetException(Uri uri) : super(uri, 'Cache reset by user request'); + const CacheResetException(Uri uri) + : super(uri, 'Cache reset by user request'); } class CacheSourceChangedException extends InvalidCacheException { - CacheSourceChangedException(Uri uri) : super(uri, 'Cache source changed'); + const CacheSourceChangedException(Uri uri) + : super(uri, 'Cache source changed'); } class HttpRangeException extends InvalidCacheException implements RangeError { final HttpRangeRequest request; final HttpRangeResponse? response; - HttpRangeException( + const HttpRangeException( Uri uri, this.request, this.response, @@ -63,16 +65,27 @@ class HttpRangeException extends InvalidCacheException implements RangeError { num? get end => request.end; } -class InvalidCacheLengthException extends InvalidCacheException { - InvalidCacheLengthException(Uri uri, int length, int expected) +class InvalidCacheSizeException extends InvalidCacheException { + final int size; + final int expected; + const InvalidCacheSizeException(Uri uri, this.size, this.expected) : super( uri, - 'Invalid cache length | Length: $length, expected $expected (Diff: ${expected - length})', + 'Invalid cache size | Length: $size, expected $expected (Difference: ${expected - size})', ); -} -class CacheStreamDisposedException extends StateError { - final Uri uri; - CacheStreamDisposedException(this.uri) - : super('HttpCacheStream disposed | $uri'); + static void validate( + final Uri url, + final int size, + final int expected, + ) { + if (size == expected) return; + + if (expected == 0 && size == -1) { + //Accept non-existent cache as valid if expected length is 0 + return; + } + + throw InvalidCacheSizeException(url, size, expected); + } } diff --git a/lib/src/models/exceptions/state_errors.dart b/lib/src/models/exceptions/state_errors.dart new file mode 100644 index 0000000..f6ac2b7 --- /dev/null +++ b/lib/src/models/exceptions/state_errors.dart @@ -0,0 +1,10 @@ +class CacheStreamDisposedException extends StateError { + final Uri sourceUrl; + CacheStreamDisposedException(this.sourceUrl) + : super('Attempted to use a disposed HttpCacheStream | $sourceUrl'); +} + +class CacheManagerDisposedException extends StateError { + CacheManagerDisposedException() + : super('Attempted to use a disposed HttpCacheManager'); +} diff --git a/lib/src/models/metadata/cache_metadata.dart b/lib/src/models/metadata/cache_metadata.dart index e20355a..91ac2c9 100644 --- a/lib/src/models/metadata/cache_metadata.dart +++ b/lib/src/models/metadata/cache_metadata.dart @@ -1,9 +1,13 @@ -import 'dart:convert'; import 'dart:io'; +import 'package:http_cache_stream/src/etc/extensions/file_extensions.dart'; import 'package:http_cache_stream/src/models/cache_files/cache_files.dart'; import 'package:http_cache_stream/src/models/metadata/cached_response_headers.dart'; +import '../../etc/helpers.dart'; +import '../cache_state/cache_state.dart'; +import '../exceptions/invalid_cache_exceptions.dart'; + /// Metadata for a cached file. class CacheMetadata { /// The files associated with the cache. @@ -14,17 +18,7 @@ class CacheMetadata { /// The cached response headers, if any. final CachedResponseHeaders? headers; - const CacheMetadata._(this.cacheFiles, this.sourceUrl, {this.headers}); - - /// Constructs [CacheMetadata] from [CacheFiles] and sourceUrl. - factory CacheMetadata.construct( - final CacheFiles cacheFiles, final Uri sourceUrl) { - return CacheMetadata._( - cacheFiles, - sourceUrl, - headers: CachedResponseHeaders.fromCacheFiles(cacheFiles), - ); - } + const CacheMetadata(this.cacheFiles, this.sourceUrl, this.headers); ///Attempts to load the metadata file for the given [file]. Returns null if the metadata file does not exist. ///The [file] parameter accepts metadata, partial, or complete cache files. The metadata file is determined by the file extension. @@ -36,39 +30,37 @@ class CacheMetadata { final metadataFile = cacheFiles.metadata; if (!metadataFile.existsSync()) return null; final metadataJson = - jsonDecode(metadataFile.readAsStringSync()) as Map; - final urlValue = metadataJson['Url']; - final sourceUrl = urlValue == null ? null : Uri.tryParse(urlValue); - if (sourceUrl == null) return null; - return CacheMetadata._( + jsonDecodeBytes(metadataFile.readAsBytesSync()) as Map; + return CacheMetadata( cacheFiles, - sourceUrl, - headers: CachedResponseHeaders.fromJson(metadataJson['headers']), + Uri.parse(metadataJson['Url']), + CachedResponseHeaders.fromJson(metadataJson['headers']), ); } - ///Returns the cache download progress as a percentage, rounded to 2 decimal places. Returns null if the source length is unknown. Returns 1.0 only if the cache file exists. - ///The progress reported here may be inaccurate if a download is ongoing. Use [progress] on [HttpCacheStream] to get the most accurate progress. - double? cacheProgress() { + Future cacheState() async { final sourceLength = this.sourceLength; - if (sourceLength == null) return null; + if (sourceLength == null) return const CacheState.zero(); - if (isComplete) return 1.0; + final completeCacheSize = await cacheFile.lengthOrNull(); + if (completeCacheSize != null) { + InvalidCacheSizeException.validate( + sourceUrl, completeCacheSize, sourceLength); + return CacheState.complete(completeCacheSize); + } - final partialCacheSize = partialCacheFile.statSync().size; - if (partialCacheSize <= 0) { - return 0.0; + final partialCacheSize = await partialCacheFile.lengthOrNull(); + if (partialCacheSize == null || partialCacheSize <= 0) { + return const CacheState.zero(); } else if (partialCacheSize == sourceLength) { - partialCacheFile.renameSync( + await partialCacheFile.rename( cacheFile.path); //Rename the partial cache to the complete cache - return 1.0; + return CacheState.complete(partialCacheSize); } else if (partialCacheSize > sourceLength) { - partialCacheFile - .deleteSync(); //Reset the cache if the partial cache is larger than the source - return 0.0; + throw InvalidCacheSizeException( + sourceUrl, partialCacheSize, sourceLength); } else { - return ((partialCacheSize / sourceLength) * 100).floor() / - 100; //Round to 2 decimal places + return CacheState.incomplete(partialCacheSize, sourceLength); } } @@ -87,14 +79,6 @@ class CacheMetadata { }; } - CacheMetadata setHeaders(CachedResponseHeaders? headers) { - return CacheMetadata._( - cacheFiles, //immutable - sourceUrl, //immutable - headers: headers, - ); - } - @override String toString() => 'CacheFileMetadata(' 'Files: $cacheFiles, ' diff --git a/lib/src/models/metadata/cached_response_headers.dart b/lib/src/models/metadata/cached_response_headers.dart index 02356e7..628f0f5 100644 --- a/lib/src/models/metadata/cached_response_headers.dart +++ b/lib/src/models/metadata/cached_response_headers.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -6,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; import 'package:http_cache_stream/src/models/http_range/http_range_response.dart'; +import '../../etc/helpers.dart'; import '../../etc/mime_types.dart'; import '../cache_files/cache_files.dart'; import '../exceptions/http_exceptions.dart'; @@ -146,7 +146,7 @@ class CachedResponseHeaders { for (final header in essentialHeaders) { final value = _headers[header]; - if (value != null) { + if (value != null && value.isNotEmpty) { retainedHeaders[header] = value; } } @@ -203,7 +203,7 @@ class CachedResponseHeaders { static CachedResponseHeaders? fromCacheFiles(final CacheFiles cacheFiles) { try { if (cacheFiles.metadata.existsSync()) { - final json = jsonDecode(cacheFiles.metadata.readAsStringSync()); + final json = jsonDecodeBytes(cacheFiles.metadata.readAsBytesSync()); if (json is Map) { final headersFromJson = CachedResponseHeaders.fromJson(json['headers']); @@ -216,10 +216,29 @@ class CachedResponseHeaders { } } + static Future fromCacheFilesAsync( + final CacheFiles cacheFiles) async { + try { + if (await cacheFiles.metadata.exists()) { + final json = jsonDecodeBytes(await cacheFiles.metadata.readAsBytes()); + if (json is Map) { + final headersFromJson = + CachedResponseHeaders.fromJson(json['headers']); + if (headersFromJson != null) return headersFromJson; + } + } + return CachedResponseHeaders.fromFile( + cacheFiles.complete, await cacheFiles.complete.stat()); + } catch (_) { + return null; + } + } + ///Simulates a [CachedResponseHeaders] object from the given [file]. ///Returns null if the file does not exist or is empty. - static CachedResponseHeaders? fromFile(final File file) { - final fileStat = file.statSync(); + static CachedResponseHeaders? fromFile(final File file, + [FileStat? fileStat]) { + fileStat ??= file.statSync(); final fileSize = fileStat.size; if (fileStat.type != FileSystemEntityType.file || fileSize <= 0) { return null; diff --git a/lib/src/models/stream_response/cache_download_stream_response.dart b/lib/src/models/stream_response/cache_download_stream_response.dart index 8fb0c1f..e681720 100644 --- a/lib/src/models/stream_response/cache_download_stream_response.dart +++ b/lib/src/models/stream_response/cache_download_stream_response.dart @@ -1,7 +1,7 @@ import 'dart:async'; import '../../cache_stream/response_streams/buffered_data_stream.dart'; -import '../config/stream_cache_config.dart'; +import '../cache_config/stream_cache_config.dart'; import '../exceptions/stream_response_exceptions.dart'; import '../metadata/cached_response_headers.dart'; import '../stream_requests/int_range.dart'; diff --git a/lib/src/models/stream_response/combined_cache_stream_response.dart b/lib/src/models/stream_response/combined_cache_stream_response.dart index 7c581e3..50627ce 100644 --- a/lib/src/models/stream_response/combined_cache_stream_response.dart +++ b/lib/src/models/stream_response/combined_cache_stream_response.dart @@ -1,8 +1,8 @@ import 'dart:async'; import '../../cache_stream/response_streams/combined_data_stream.dart'; +import '../cache_config/stream_cache_config.dart'; import '../cache_files/cache_files.dart'; -import '../config/stream_cache_config.dart'; import '../exceptions/stream_response_exceptions.dart'; import '../metadata/cached_response_headers.dart'; import '../stream_requests/int_range.dart'; diff --git a/lib/src/models/stream_response/range_download_stream_response.dart b/lib/src/models/stream_response/range_download_stream_response.dart index 3a7aa87..60a06ef 100644 --- a/lib/src/models/stream_response/range_download_stream_response.dart +++ b/lib/src/models/stream_response/range_download_stream_response.dart @@ -2,7 +2,7 @@ import 'dart:async'; import '../../cache_stream/response_streams/download_stream.dart'; import '../../etc/chunked_bytes_buffer.dart'; -import '../config/stream_cache_config.dart'; +import '../cache_config/stream_cache_config.dart'; import '../stream_requests/int_range.dart'; import 'stream_response.dart'; diff --git a/lib/src/models/stream_response/stream_response.dart b/lib/src/models/stream_response/stream_response.dart index efd563a..2961a6c 100644 --- a/lib/src/models/stream_response/stream_response.dart +++ b/lib/src/models/stream_response/stream_response.dart @@ -1,7 +1,7 @@ import 'dart:async'; +import '../cache_config/stream_cache_config.dart'; import '../cache_files/cache_files.dart'; -import '../config/stream_cache_config.dart'; import '../metadata/cached_response_headers.dart'; import '../stream_requests/int_range.dart'; import 'cache_download_stream_response.dart'; @@ -69,7 +69,7 @@ abstract class StreamResponse { ); } - factory StreamResponse.fromFileAndStream( + factory StreamResponse.combined( final IntRange range, final CachedResponseHeaders headers, final CacheFiles cacheFiles, @@ -77,33 +77,14 @@ abstract class StreamResponse { final int dataStreamPosition, final StreamCacheConfig streamConfig, ) { - final effectiveEnd = range.end ?? headers.sourceLength; - if (effectiveEnd != null && dataStreamPosition >= effectiveEnd) { - //We can fully serve the request from the file - return StreamResponse.fromFile( - range, - cacheFiles, - headers, - ); - } else if (range.start >= dataStreamPosition) { - //We can fully serve the request from the cache stream - return StreamResponse.fromStream( - range, - headers, - dataStream, - dataStreamPosition, - streamConfig, - ); - } else { - return CombinedCacheStreamResponse.construct( - range, - headers, - cacheFiles, - dataStream, - dataStreamPosition, - streamConfig, - ); - } + return CombinedCacheStreamResponse.construct( + range, + headers, + cacheFiles, + dataStream, + dataStreamPosition, + streamConfig, + ); } ///The length of the content in the response. This may be different from the source length. diff --git a/lib/src/request_handler/request_handler.dart b/lib/src/request_handler/request_handler.dart index 3d70b66..938de50 100644 --- a/lib/src/request_handler/request_handler.dart +++ b/lib/src/request_handler/request_handler.dart @@ -3,7 +3,7 @@ import 'dart:io'; import '../cache_stream/http_cache_stream.dart'; import '../etc/mime_types.dart'; -import '../models/config/stream_cache_config.dart'; +import '../models/cache_config/stream_cache_config.dart'; import '../models/exceptions/invalid_cache_exceptions.dart'; import '../models/http_range/http_range.dart'; import '../models/http_range/http_range_request.dart'; @@ -97,6 +97,7 @@ class RequestHandler { contentType = MimeTypes.fromPath(_request.uri.path) ?? MimeTypes.octetStream; } + httpResponse.headers.set(HttpHeaders.contentTypeHeader, contentType); if (rangeRequest == null) { diff --git a/pubspec.yaml b/pubspec.yaml index bb2b842..48f3478 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: http_cache_stream description: "Simultaneously download, cache, and stream remote content. Perfect for media players and any plugin that streams web content." -version: 0.0.6 +version: 0.1.0 homepage: https://github.com/Colton127/http_cache_stream repository: https://github.com/Colton127/http_cache_stream topics: @@ -31,8 +31,11 @@ dependencies: path: ^1.9.0 path_provider: ^2.1.5 synchronized: ^3.4.0 + rxdart: ^0.28.0 dev_dependencies: flutter_lints: ^5.0.0 + flutter_test: + sdk: flutter flutter: diff --git a/test/e2e/dispose_test.dart b/test/e2e/dispose_test.dart new file mode 100644 index 0000000..b1dff8c --- /dev/null +++ b/test/e2e/dispose_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../support/harness.dart'; + +void main() { + late CacheTestHarness h; + + // Uses the default lifecycle config (disposeAfter = 5 min). A regression in the + // dispose()/release() race would block until the test framework timeout rather + // than completing in milliseconds. + setUp(() async { + h = CacheTestHarness(); + await h.setUp(); + }); + + tearDown(() => h.tearDown()); + + test('dispose() completes promptly when a holder releases afterward', + () async { + final stream = h.manager.createStream(h.origin.url('/a.mp3')); // count 1 + stream.retain(); // simulate an in-flight request holding it: count 2 + + final disposing = + stream.dispose(); // count 1: still retained, completes later + expect(stream.isDisposed, isFalse); + + stream.release(); // count 0: must honor the pending dispose now + + await disposing.timeout(const Duration(seconds: 5)); + expect(stream.isDisposed, isTrue); + }); + + test('a retain after dispose resurrects the stream', () async { + final stream = h.manager.createStream(h.origin.url('/b.mp3')); // count 1 + stream.retain(); // count 2 + + stream.dispose(); // count 1: pending dispose + stream.retain(); // count 2: a new holder clears the pending dispose + + stream.release(); // count 1 + stream.release(); // count 0: lifecycle, not disposed + expect(stream.isDisposed, isFalse); + + await stream.dispose(force: true); + expect(stream.isDisposed, isTrue); + }); +} diff --git a/test/e2e/e2e_headers_test.dart b/test/e2e/e2e_headers_test.dart new file mode 100644 index 0000000..eb6a6ec --- /dev/null +++ b/test/e2e/e2e_headers_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_cache_stream/src/etc/mime_types.dart'; + +import '../support/harness.dart'; +import '../support/payload.dart'; + +void main() { + late CacheTestHarness h; + + setUp(() async { + h = CacheTestHarness(); + await h.setUp(payload: Payload.generate(256 * 1024)); + }); + + tearDown(() => h.tearDown()); + + // These assert the headers the cache server sets (RequestHandler._setHeaders), + // which are identical whether the body is served from an in-flight download or + // a completed file. They fetch lazily (no pre-download) so the body streams + // back as it is fetched. + + test('a full response carries content-type, length and accept-ranges', + () async { + final cacheUrl = h.manager.getCacheUrl(h.origin.url('/media/clip.mp3')); + final res = await h.fetch(cacheUrl); + expect(res.statusCode, 200); + expect(res.header('content-type'), 'audio/mpeg'); + expect(int.parse(res.header('content-length')!), h.origin.payload.length); + expect(res.header('accept-ranges'), 'bytes'); + }); + + test('a range response carries content-range and the correct length', + () async { + final total = h.origin.payload.length; + final cacheUrl = h.manager.getCacheUrl(h.origin.url('/media/clip.mp3')); + final res = await h.fetch(cacheUrl, range: 'bytes=10-109'); + expect(res.statusCode, 206); + expect(res.header('content-range'), 'bytes 10-109/$total'); + expect(int.parse(res.header('content-length')!), 100); + }); + + test('an out-of-range request returns 416', () async { + // Pre-cache so the source length is known and the range can be rejected. + final source = h.origin.url('/media/clip.mp3'); + final stream = h.manager.createStream(source); + await stream.download(); + final total = h.origin.payload.length; + + final res = await h.fetch(h.manager.getCacheUrl(source), + range: 'bytes=${total + 10}-${total + 20}'); + expect(res.statusCode, 416); + + await stream.dispose(); + }); + + test('HEAD returns headers with an empty body', () async { + final cacheUrl = h.manager.getCacheUrl(h.origin.url('/media/clip.mp3')); + final res = await h.fetch(cacheUrl, method: 'HEAD'); + expect(res.body, isEmpty); + expect(int.parse(res.header('content-length')!), h.origin.payload.length); + }); + + test('content-type falls back to the path when the origin omits it', + () async { + h.origin.contentType = null; + final cacheUrl = h.manager.getCacheUrl(h.origin.url('/media/clip.mp3')); + final res = await h.fetch(cacheUrl); + expect(res.header('content-type'), MimeTypes.fromPath('clip.mp3')); + expect(res.header('content-type'), isNot(MimeTypes.octetStream)); + }); +} diff --git a/test/e2e/e2e_integrity_test.dart b/test/e2e/e2e_integrity_test.dart new file mode 100644 index 0000000..65b8781 --- /dev/null +++ b/test/e2e/e2e_integrity_test.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../support/harness.dart'; +import '../support/payload.dart'; + +void main() { + late CacheTestHarness h; + + setUp(() async { + h = CacheTestHarness(); + await h.setUp(payload: Payload.generate(512 * 1024)); + }); + + tearDown(() => h.tearDown()); + + test('full download produces a byte-identical cache file', () async { + final source = h.origin.url('/media/file.mp3'); + final stream = h.manager.createStream(source); + final file = await stream.download(); + + final bytes = await file.readAsBytes(); + expect(bytes.length, h.origin.payload.length); + expect(Payload.hash(bytes), h.payloadHash); + + await stream.dispose(); + }); + + test('streamed read through the cache server matches the origin', () async { + final source = h.origin.url('/media/file.mp3'); + final cacheUrl = h.manager.getCacheUrl(source); + + final res = await h.fetch(cacheUrl); + expect(res.statusCode, 200); + expect(Payload.hash(res.body), h.payloadHash); + expect(res.header('accept-ranges'), 'bytes'); + expect(int.parse(res.header('content-length')!), h.origin.payload.length); + }); + + test('range request returns exactly the requested bytes', () async { + final source = h.origin.url('/media/file.mp3'); + final cacheUrl = h.manager.getCacheUrl(source); + + final res = await h.fetch(cacheUrl, range: 'bytes=100-199'); + expect(res.statusCode, 206); + expect(res.body.length, 100); + expect(res.header('content-range'), + 'bytes 100-199/${h.origin.payload.length}'); + + final expected = Uint8List.sublistView(h.origin.payload, 100, 200); + expect(Payload.hash(res.body), Payload.hash(expected)); + }); + + test('open-ended range streams to the end of the file', () async { + final source = h.origin.url('/media/file.mp3'); + final cacheUrl = h.manager.getCacheUrl(source); + final total = h.origin.payload.length; + + final res = await h.fetch(cacheUrl, range: 'bytes=${total - 50}-'); + expect(res.statusCode, 206); + expect(res.body.length, 50); + + final expected = Uint8List.sublistView(h.origin.payload, total - 50, total); + expect(Payload.hash(res.body), Payload.hash(expected)); + }); + + test('concurrent overlapping requests all return correct bytes', () async { + final source = h.origin.url('/media/file.mp3'); + final cacheUrl = h.manager.getCacheUrl(source); + final total = h.origin.payload.length; + + final results = await Future.wait([ + h.fetch(cacheUrl), + h.fetch(cacheUrl, range: 'bytes=0-1023'), + h.fetch(cacheUrl, range: 'bytes=${total ~/ 2}-'), + ]); + + expect(Payload.hash(results[0].body), h.payloadHash); + + expect(results[1].body.length, 1024); + expect(Payload.hash(results[1].body), + Payload.hash(Uint8List.sublistView(h.origin.payload, 0, 1024))); + + expect( + Payload.hash(results[2].body), + Payload.hash( + Uint8List.sublistView(h.origin.payload, total ~/ 2, total))); + }); + + test('resumes from an existing partial cache instead of re-downloading', + () async { + final source = h.origin.url('/media/file.mp3'); + final total = h.origin.payload.length; + const k = 100 * 1024; + + // Pre-seed a valid partial cache + metadata so the next download must + // resume from byte k rather than start over. + final files = h.manager.getCacheFiles(source); + await files.partial.parent.create(recursive: true); + await files.partial + .writeAsBytes(Uint8List.sublistView(h.origin.payload, 0, k)); + await files.metadata.writeAsString(jsonEncode({ + 'Url': source.toString(), + 'headers': { + 'content-length': '$total', + 'accept-ranges': 'bytes', + 'etag': h.origin.etag, + }, + })); + + final stream = h.manager.createStream(source); + final file = await stream.download(); + + expect(Payload.hash(await file.readAsBytes()), h.payloadHash); + expect(h.origin.rangeHeaders.contains('bytes=$k-'), isTrue, + reason: 'download should resume from the partial cache offset'); + expect(h.origin.rangeHeaders.contains('bytes=0-'), isFalse, + reason: 'a full re-download should not occur'); + + await stream.dispose(); + }); + + test('a completed cache is reused without re-hitting the origin', () async { + final source = h.origin.url('/media/file.mp3'); + final stream = h.manager.createStream(source); + + final file1 = await stream.download(); + expect(Payload.hash(await file1.readAsBytes()), h.payloadHash); + final originRequestsAfterDownload = h.origin.requestCount; + + // Accessing the completed cache again must serve from disk, byte-identical, + // without contacting the origin. + final file2 = await stream.download(); + expect(Payload.hash(await file2.readAsBytes()), h.payloadHash); + expect(h.origin.requestCount, originRequestsAfterDownload, + reason: 'a completed cache must not re-contact the origin'); + + await stream.dispose(); + }); +} diff --git a/test/e2e/lifecycle_test.dart b/test/e2e/lifecycle_test.dart new file mode 100644 index 0000000..73b0801 --- /dev/null +++ b/test/e2e/lifecycle_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_cache_stream/http_cache_stream.dart'; + +import '../support/harness.dart'; +import '../support/payload.dart'; + +// pauseAfter == disposeAfter takes the immediate-dispose branch in release(), +// so teardown is fast and independent of readTimeout. +const _fastLifecycle = StreamLifecycleConfig( + pauseAfter: Duration(milliseconds: 150), + disposeAfter: Duration(milliseconds: 150), +); + +void main() { + late CacheTestHarness h; + + setUp(() async { + h = CacheTestHarness(); + await h.setUp( + payload: Payload.generate(128 * 1024), + configBuilder: (cacheDir) => GlobalCacheConfig( + cacheDirectory: cacheDir, + lifecycleConfig: _fastLifecycle, + ), + ); + }); + + tearDown(() => h.tearDown()); + + test('getCacheUrl lazily creates a stream that auto-disposes when idle', + () async { + final source = h.origin.url('/a.mp3'); + final cacheUrl = h.manager.getCacheUrl(source); + + // No stream exists until the first request reaches the server. + expect(h.manager.getExistingStream(source), isNull); + + await h.fetch(cacheUrl); + expect(h.manager.getExistingStream(source), isNotNull); + + // After release + the (tiny) lifecycle window, the stream is disposed and + // removed from the manager. + await Future.delayed(const Duration(milliseconds: 500)); + expect(h.manager.getExistingStream(source), isNull); + }); + + test('retaining again before disposal keeps the stream alive', () async { + final source = h.origin.url('/b.mp3'); + final cacheUrl = h.manager.getCacheUrl(source); + + await h.fetch(cacheUrl); + // Re-acquire (retain) before the lifecycle timer fires; this cancels it. + final stream = h.manager.createStream(source); + expect(stream.isDisposed, isFalse); + + await Future.delayed(const Duration(milliseconds: 400)); + expect(stream.isDisposed, isFalse, + reason: 'a retained stream must survive past disposeAfter'); + + await stream.dispose(); + }); + + test('dispose(force: true) tears the stream down immediately', () async { + final source = h.origin.url('/c.mp3'); + final stream = h.manager.createStream(source); + + await stream.dispose(force: true); + expect(stream.isDisposed, isTrue); + expect(h.manager.getExistingStream(source), isNull); + }); + + test('preCacheUrl downloads the file then disposes the stream', () async { + final source = h.origin.url('/d.mp3'); + final file = await h.manager.preCacheUrl(source); + + expect(file.existsSync(), isTrue); + expect(Payload.hash(await file.readAsBytes()), h.payloadHash); + expect(h.manager.getExistingStream(source), isNull, + reason: 'preCacheUrl disposes its stream when done'); + }); + + test('getCacheUrl is stable for the same source', () { + final source = h.origin.url('/e.mp3'); + expect(h.manager.getCacheUrl(source), h.manager.getCacheUrl(source)); + }); + + test('deleteCache removes cached files once no streams are active', () async { + final source = h.origin.url('/f.mp3'); + final files = h.manager.getCacheFiles(source); + + final stream = h.manager.createStream(source); + await stream.download(); + await stream.dispose(); + expect(files.complete.existsSync(), isTrue); + + await h.manager.deleteCache(); + expect(files.complete.existsSync(), isFalse); + }); +} diff --git a/test/io/buffered_io_sink_test.dart b/test/io/buffered_io_sink_test.dart new file mode 100644 index 0000000..915dc8f --- /dev/null +++ b/test/io/buffered_io_sink_test.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http_cache_stream/src/cache_stream/cache_downloader/buffered_io_sink.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/payload.dart'; + +void main() { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('hcs_sink_'); + }); + + tearDown(() async { + if (dir.existsSync()) await dir.delete(recursive: true); + }); + + File tmp(String name) => File('${dir.path}/$name'); + + test('writes chunks in order with byte-for-byte integrity', () async { + final data = Payload.generate(300 * 1024); + final file = tmp('out.bin'); + final sink = BufferedIOSink(file, 0); + + // Feed the payload in uneven chunks. + var offset = 0; + for (final size in [1, 1024, 65535, 200000, data.length - 266560]) { + sink.add(Uint8List.sublistView(data, offset, offset + size)); + offset += size; + } + expect(offset, data.length); + + await sink.close(); + final written = await file.readAsBytes(); + expect(written.length, data.length); + expect(Payload.hash(written), Payload.hash(data)); + }); + + test('flushedBytes accounts for everything written', () async { + final data = Payload.generate(100 * 1024); + final file = tmp('count.bin'); + final sink = BufferedIOSink(file, 0); + + sink.add(data); + expect(sink.bufferSize, data.length); + await sink.flush(); + expect(sink.bufferSize, 0); + expect(sink.flushedBytes, data.length); + await sink.close(); + }); + + test('append mode resumes from an existing partial file', () async { + final first = Payload.generate(50 * 1024, seed: 1); + final second = Payload.generate(50 * 1024, seed: 2); + final file = tmp('resume.bin'); + await file.writeAsBytes(first); + + final sink = BufferedIOSink(file, first.length); + sink.add(second); + await sink.close(); + + final written = await file.readAsBytes(); + expect(written.length, first.length + second.length); + final expected = Uint8List.fromList([...first, ...second]); + expect(Payload.hash(written), Payload.hash(expected)); + }); + + test('waitForPosition completes once the target is flushed', () async { + final data = Payload.generate(10 * 1024); + final sink = BufferedIOSink(tmp('wait.bin'), 0); + sink.add(data); + final f = sink.waitForPosition(5 * 1024); + await sink.flush(); + await f; // should not throw + await sink.close(); + }); + + test('waitForPosition times out when the target is never reached', () async { + final sink = BufferedIOSink(tmp('timeout.bin'), 0); + sink.add(Payload.generate(1024)); + await sink.flush(); + await expectLater( + sink.waitForPosition(1 << 30, const Duration(milliseconds: 100)), + throwsA(isA()), + ); + await sink.close(); + }); + + test('waitForPosition fails if the sink closes before reaching it', () async { + final sink = BufferedIOSink(tmp('closed.bin'), 0); + sink.add(Payload.generate(1024)); + // Attach the matcher before closing: close() fails the waiter synchronously, + // and an unobserved error future would otherwise crash the test. + final expectation = expectLater( + sink.waitForPosition(10 * 1024 * 1024), throwsA(isA())); + await sink.close(); + await expectation; + }); + + test('adding to a closed sink throws', () async { + final sink = BufferedIOSink(tmp('afterclose.bin'), 0); + await sink.close(); + expect(() => sink.add(Payload.generate(8)), throwsStateError); + expect(sink.isClosed, isTrue); + }); +} diff --git a/test/io/cache_metadata_io_test.dart b/test/io/cache_metadata_io_test.dart new file mode 100644 index 0000000..e1fd2ea --- /dev/null +++ b/test/io/cache_metadata_io_test.dart @@ -0,0 +1,108 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:http_cache_stream/http_cache_stream.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/payload.dart'; + +CachedResponseHeaders headersWithLength(int length) { + return CachedResponseHeaders.fromBaseResponse( + http.Response('', 200, headers: {'content-length': '$length'}), + ); +} + +void main() { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('hcs_meta_'); + }); + + tearDown(() async { + if (dir.existsSync()) await dir.delete(recursive: true); + }); + + CacheFiles filesFor(String name) => + CacheFiles.fromFile(File('${dir.path}/$name')); + + group('CacheMetadata.cacheState', () { + test('a complete file of the expected size reports complete', () async { + final files = filesFor('a.bin'); + await files.complete.writeAsBytes(Payload.generate(100)); + final meta = CacheMetadata( + files, Uri.parse('https://e.com/a.bin'), headersWithLength(100)); + + final state = await meta.cacheState(); + expect(state.isComplete, isTrue); + expect(state.position, 100); + }); + + test('a short partial file reports incomplete', () async { + final files = filesFor('b.bin'); + await files.partial.writeAsBytes(Payload.generate(40)); + final meta = CacheMetadata( + files, Uri.parse('https://e.com/b.bin'), headersWithLength(100)); + + final state = await meta.cacheState(); + expect(state.isComplete, isFalse); + expect(state.position, 40); + expect(state.sourceLength, 100); + }); + + test('an oversized partial file is rejected as invalid', () async { + final files = filesFor('c.bin'); + await files.partial.writeAsBytes(Payload.generate(150)); + final meta = CacheMetadata( + files, Uri.parse('https://e.com/c.bin'), headersWithLength(100)); + + await expectLater( + meta.cacheState(), + throwsA(isA()), + ); + }); + + test('a full-size partial file is promoted to complete', () async { + final files = filesFor('d.bin'); + await files.partial.writeAsBytes(Payload.generate(100)); + final meta = CacheMetadata( + files, Uri.parse('https://e.com/d.bin'), headersWithLength(100)); + + final state = await meta.cacheState(); + expect(state.isComplete, isTrue); + expect(files.complete.existsSync(), isTrue); + expect(files.partial.existsSync(), isFalse); + }); + + test('unknown source length reports zero', () async { + final files = filesFor('e.bin'); + final meta = CacheMetadata(files, Uri.parse('https://e.com/e.bin'), null); + final state = await meta.cacheState(); + expect(state, const CacheState.zero()); + }); + }); + + group('CachedResponseHeaders.fromFile', () { + test('synthesizes length, range support and content-type from the file', + () async { + final file = File('${dir.path}/clip.mp3'); + final bytes = Payload.generate(2048); + await file.writeAsBytes(bytes); + + final headers = CachedResponseHeaders.fromFile(file); + expect(headers, isNotNull); + expect(headers!.sourceLength, bytes.length); + expect(headers.acceptsRangeRequests, isTrue); + expect(headers.contentType, isNotNull); + }); + + test('returns null for a missing or empty file', () async { + expect( + CachedResponseHeaders.fromFile(File('${dir.path}/nope.bin')), isNull); + final empty = File('${dir.path}/empty.bin'); + await empty.writeAsBytes(Uint8List(0)); + expect(CachedResponseHeaders.fromFile(empty), isNull); + }); + }); +} diff --git a/test/support/harness.dart b/test/support/harness.dart new file mode 100644 index 0000000..e51e917 --- /dev/null +++ b/test/support/harness.dart @@ -0,0 +1,83 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http_cache_stream/http_cache_stream.dart'; + +import 'payload.dart'; +import 'test_origin.dart'; + +/// The result of fetching a URL through the local cache server. +class FetchResult { + FetchResult(this.statusCode, this.body, this.headers); + final int statusCode; + final Uint8List body; + final HttpHeaders headers; + + String? header(String name) => headers.value(name); +} + +/// Shared setup/teardown for end-to-end cache tests. +/// +/// Creates a fresh temp cache directory and a [TestOrigin] per test, and inits +/// the [HttpCacheManager] singleton against them. Because the manager is a +/// process-wide singleton with no dedicated reset hook, [tearDown] disposes it +/// (which nulls the static instance) so the next test starts clean. +class CacheTestHarness { + late final Directory cacheDir; + late final TestOrigin origin; + late final HttpCacheManager manager; + + final _client = HttpClient(); + + Future setUp({ + Uint8List? payload, + GlobalCacheConfig Function(Directory cacheDir)? configBuilder, + }) async { + cacheDir = await Directory.systemTemp.createTemp('hcs_test_'); + origin = await TestOrigin.start(payload ?? Payload.generate(256 * 1024)); + if (configBuilder != null) { + manager = await HttpCacheManager.init(config: configBuilder(cacheDir)); + } else { + manager = await HttpCacheManager.init(cacheDir: cacheDir); + } + } + + Future tearDown() async { + _client.close(force: true); + if (HttpCacheManager.isInitialized) { + await HttpCacheManager.instance.dispose(); + } + await origin.close(); + if (cacheDir.existsSync()) { + await cacheDir.delete(recursive: true); + } + } + + /// Fetches [url] (typically a `cacheUrl`) through the local cache server and + /// buffers the full response. + Future fetch( + Uri url, { + String method = 'GET', + String? range, + }) async { + final request = await _client.openUrl(method, url); + // Don't pool the connection. The cache server finishes a response by + // destroying the socket (an abortive close); with keep-alive the client + // can hang waiting to reuse a connection the server already tore down. + // Reading until close also makes completed-cache (instant) responses robust. + request.persistentConnection = false; + if (range != null) { + request.headers.set(HttpHeaders.rangeHeader, range); + } + final response = await request.close(); + final builder = BytesBuilder(copy: false); + // Fail fast with a clear message rather than the 30s framework timeout if a + // response ever stalls. + await response.timeout(const Duration(seconds: 15)).forEach(builder.add); + return FetchResult( + response.statusCode, builder.takeBytes(), response.headers); + } + + /// Convenience: the expected SHA-256 of the full origin payload. + String get payloadHash => Payload.hash(origin.payload); +} diff --git a/test/support/payload.dart b/test/support/payload.dart new file mode 100644 index 0000000..e0ef6f3 --- /dev/null +++ b/test/support/payload.dart @@ -0,0 +1,32 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Deterministic test payload generation + hashing helpers. +/// +/// Tests assert that the bytes served/cached by the package are byte-for-byte +/// identical to what the origin produced. To make that assertion meaningful and +/// reproducible, payloads are generated from a seeded PRNG and compared by +/// SHA-256, never by re-reading the source of truth from the same code path. +class Payload { + /// Generates [length] deterministic bytes from [seed]. + /// + /// Uses a simple xorshift-style PRNG so the output is stable across platforms + /// and Dart versions (unlike `dart:math` Random, which is not guaranteed to be + /// reproducible across SDK versions). + static Uint8List generate(int length, {int seed = 0x9E3779B1}) { + final bytes = Uint8List(length); + var x = (seed == 0 ? 0x1234567 : seed) & 0xFFFFFFFF; + for (var i = 0; i < length; i++) { + // xorshift32 + x ^= (x << 13) & 0xFFFFFFFF; + x ^= x >> 17; + x ^= (x << 5) & 0xFFFFFFFF; + bytes[i] = x & 0xFF; + } + return bytes; + } + + /// SHA-256 of [bytes], as a lowercase hex string. + static String hash(List bytes) => sha256.convert(bytes).toString(); +} diff --git a/test/support/test_origin.dart b/test/support/test_origin.dart new file mode 100644 index 0000000..00e1797 --- /dev/null +++ b/test/support/test_origin.dart @@ -0,0 +1,187 @@ +import 'dart:io'; +import 'dart:typed_data'; + +/// A configurable local origin HTTP server for end-to-end tests. +/// +/// Serves a fixed in-memory [payload] over loopback, honoring `Range` requests +/// (RFC 7233, single ranges) and exposing knobs to simulate the header +/// combinations and failure modes the cache must handle. Behavior is controlled +/// by mutable public fields so individual tests can tweak a single aspect. +class TestOrigin { + TestOrigin._(this._server, this.payload); + + final HttpServer _server; + + /// The bytes this origin serves for a `200`/`206` response. + final Uint8List payload; + + // ---- Behavior knobs (mutate per-test) ---- + + /// When false, `Range` requests are ignored and the full `200` body is sent. + bool supportRanges = true; + + /// Value for the `Content-Type` header. Null omits the header. + String? contentType = 'audio/mpeg'; + + /// Value for the `ETag` header. Null omits it. Mutable to simulate a source + /// change between requests. + String? etag = '"v1"'; + + /// Value for the `Last-Modified` header. Null omits it. + DateTime? lastModified = DateTime.utc(2024, 1, 1, 0, 0, 0); + + /// Value for the `Cache-Control` header. Null omits it. + String? cacheControl; + + /// When set, every response uses this status code and an empty body (used to + /// simulate `404`/`500`/`416`). + int? forcedStatusCode; + + /// When set, a `200` response declares this `Content-Length` instead of the + /// real payload length (used to simulate a lying server). + int? lyingContentLength; + + /// When set, the server writes only this many bytes of the body and then + /// destroys the socket, simulating a mid-stream connection drop. Applies once + /// then resets to null so a retry can succeed. + int? dropAfterBytes; + + // ---- Observability ---- + + int requestCount = 0; + String? lastMethod; + String? lastRangeHeader; + final List rangeHeaders = []; + + // Reported as `localhost` (not the bound `127.0.0.1`) so the source host + // differs from the cache server's loopback host; otherwise the package treats + // the source URL as an already-encoded cache URL. `localhost` still resolves + // to loopback where the origin is listening. + Uri get baseUri => Uri(scheme: 'http', host: 'localhost', port: _server.port); + + /// A source URL on this origin for the given [path] (e.g. `/media/file.mp3`). + Uri url(String path) => baseUri.replace(path: path); + + static Future start(Uint8List payload) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final origin = TestOrigin._(server, payload); + origin._listen(); + return origin; + } + + void _listen() { + _server.listen((request) { + _handle(request).catchError((_) {/* client closed; ignore */}); + }); + } + + Future _handle(HttpRequest request) async { + requestCount++; + lastMethod = request.method; + final rangeHeader = request.headers.value(HttpHeaders.rangeHeader); + lastRangeHeader = rangeHeader; + rangeHeaders.add(rangeHeader); + + final response = request.response; + + if (forcedStatusCode != null) { + response.statusCode = forcedStatusCode!; + await response.close(); + return; + } + + _setCommonHeaders(response); + + // Resolve the requested byte range. + final total = payload.length; + int start = 0; + int endEx = total; // exclusive + final isRange = supportRanges && rangeHeader != null; + + if (isRange) { + final parsed = _parseRange(rangeHeader, total); + if (parsed == null) { + response.statusCode = HttpStatus.requestedRangeNotSatisfiable; + response.headers.set(HttpHeaders.contentRangeHeader, 'bytes */$total'); + await response.close(); + return; + } + start = parsed[0]; + endEx = parsed[1]; + response.statusCode = HttpStatus.partialContent; + response.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes $start-${endEx - 1}/$total', + ); + } else { + response.statusCode = HttpStatus.ok; + } + + final bodyLength = endEx - start; + response.headers.contentLength = lyingContentLength ?? bodyLength; + + if (request.method == 'HEAD') { + await response.close(); + return; + } + + final body = Uint8List.sublistView(payload, start, endEx); + + final drop = dropAfterBytes; + if (drop != null && drop < body.length) { + dropAfterBytes = null; // one-shot + final socket = await response.detachSocket(writeHeaders: true); + socket.add(Uint8List.sublistView(body, 0, drop)); + await socket.flush(); + socket.destroy(); + return; + } + + response.add(body); + await response.close(); + } + + void _setCommonHeaders(HttpResponse response) { + response.headers.clear(); + if (supportRanges) { + response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + } + if (contentType != null) { + response.headers.set(HttpHeaders.contentTypeHeader, contentType!); + } + if (etag != null) { + response.headers.set(HttpHeaders.etagHeader, etag!); + } + if (lastModified != null) { + response.headers + .set(HttpHeaders.lastModifiedHeader, HttpDate.format(lastModified!)); + } + if (cacheControl != null) { + response.headers.set(HttpHeaders.cacheControlHeader, cacheControl!); + } + } + + /// Parses a single `bytes=start-end` range header into `[start, endExclusive]`, + /// or null if unsatisfiable. Supports open-ended `bytes=start-`. + List? _parseRange(String header, int total) { + const prefix = 'bytes='; + if (!header.startsWith(prefix)) return null; + final value = header.substring(prefix.length).trim(); + final parts = value.split('-'); + if (parts.length != 2) return null; + final start = int.tryParse(parts[0]); + if (start == null || start < 0 || start >= total) return null; + int endInclusive; + if (parts[1].isEmpty) { + endInclusive = total - 1; + } else { + final parsed = int.tryParse(parts[1]); + if (parsed == null) return null; + endInclusive = parsed >= total ? total - 1 : parsed; + } + if (endInclusive < start) return null; + return [start, endInclusive + 1]; + } + + Future close() => _server.close(force: true); +} diff --git a/test/unit/cache_files_test.dart b/test/unit/cache_files_test.dart new file mode 100644 index 0000000..21d0691 --- /dev/null +++ b/test/unit/cache_files_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:http_cache_stream/http_cache_stream.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CacheFiles path derivation', () { + test('derives partial and metadata paths from a complete file', () { + final files = CacheFiles.fromFile(File('/tmp/cache/song.mp3')); + expect(files.complete.path, '/tmp/cache/song.mp3'); + expect(files.partial.path, '/tmp/cache/song.mp3.part'); + expect(files.metadata.path, '/tmp/cache/song.mp3.metadata'); + }); + + test('normalizes a partial file back to the complete file', () { + final files = CacheFiles.fromFile(File('/tmp/cache/song.mp3.part')); + expect(files.complete.path, '/tmp/cache/song.mp3'); + expect(files.partial.path, '/tmp/cache/song.mp3.part'); + }); + + test('normalizes a metadata file back to the complete file', () { + final files = CacheFiles.fromFile(File('/tmp/cache/song.mp3.metadata')); + expect(files.complete.path, '/tmp/cache/song.mp3'); + expect(files.metadata.path, '/tmp/cache/song.mp3.metadata'); + }); + }); + + group('CacheFileType', () { + test('classifies files by extension', () { + expect(CacheFileType.isPartial(File('a.mp3.part')), isTrue); + expect(CacheFileType.isMetadata(File('a.mp3.metadata')), isTrue); + expect(CacheFileType.isComplete(File('a.mp3')), isTrue); + expect(CacheFileType.parse(File('a.mp3.part')), CacheFileType.partial); + }); + }); + + group('CacheFiles.delete', () { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('hcs_files_'); + }); + + tearDown(() async { + if (dir.existsSync()) await dir.delete(recursive: true); + }); + + test('deletes all files', () async { + final files = CacheFiles.fromFile(File('${dir.path}/x.bin')); + await files.complete.writeAsString('c'); + await files.partial.writeAsString('p'); + await files.metadata.writeAsString('m'); + + final deleted = await files.delete(); + expect(deleted, isTrue); + expect(files.complete.existsSync(), isFalse); + expect(files.partial.existsSync(), isFalse); + expect(files.metadata.existsSync(), isFalse); + }); + + test('partialOnly keeps a completed cache', () async { + final files = CacheFiles.fromFile(File('${dir.path}/y.bin')); + await files.complete.writeAsString('c'); + await files.partial.writeAsString('p'); + + final deleted = await files.delete(partialOnly: true); + expect(deleted, isFalse); // complete exists, so nothing removed + expect(files.complete.existsSync(), isTrue); + expect(files.partial.existsSync(), isTrue); + }); + }); +} diff --git a/test/unit/cache_state_test.dart b/test/unit/cache_state_test.dart new file mode 100644 index 0000000..028225b --- /dev/null +++ b/test/unit/cache_state_test.dart @@ -0,0 +1,72 @@ +import 'package:http_cache_stream/http_cache_stream.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CacheState.zero', () { + test('has no position, length or progress', () { + const s = CacheState.zero(); + expect(s.position, 0); + expect(s.sourceLength, isNull); + expect(s.isComplete, isFalse); + expect(s.progress, isNull); + expect(s.remainingBytes, isNull); + }); + }); + + group('CacheState.incomplete', () { + test('reports fractional progress and remaining bytes', () { + const s = CacheState.incomplete(50, 100); + expect(s.progress, closeTo(0.5, 1e-9)); + expect(s.remainingBytes, 50); + expect(s.isComplete, isFalse); + }); + + test('progress is capped below 1.0 until finalized', () { + const s = CacheState.incomplete(100, 100); + expect(s.progress, lessThan(1.0)); + expect(s.progress, closeTo(0.99, 1e-9)); + expect(s.isComplete, isFalse); + }); + + test('unknown source length means null progress and remaining', () { + const s = CacheState.incomplete(50, null); + expect(s.progress, isNull); + expect(s.remainingBytes, isNull); + }); + + test('zero source length means null progress', () { + const s = CacheState.incomplete(0, 0); + expect(s.progress, isNull); + }); + }); + + group('CacheState.complete', () { + test('is fully complete with progress 1.0', () { + const s = CacheState.complete(100); + expect(s.isComplete, isTrue); + expect(s.progress, 1.0); + expect(s.position, 100); + expect(s.remainingBytes, 0); + }); + }); + + group('equality', () { + test('incomplete states compare by position and length', () { + expect(const CacheState.incomplete(10, 100), + const CacheState.incomplete(10, 100)); + expect(const CacheState.incomplete(10, 100), + isNot(const CacheState.incomplete(20, 100))); + }); + + test('complete states compare by source length', () { + expect(const CacheState.complete(100), const CacheState.complete(100)); + expect(const CacheState.complete(100), + isNot(const CacheState.complete(200))); + }); + + test('a complete and incomplete state are never equal', () { + expect(const CacheState.complete(100), + isNot(const CacheState.incomplete(100, 100))); + }); + }); +} diff --git a/test/unit/cached_response_headers_test.dart b/test/unit/cached_response_headers_test.dart new file mode 100644 index 0000000..22fa1a1 --- /dev/null +++ b/test/unit/cached_response_headers_test.dart @@ -0,0 +1,203 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:http_cache_stream/http_cache_stream.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Builds [CachedResponseHeaders] from a synthetic HTTP response. Header keys +/// must be lowercase to match the `HttpHeaders.*` constants the package looks up. +CachedResponseHeaders headers(Map map, {int status = 200}) { + return CachedResponseHeaders.fromBaseResponse( + http.Response('', status, headers: map)); +} + +void main() { + group('essential header extraction', () { + test('content-length, content-type, accept-ranges, etag, last-modified', + () { + final lm = DateTime.utc(2024, 1, 1, 12); + final h = headers({ + 'content-length': '500', + 'content-type': 'audio/mpeg', + 'accept-ranges': 'bytes', + 'etag': '"abc"', + 'last-modified': HttpDate.format(lm), + }); + + expect(h.contentLength, 500); + expect(h.sourceLength, 500); + expect(h.contentType?.mimeType, 'audio/mpeg'); + expect(h.acceptsRangeRequests, isTrue); + expect(h.canResumeDownload(), isTrue); + expect(h.eTag, '"abc"'); + expect(h.lastModified, lm); + }); + + test('content-length of 0 is treated as unknown', () { + final h = headers({'content-length': '0'}); + expect(h.contentLength, isNull); + expect(h.sourceLength, isNull); + }); + + test('missing accept-ranges means range requests unsupported', () { + final h = headers({'content-length': '10'}); + expect(h.acceptsRangeRequests, isFalse); + expect(h.canResumeDownload(), isFalse); + }); + }); + + group('compressed / chunked responses have unknown source length', () { + test('gzip', () { + final h = headers({'content-length': '500', 'content-encoding': 'gzip'}); + expect(h.isCompressedOrChunked, isTrue); + expect(h.sourceLength, isNull); + expect(h.canResumeDownload(), isFalse); + }); + + test('chunked', () { + final h = headers({'transfer-encoding': 'chunked'}); + expect(h.isCompressedOrChunked, isTrue); + expect(h.sourceLength, isNull); + }); + }); + + group('content-range responses', () { + test('source length is taken from the content-range total', () { + final h = headers( + {'content-range': 'bytes 0-99/500', 'content-length': '100'}, + status: HttpStatus.partialContent, + ); + expect(h.sourceLength, 500); + expect(h.acceptsRangeRequests, isTrue); + // The content-range header itself is stripped from the stored headers. + expect(h.get(HttpHeaders.contentRangeHeader), isNull); + }); + }); + + group('setSourceLength', () { + test('sets length + accept-ranges and strips encoding/range headers', () { + final original = headers({ + 'content-encoding': 'gzip', + 'transfer-encoding': 'chunked', + 'content-range': 'bytes 0-99/*', + }); + final updated = original.setSourceLength(1234); + + expect(updated.sourceLength, 1234); + expect(updated.acceptsRangeRequests, isTrue); + expect(updated.get(HttpHeaders.contentEncodingHeader), isNull); + expect(updated.get(HttpHeaders.transferEncodingHeader), isNull); + expect(updated.get(HttpHeaders.contentRangeHeader), isNull); + }); + }); + + group('essentialHeaders', () { + test('retains only whitelisted headers', () { + final h = headers({ + 'content-length': '10', + 'content-type': 'text/plain', + 'etag': '"e"', + 'x-custom': 'should-be-dropped', + 'server': 'nginx', + }); + final essential = h.essentialHeaders(); + expect(essential.get('content-length'), '10'); + expect(essential.get('content-type'), 'text/plain'); + expect(essential.get('etag'), '"e"'); + expect(essential.get('x-custom'), isNull); + expect(essential.get('server'), isNull); + }); + }); + + group('revalidation / expiry', () { + test('expires in the future means no revalidation needed', () { + final future = DateTime.now().toUtc().add(const Duration(hours: 1)); + final h = headers({'expires': HttpDate.format(future)}); + expect(h.cacheExpirationDateTime, isNotNull); + expect(h.shouldRevalidate(), isFalse); + }); + + test('expires in the past means revalidate', () { + final past = DateTime.now().toUtc().subtract(const Duration(hours: 1)); + final h = headers({'expires': HttpDate.format(past)}); + expect(h.shouldRevalidate(), isTrue); + }); + + test('cache-control max-age combined with date', () { + final date = DateTime.now().toUtc(); + final h = headers({ + 'date': HttpDate.format(date), + 'cache-control': 'max-age=3600', + }); + final expiry = h.cacheExpirationDateTime; + expect(expiry, isNotNull); + expect(expiry!.isAfter(date), isTrue); + expect(h.shouldRevalidate(), isFalse); + }); + + test('no expiry information means revalidate', () { + final h = headers({'content-length': '10'}); + expect(h.cacheExpirationDateTime, isNull); + expect(h.shouldRevalidate(), isTrue); + }); + }); + + group('validateCacheResponse', () { + test('matching etags validate', () { + final a = headers({'etag': '"v1"', 'content-length': '10'}); + final b = headers({'etag': '"v1"', 'content-length': '999'}); + expect(CachedResponseHeaders.validateCacheResponse(a, b), isTrue); + }); + + test('mismatched etags invalidate', () { + final a = headers({'etag': '"v1"'}); + final b = headers({'etag': '"v2"'}); + expect(CachedResponseHeaders.validateCacheResponse(a, b), isFalse); + }); + + test('a newer last-modified invalidates', () { + final older = DateTime.utc(2024, 1, 1); + final newer = DateTime.utc(2024, 6, 1); + final a = headers({'last-modified': HttpDate.format(older)}); + final b = headers({'last-modified': HttpDate.format(newer)}); + expect(CachedResponseHeaders.validateCacheResponse(a, b), isFalse); + }); + + test('equal source length validates when no etag/last-modified', () { + final a = headers({'content-length': '500'}); + final b = headers({'content-length': '500'}); + expect(CachedResponseHeaders.validateCacheResponse(a, b), isTrue); + }); + }); + + group('date parsing', () { + test('invalid date header parses to null', () { + final h = headers({'last-modified': 'not-a-date'}); + expect(h.lastModified, isNull); + }); + }); + + group('json round-trip', () { + test('toJson then fromJson preserves header values', () { + final original = headers({ + 'content-length': '500', + 'content-type': 'audio/mpeg', + 'etag': '"v1"', + }); + final restored = CachedResponseHeaders.fromJson(original.toJson()); + expect(restored, isNotNull); + expect(restored!.sourceLength, 500); + expect(restored.contentType?.mimeType, 'audio/mpeg'); + expect(restored.eTag, '"v1"'); + }); + + test('fromJson joins list-valued headers', () { + final restored = CachedResponseHeaders.fromJson({ + 'content-length': '12', + 'set-cookie': ['a=1', 'b=2'], + }); + expect(restored, isNotNull); + expect(restored!.get('set-cookie'), 'a=1, b=2'); + }); + }); +} diff --git a/test/unit/future_runner_test.dart b/test/unit/future_runner_test.dart new file mode 100644 index 0000000..6b4bab6 --- /dev/null +++ b/test/unit/future_runner_test.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:http_cache_stream/src/etc/future_runner.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('deduplicates concurrent calls and runs the factory once', () async { + final runner = FutureRunner(); + final gate = Completer(); + var calls = 0; + + Future factory() async { + calls++; + await gate.future; + return 42; + } + + final a = runner.run(factory); + final b = runner.run(factory); + + expect(runner.isRunning, isTrue); + expect(identical(a, b), isTrue); + + gate.complete(); + expect(await a, 42); + expect(await b, 42); + expect(calls, 1); + }); + + test('clears the in-flight future after success so it can run again', + () async { + final runner = FutureRunner(); + expect(await runner.run(() async => 1), 1); + expect(runner.isRunning, isFalse); + expect(await runner.run(() async => 2), 2); + }); + + test('clears the in-flight future after an error', () async { + final runner = FutureRunner(); + await expectLater( + runner.run(() async => throw StateError('boom')), + throwsStateError, + ); + expect(runner.isRunning, isFalse); + // Recovers and can run again. + expect(await runner.run(() async => 7), 7); + }); +} diff --git a/test/unit/http_range_test.dart b/test/unit/http_range_test.dart new file mode 100644 index 0000000..1e96ef5 --- /dev/null +++ b/test/unit/http_range_test.dart @@ -0,0 +1,98 @@ +import 'package:http/http.dart' as http; +import 'package:http_cache_stream/http_cache_stream.dart'; +import 'package:flutter_test/flutter_test.dart'; + +HttpRangeResponse? parseContentRange(String? value) { + final headers = value == null ? {} : {'content-range': value}; + return HttpRangeResponse.parse(http.Response('', 206, headers: headers)); +} + +void main() { + group('HttpRangeResponse.parse', () { + test('parses start, end and total', () { + final r = parseContentRange('bytes 0-99/500'); + expect(r, isNotNull); + expect(r!.start, 0); + expect(r.end, 99); + expect(r.sourceLength, 500); + }); + + test('unknown total (*) yields null source length', () { + final r = parseContentRange('bytes 0-99/*'); + expect(r, isNotNull); + expect(r!.sourceLength, isNull); + }); + + test('unsatisfied-range form (*/500) is not supported', () { + expect(parseContentRange('bytes */500'), isNull); + }); + + test('malformed values return null', () { + expect(parseContentRange('bytes abc'), isNull); + expect(parseContentRange('0-99/500'), isNull); // missing prefix + expect(parseContentRange(null), isNull); // missing header + }); + }); + + group('HttpRangeResponse formatting', () { + test('header round-trips with a known total', () { + expect( + HttpRangeResponse(0, 99, sourceLength: 500).header, 'bytes 0-99/500'); + }); + + test('header uses * for unknown total', () { + expect(HttpRangeResponse(0, 99).header, 'bytes 0-99/*'); + }); + + test('inclusive factory converts an exclusive end', () { + final r = HttpRangeResponse.inclusive(0, 100, 500); + expect(r.end, 99); + expect(r.contentLength, 100); + }); + }); + + group('HttpRangeRequest', () { + test('header for a closed range', () { + expect(HttpRangeRequest(0, 99).header, 'bytes=0-99'); + }); + + test('header for an open-ended range', () { + expect(HttpRangeRequest(100, null).header, 'bytes=100-'); + }); + + test('inclusive factory converts an exclusive end', () { + expect(HttpRangeRequest.inclusive(0, 100).header, 'bytes=0-99'); + }); + + test('start greater than end throws', () { + expect(() => HttpRangeRequest(5, 3), throwsRangeError); + }); + + test('negative start throws', () { + expect(() => HttpRangeRequest(-1, 10), throwsRangeError); + }); + + test('content length covers the inclusive span', () { + expect(HttpRangeRequest(0, 99).contentLength, 100); + }); + }); + + group('HttpRange.isEqual', () { + test('equal when start/end/total match', () { + expect( + HttpRange.isEqual( + HttpRangeRequest(0, 99), + HttpRangeResponse(0, 99, sourceLength: 500), + ), + isTrue, + ); + }); + + test('not equal when start differs', () { + expect( + HttpRange.isEqual(HttpRangeRequest(0, 99), HttpRangeResponse(1, 99)), + isFalse, + ); + }); + }); +} diff --git a/test/unit/int_range_test.dart b/test/unit/int_range_test.dart new file mode 100644 index 0000000..a412da7 --- /dev/null +++ b/test/unit/int_range_test.dart @@ -0,0 +1,61 @@ +import 'package:http_cache_stream/http_cache_stream.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('IntRange.validate', () { + test('null start/end yields the full range', () { + final r = IntRange.validate(null, null, null); + expect(r.isFull, isTrue); + expect(r.start, 0); + expect(r.end, isNull); + }); + + test('start 0 with null end yields the full range', () { + expect(IntRange.validate(0, null, null).isFull, isTrue); + }); + + test('a bounded range is preserved', () { + final r = IntRange.validate(0, 100, 500); + expect(r.start, 0); + expect(r.end, 100); + expect(r.isFull, isFalse); + }); + + test('start greater than end throws', () { + expect(() => IntRange.validate(5, 3, null), throwsRangeError); + }); + + test('negative start throws', () { + expect(() => IntRange.validate(-1, null, null), throwsRangeError); + }); + + test('start beyond max throws', () { + expect(() => IntRange.validate(10, null, 5), throwsRangeError); + }); + + test('end beyond max throws', () { + expect(() => IntRange.validate(0, 600, 500), throwsRangeError); + }); + }); + + group('IntRange.compareTo (backs sorted request queueing)', () { + test('orders by start then end', () { + final list = [ + const IntRange(10, 20), + const IntRange(0, 50), + const IntRange(0, 10), + ]..sort(); + expect(list, [ + const IntRange(0, 10), + const IntRange(0, 50), + const IntRange(10, 20), + ]); + }); + + test('an open-ended range sorts after a closed one with the same start', + () { + expect(const IntRange(0, null).compareTo(const IntRange(0, 100)), 1); + expect(const IntRange(0, 100).compareTo(const IntRange(0, null)), -1); + }); + }); +} diff --git a/test/unit/retain_counter_test.dart b/test/unit/retain_counter_test.dart new file mode 100644 index 0000000..80a3add --- /dev/null +++ b/test/unit/retain_counter_test.dart @@ -0,0 +1,41 @@ +import 'package:http_cache_stream/src/etc/counters/retain_counter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('starts retained with a count of 1', () { + final c = RetainCounter(); + expect(c.count, 1); + expect(c.isRetained, isTrue); + }); + + test('retain and release are symmetric', () { + final c = RetainCounter(); + c.retain(); + c.retain(); + expect(c.count, 3); + c.release(); + expect(c.count, 2); + c.release(); + c.release(); + expect(c.count, 0); + expect(c.isRetained, isFalse); + }); + + test('release does not go below zero', () { + final c = RetainCounter(); + c.release(); // 0 + c.release(); // still 0 + expect(c.count, 0); + expect(c.isRetained, isFalse); + }); + + test('force release drops straight to zero', () { + final c = RetainCounter(); + c.retain(); + c.retain(); + expect(c.count, 3); + c.release(force: true); + expect(c.count, 0); + expect(c.isRetained, isFalse); + }); +} diff --git a/test/unit/url_codec_test.dart b/test/unit/url_codec_test.dart new file mode 100644 index 0000000..d178705 --- /dev/null +++ b/test/unit/url_codec_test.dart @@ -0,0 +1,71 @@ +import 'package:http_cache_stream/src/cache_server/local_cache_server.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late LocalCacheServer server; + + setUp(() async { + server = await LocalCacheServer.init(port: 0); + // Attach a listener so the underlying keep-alive stream has a subscriber; + // without it, close() blocks on the buffered done event during teardown. + // No requests are made in these tests, so the callback is never invoked. + server.start((_) => throw UnimplementedError()); + }); + + tearDown(() async { + await server.close(force: true); + }); + + void expectRoundTrip(Uri source) { + final encoded = server.encodeSourceUrl(source); + expect(server.validateCacheUrl(encoded), isTrue, + reason: 'encoded URL should validate: $encoded'); + final decoded = server.decodeSourceUrl(encoded); + expect(decoded.toString(), source.toString()); + } + + group('encode/decode round-trip', () { + test('https with default port', () { + expectRoundTrip(Uri.parse('https://example.com/media/file.mp3')); + }); + + test('http with a non-default port preserves the port', () { + final source = Uri.parse('http://example.com:8080/a/b/c.ts'); + final encoded = server.encodeSourceUrl(source); + // The non-default port is carried in the host path segment. + expect(encoded.pathSegments[1], 'example.com:8080'); + expectRoundTrip(source); + }); + + test('query string is preserved', () { + expectRoundTrip( + Uri.parse('https://cdn.example.com/v.m3u8?token=abc&x=1')); + }); + + test('multi-segment path is preserved', () { + expectRoundTrip(Uri.parse('https://h.example.com/a/b/c/d/e.mp4')); + }); + }); + + group('validation', () { + test('re-encoding an already-encoded URL is a no-op', () { + final source = Uri.parse('https://example.com/file.mp3'); + final encoded = server.encodeSourceUrl(source); + expect(server.encodeSourceUrl(encoded), encoded); + }); + + test('a foreign URL does not validate as a cache URL', () { + expect( + server + .validateCacheUrl(Uri.parse('http://127.0.0.1:9/not/a/cache/url')), + isFalse, + ); + }); + + test('decoding a non-cache URI returns null', () { + // Too few path segments to encode a source scheme + host. + expect( + server.decodeSourceUrl(server.serverUri.replace(path: '/x')), isNull); + }); + }); +}