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);
+ });
+ });
+}