From 9af70b812ad7a1ba88e4afd41875de051f996129 Mon Sep 17 00:00:00 2001 From: Colton Date: Wed, 15 Apr 2026 19:23:51 -0400 Subject: [PATCH 01/11] Remove generated flutter plugin dependency file from repository --- .flutter-plugins-dependencies | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .flutter-plugins-dependencies 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 From da33acf8d6969cd35719671229ee61b51acb504b Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 16 Apr 2026 17:49:47 -0400 Subject: [PATCH 02/11] dev release 1 --- CHANGELOG.md | 4 ++ lib/http_cache_stream.dart | 15 +++---- .../cache_downloader/cache_downloader.dart | 16 ++++---- .../download_response_listener.dart | 2 +- .../cache_downloader/downloader.dart | 2 +- lib/src/cache_stream/http_cache_stream.dart | 7 ++-- .../buffered_data_stream.dart | 2 +- .../combined_data_stream.dart | 2 +- .../response_streams/download_stream.dart | 2 +- .../cache_config.dart | 12 ++++++ .../global_cache_config.dart | 8 ++-- .../stream_cache_config.dart | 17 +++++--- .../cache_file_resolver.dart | 0 .../exceptions/invalid_cache_exceptions.dart | 39 ++++++++++++------- lib/src/models/exceptions/state_errors.dart | 5 +++ .../cache_download_stream_response.dart | 2 +- .../combined_cache_stream_response.dart | 2 +- .../range_download_stream_response.dart | 2 +- .../stream_response/stream_response.dart | 2 +- lib/src/request_handler/request_handler.dart | 2 +- 20 files changed, 91 insertions(+), 52 deletions(-) rename lib/src/models/{config => cache_config}/cache_config.dart (91%) rename lib/src/models/{config => cache_config}/global_cache_config.dart (93%) rename lib/src/models/{config => cache_config}/stream_cache_config.dart (92%) rename lib/src/models/{config => cache_files}/cache_file_resolver.dart (100%) create mode 100644 lib/src/models/exceptions/state_errors.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa89a3..01f8787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.0.7 + +* Renamed `InvalidCacheLengthException` to `InvalidCacheSizeException` and exposed expected/actual size. + ## 0.0.6 * Add `onStreamCreated` callback to `HttpCacheManager`. diff --git a/lib/http_cache_stream.dart b/lib/http_cache_stream.dart index 0eeac64..ab184ba 100644 --- a/lib/http_cache_stream.dart +++ b/lib/http_cache_stream.dart @@ -16,19 +16,20 @@ 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_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_files/cache_file_resolver.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_stream/cache_downloader/cache_downloader.dart b/lib/src/cache_stream/cache_downloader/cache_downloader.dart index 05839bd..e2b3475 100644 --- a/lib/src/cache_stream/cache_downloader/cache_downloader.dart +++ b/lib/src/cache_stream/cache_downloader/cache_downloader.dart @@ -2,8 +2,8 @@ import 'dart:async'; import '../../etc/extensions/file_extensions.dart'; import '../../etc/extensions/list_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'; @@ -116,17 +116,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, - ); } } finally { if (!_completer.isCompleted) { 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..492f0e7 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 { diff --git a/lib/src/cache_stream/cache_downloader/downloader.dart b/lib/src/cache_stream/cache_downloader/downloader.dart index d7fa3ce..c0afe05 100644 --- a/lib/src/cache_stream/cache_downloader/downloader.dart +++ b/lib/src/cache_stream/cache_downloader/downloader.dart @@ -4,7 +4,7 @@ import 'package:http_cache_stream/src/cache_stream/response_streams/download_str 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 '../../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/cache_stream/http_cache_stream.dart b/lib/src/cache_stream/http_cache_stream.dart index cbeb9be..72abee4 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -3,16 +3,16 @@ 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:synchronized/synchronized.dart'; -import '../etc/callback_helpers.dart'; import '../etc/extensions/list_extensions.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'; @@ -242,8 +242,7 @@ class HttpCacheStream { } _updateProgressStream(1.0); downloadCompleter.complete(completedCacheFile); - fireUserCallback( - () => config.onCacheComplete(this, completedCacheFile)); + config.handleCacheCompletion(this, completedCacheFile); }, onHeaders: (responseHeaders) { _setCachedResponseHeaders(responseHeaders); 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..5489ae8 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'; 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/models/config/cache_config.dart b/lib/src/models/cache_config/cache_config.dart similarity index 91% rename from lib/src/models/config/cache_config.dart rename to lib/src/models/cache_config/cache_config.dart index 6e7d4d2..af143ea 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,11 @@ abstract interface class CacheConfiguration { bool get saveAllHeaders; set saveAllHeaders(bool value); + /// 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 +110,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 93% rename from lib/src/models/config/global_cache_config.dart rename to lib/src/models/cache_config/global_cache_config.dart index c7dfefe..40fc710 100644 --- a/lib/src/models/config/global_cache_config.dart +++ b/lib/src/models/cache_config/global_cache_config.dart @@ -1,12 +1,12 @@ 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'; /// A configuration class for [HttpCacheManager]. /// @@ -113,8 +113,8 @@ class GlobalCacheConfig implements CacheConfiguration { /// 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. /// diff --git a/lib/src/models/config/stream_cache_config.dart b/lib/src/models/cache_config/stream_cache_config.dart similarity index 92% rename from lib/src/models/config/stream_cache_config.dart rename to lib/src/models/cache_config/stream_cache_config.dart index 8b71eb6..c8d4c4f 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/callback_helpers.dart'; + /// Cache configuration for a single [HttpCacheStream]. /// /// Values set here override the global values set in [GlobalCacheConfig]. @@ -144,9 +146,8 @@ 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 + CacheCompleteCallback? onCacheDone; /// Returns an immutable map of all custom request headers. Map combinedRequestHeaders() { @@ -169,9 +170,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( 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/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..a28cdb3 --- /dev/null +++ b/lib/src/models/exceptions/state_errors.dart @@ -0,0 +1,5 @@ +class CacheStreamDisposedException extends StateError { + final Uri sourceUrl; + CacheStreamDisposedException(this.sourceUrl) + : super('Attempted to use a disposed HttpCacheStream | $sourceUrl'); +} 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..6eeb8d4 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'; diff --git a/lib/src/request_handler/request_handler.dart b/lib/src/request_handler/request_handler.dart index 3d70b66..77a4c2f 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'; From d914c8821e3ff486e82192717fa0445f1280ff92 Mon Sep 17 00:00:00 2001 From: Colton Date: Sun, 3 May 2026 19:57:30 -0400 Subject: [PATCH 03/11] pre-removal of HttpCacheServer --- CHANGELOG.md | 55 +++ example/lib/examples/hls_video.dart | 21 +- lib/http_cache_stream.dart | 1 + lib/src/cache_manager/http_cache_manager.dart | 133 +++--- lib/src/cache_server/http_cache_server.dart | 117 ++--- lib/src/cache_server/local_cache_server.dart | 74 ++- .../cache_downloader/cache_downloader.dart | 69 +-- .../cache_downloader/downloader.dart | 20 +- lib/src/cache_stream/http_cache_stream.dart | 424 ++++++++++-------- lib/src/etc/{ => counters}/pause_counter.dart | 0 lib/src/etc/counters/retain_counter.dart | 16 + lib/src/etc/extensions/uri_extensions.dart | 24 +- lib/src/etc/future_runner.dart | 19 + lib/src/models/cache_config/cache_config.dart | 7 +- .../cache_config/global_cache_config.dart | 15 +- .../cache_config/stream_cache_config.dart | 18 +- .../cache_config/stream_lifecycle_config.dart | 13 + lib/src/models/exceptions/state_errors.dart | 12 +- lib/src/models/metadata/cache_metadata.dart | 41 +- .../metadata/cached_response_headers.dart | 70 ++- lib/src/request_handler/request_handler.dart | 33 +- 21 files changed, 696 insertions(+), 486 deletions(-) rename lib/src/etc/{ => counters}/pause_counter.dart (100%) create mode 100644 lib/src/etc/counters/retain_counter.dart create mode 100644 lib/src/etc/future_runner.dart create mode 100644 lib/src/models/cache_config/stream_lifecycle_config.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 01f8787..d719f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,58 @@ +## 0.1.0 + +* Added `StreamLifecycleConfig` to cache configuration — controls pause and dispose + delays for inactive streams. + +* Added `release()` method to `HttpCacheStream`. Once released, the stream pauses + its ongoing download after `StreamLifecycleConfig.pauseDelay` and is fully disposed + after `StreamLifecycleConfig.disposeDelay`. Calling `retain()` before disposal + cancels the timers and resumes the download. + The `dispose()` method retains its original behaviour (immediate disposal, + bypassing lifecycle configuration). + +* `HttpCacheServer` is now the recommended approach for any scenario involving + multiple URLs from the same origin (HLS/DASH, playlists, CDN-hosted assets). + `HttpCacheManager.createServer` returns an existing server by default when called + with the same origin, so a single server instance can be shared application-wide. + +--- + +BREAKING: `HttpCacheServer` changes: + +* `HttpCacheServer.source` renamed to `HttpCacheServer.origin`. The field has always + held only the origin (scheme+host+port), and the new name reflects that precisely. + URLs with the same origin (e.g. `https://cdn.example.com/1.mp3` and + `https://cdn.example.com/2.mp3`) are both handled by the same server. + +* `HttpCacheServer.dispose()` renamed to `HttpCacheServer.close()`. + +* `HttpCacheServer.isDisposed` renamed to `HttpCacheServer.isClosed`. + +* `HttpCacheManager.createServer` parameter renamed from `source` to `origin`. + `createServer` now returns an existing server for the same origin by default + (`returnExisting: true`). Pass `returnExisting: false` to force a new server. + +* `HttpCacheManager.getExistingServer` parameter renamed from `sourceUrl` to `origin`. + +--- + +BREAKING: `HttpCacheManager.createServer` lifecycle changes: + +* Removed `autoDisposeDelay` parameter. Configure stream lifecycle via + `StreamLifecycleConfig` on the `StreamCacheConfig` passed to `createServer` instead: + + ```dart + // Before + await manager.createServer(source, autoDisposeDelay: Duration(seconds: 30)); + + // After + final config = manager.createStreamConfig(); + config.lifecycleConfig = StreamLifecycleConfig( + pauseDelay: Duration(seconds: 10), + disposeDelay: Duration(seconds: 30), + ); + await manager.createServer(Uri.parse('https://cdn.example.com'), config: config); + ## 0.0.7 * Renamed `InvalidCacheLengthException` to `InvalidCacheSizeException` and exposed expected/actual size. diff --git a/example/lib/examples/hls_video.dart b/example/lib/examples/hls_video.dart index 09b7136..f3ebbe4 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,17 +23,9 @@ 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 cacheServer = await HttpCacheManager.instance.createServer(sourceUrl); + if (!mounted) return; + final cacheUrl = cacheServer.getCacheUrl(sourceUrl); print('Playing from: $cacheUrl'); final controller = _controller = VideoPlayerController.networkUrl(cacheUrl); @@ -47,7 +38,6 @@ class _VideoPlayerExampleState extends State { @override void dispose() { - _cacheServer?.dispose(); _controller?.dispose(); super.dispose(); } @@ -58,9 +48,6 @@ class _VideoPlayerExampleState extends State { if (controller == null) { return const CircularProgressIndicator(); } - return AspectRatio( - aspectRatio: controller.value.aspectRatio, - child: VideoPlayer(controller), - ); + return AspectRatio(aspectRatio: controller.value.aspectRatio, child: VideoPlayer(controller)); } } diff --git a/lib/http_cache_stream.dart b/lib/http_cache_stream.dart index ab184ba..f87ecdf 100644 --- a/lib/http_cache_stream.dart +++ b/lib/http_cache_stream.dart @@ -19,6 +19,7 @@ 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_type.dart'; export 'src/models/cache_files/cache_files.dart'; export 'src/models/cache_files/cache_file_resolver.dart'; diff --git a/lib/src/cache_manager/http_cache_manager.dart b/lib/src/cache_manager/http_cache_manager.dart index acdee11..76d7434 100644 --- a/lib/src/cache_manager/http_cache_manager.dart +++ b/lib/src/cache_manager/http_cache_manager.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:http_cache_stream/src/cache_server/local_cache_server.dart'; import 'package:http_cache_stream/src/etc/extensions/uri_extensions.dart'; +import 'package:synchronized/synchronized.dart'; import '../../http_cache_stream.dart'; import '../etc/callback_helpers.dart'; @@ -18,10 +19,16 @@ class HttpCacheManager { /// The global configuration used for all streams managed by this manager. final GlobalCacheConfig config; - final Map _streams = {}; + final Map _streams = {}; final List _cacheServers = []; HttpCacheManager._(this._server, this.config) { _server.start((request) { + final Uri? sourceUrl = _server.decodeSourceUrl(request.uri); + if (sourceUrl != null) { + print('Creating cache stream for decoded source URL: $sourceUrl'); + return request.stream(createStream(sourceUrl)); + } + final cacheStream = getExistingStream(request.uri); if (cacheStream != null) { return request.stream(cacheStream); @@ -32,6 +39,11 @@ class HttpCacheManager { }); } + Uri getCacheUrl(final Uri sourceUrl) { + _checkDisposed(); + 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). HttpCacheStream createStream( @@ -39,12 +51,10 @@ class HttpCacheManager { final File? file, final StreamCacheConfig? config, }) { - assert(!isDisposed, - 'HttpCacheManager is disposed. Cannot create new streams.'); + _checkDisposed(); final existingStream = getExistingStream(sourceUrl); if (existingStream != null && !existingStream.isDisposed) { - existingStream - .retain(); //Retain the stream to prevent it from being disposed + existingStream.retain(); //Retain the stream to prevent it from being disposed return existingStream; } final cacheStream = HttpCacheStream( @@ -56,7 +66,10 @@ class HttpCacheManager { final key = sourceUrl.requestKey; ///Remove when stream is disposed - cacheStream.future.onComplete(() => _streams.remove(key)); + cacheStream.future.onComplete(() { + _streams.remove(key); + print('Cache stream for URL $sourceUrl disposed and removed from manager'); + }); ///Add to the stream map _streams[key] = cacheStream; @@ -64,33 +77,51 @@ class HttpCacheManager { if (_onStreamCreated case final streamCreatedCallback?) { fireUserCallback(() => streamCreatedCallback(cacheStream)); } + + print('Created new cache stream for URL: $sourceUrl'); 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. + /// Creates an [HttpCacheServer] for the given [origin], or returns an existing + /// one if [returnExisting] is true (the default). + /// + /// [origin] is matched by scheme, host, and port only — the path is ignored. + /// For example, `https://cdn.example.com/1.mp3` and + /// `https://cdn.example.com/2.mp3` both resolve to the same server. /// - /// [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. + /// Use [StreamLifecycleConfig] on [config] to control the lifecycle of + /// [HttpCacheStream] instances created by the server. + /// Use [port] to bind to a specific port (random if omitted). Future createServer( - final Uri source, { - final Duration autoDisposeDelay = const Duration(seconds: 15), + final Uri origin, { 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; + final bool returnExisting = true, + final int? port, + }) { + return _createServerLock.synchronized(() async { + _checkDisposed(); + + if (returnExisting) { + final existing = getExistingServer(origin, port: port); + if (existing != null) return existing; + } + + final cacheServer = HttpCacheServer( + origin.originUri, + await LocalCacheServer.init(port: port), + config ?? createStreamConfig(), + createStream, + ); + + if (_disposed) { + cacheServer.close(force: true).ignore(); + throw CacheManagerDisposedException(); + } + + _cacheServers.add(cacheServer); + cacheServer.future.onComplete(() => _cacheServers.remove(cacheServer)); + return cacheServer; + }); } /// Downloads URL to file without creating a stream. @@ -140,8 +171,7 @@ class HttpCacheManager { for (final stream in allStreams) { activeFilePaths.addAll(stream.metadata.cacheFiles.paths); } - await for (final entry - in cacheDir.list(recursive: true, followLinks: false)) { + await for (final entry in cacheDir.list(recursive: true, followLinks: false)) { if (entry is File && !activeFilePaths.contains(entry.path)) { yield entry; } @@ -171,14 +201,12 @@ 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)); + return getExistingStream(sourceUrl)?.metadata ?? CacheMetadata.fromCacheFiles(_resolveCacheFiles(sourceUrl, 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); + return getExistingStream(sourceUrl)?.files ?? _resolveCacheFiles(sourceUrl, cacheFile); } /// Returns the existing [HttpCacheStream] for the given URL, or null if it doesn't exist. @@ -188,13 +216,12 @@ class HttpCacheManager { } ///Returns the existing [HttpCacheServer] for the given source URL, or null if it doesn't exist. - HttpCacheServer? getExistingServer(final Uri source) { + HttpCacheServer? getExistingServer(final Uri origin, {int? port}) { for (final cacheServer in _cacheServers) { - final serverSource = cacheServer.source; - if (serverSource.host == source.host && - serverSource.port == source.port && - serverSource.scheme == source.scheme) { - return cacheServer; + if (cacheServer.origin.originEquals(origin) && !cacheServer.isClosed) { + if (port == null || port == 0 || cacheServer.port == port) { + return cacheServer; + } } } return null; @@ -215,18 +242,14 @@ class HttpCacheManager { HttpCacheManager._instance = null; try { - await _server.close(); + await Future.wait([ + _server.close(force: true), + ..._cacheServers.map((server) => server.close(force: true)), + ..._streams.values.map((stream) => stream.dispose(force: true)), + ]); } finally { - for (final stream in _streams.values.toList()) { - stream.dispose(force: true).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 } @@ -238,6 +261,13 @@ class HttpCacheManager { _onStreamCreated = callback; } + void _checkDisposed() { + if (isDisposed) { + throw CacheManagerDisposedException(); + } + } + + late final _createServerLock = Lock(); HttpCacheStreamCreatedCallback? _onStreamCreated; Directory get cacheDir => config.cacheDirectory; Iterable get allStreams => _streams.values; @@ -250,10 +280,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.'); @@ -264,11 +296,10 @@ class HttpCacheManager { try { final cacheConfig = config ?? GlobalCacheConfig( - cacheDirectory: - cacheDir ?? await GlobalCacheConfig.defaultCacheDirectory(), + cacheDirectory: 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 index c8c5355..1a08009 100644 --- a/lib/src/cache_server/http_cache_server.dart +++ b/lib/src/cache_server/http_cache_server.dart @@ -3,83 +3,88 @@ import 'dart:async'; import 'package:http_cache_stream/src/cache_server/local_cache_server.dart'; import '../../http_cache_stream.dart'; +import '../etc/extensions/uri_extensions.dart'; -/// A server that redirects requests to a source and automatically creates -/// [HttpCacheStream] instances. +/// A local proxy server that creates and manages [HttpCacheStream] instances +/// for a given origin, automatically routing requests and handling lifecycle. +/// +/// `HttpCacheServer` is the recommended approach when working with content that +/// spans multiple URLs sharing the same origin — for example, HLS/DASH manifests +/// that reference many segment files, or a media player managing a playlist of +/// tracks from the same CDN. +/// +/// Create an instance via [HttpCacheManager.createServer]. By default, the manager +/// returns an existing server when one already exists for the same [origin], so a +/// single `HttpCacheServer` can be shared across your application for a given host. +/// +/// Convert any URL on the same origin to a local cache URL using [getCacheUrl]: +/// ```dart +/// final server = await cacheManager.createServer( +/// Uri.parse('https://cdn.example.com'), +/// ); +/// // Both URLs below share the same origin and use the same server. +/// final track1 = server.getCacheUrl(Uri.parse('https://cdn.example.com/1.mp3')); +/// final track2 = server.getCacheUrl(Uri.parse('https://cdn.example.com/2.mp3')); +/// ``` class HttpCacheServer { - /// The base source URI for this server. - final Uri source; + /// The origin URI used to fulfill requests to this server. + /// Example: "https://cdn.example.com". + final Uri origin; 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) { + final HttpCacheStream Function(Uri sourceUrl, {StreamCacheConfig? config}) _createCacheStream; + HttpCacheServer(this.origin, this._localCacheServer, this.config, this._createCacheStream) { _localCacheServer.start((request) { - final sourceUrl = getSourceUrl(request.uri); + final sourceUrl = request.uri.replaceOrigin(origin); 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 - } - }); + return request.stream(cacheStream).whenComplete(cacheStream.release); }); } - /// Returns the cache URL for a given source URL. + /// Returns the local cache URL for the given [sourceUrl]. + /// + /// The [sourceUrl] must share the same [origin] as this server. Uri getCacheUrl(Uri sourceUrl) { - if (sourceUrl.scheme != source.scheme || - sourceUrl.host != source.host || - sourceUrl.port != source.port) { - throw ArgumentError('Invalid source URL: $sourceUrl'); + _checkClosed(); + + if (sourceUrl.originEquals(origin)) { + return _localCacheServer.getCacheUrl(sourceUrl); + } else if (sourceUrl.originEquals(_localCacheServer.serverUri)) { + return sourceUrl; // Already a cache URL, return as is + } else { + throw ArgumentError('Invalid source URL: $sourceUrl does not match server origin: $origin'); } - 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, - ); + /// Closes this [HttpCacheServer] and its underlying local server. + /// If [force] is true, active connections will be closed immediately. + Future close({bool force = false}) { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + return _localCacheServer.close(force: force); } - /// 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(); + void _checkClosed() { + if (isClosed) { + throw CacheServerClosedException(origin); } } - final _completer = Completer(); + final _doneCompleter = Completer(); + + /// The URI of the local cache server. + /// Requests to this URI will be redirected to the `origin` and fulfilled by this server. + Uri get uri => _localCacheServer.serverUri; + + int get port => _localCacheServer.serverUri.port; - /// Whether the server has been disposed. - bool get isDisposed => _completer.isCompleted; + /// Whether the server has been closed. + bool get isClosed => _doneCompleter.isCompleted; - /// A future that completes when the server is disposed. - Future get future => _completer.future; + /// A future that completes when the server is closed. + Future get future => _doneCompleter.future; } diff --git a/lib/src/cache_server/local_cache_server.dart b/lib/src/cache_server/local_cache_server.dart index 89520cd..2bfcab0 100644 --- a/lib/src/cache_server/local_cache_server.dart +++ b/lib/src/cache_server/local_cache_server.dart @@ -1,5 +1,8 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; + +import '../etc/extensions/uri_extensions.dart'; import '../etc/keep_alive_server.dart'; import '../request_handler/request_handler.dart'; @@ -13,24 +16,26 @@ class LocalCacheServer { port: _httpServer.port, ); - static Future init() async { - final httpServer = - await KeepAliveServer.bind(InternetAddress.loopbackIPv4, 0); + static Future init({int? port}) async { + final httpServer = await KeepAliveServer.bind(InternetAddress.loopbackIPv4, port ?? 0); return LocalCacheServer._(httpServer); } - void start( - final Future Function(RequestHandler handler) processRequest) { + void start(final Future Function(RequestHandler handler) processRequest) { _httpServer.listen( (request) async { + if (kDebugMode) { + final sourceUrl = decodeSourceUrl(request.uri); + print( + 'LocalCacheServer received request: ${request.uri} (requestedUri: ${request.requestedUri}) (sourceUrl: $sourceUrl, requestKey: ${request.uri.requestKey})'); + } + final requestHandler = RequestHandler(request); try { await processRequest(requestHandler); + assert(requestHandler.isClosed, 'RequestHandler should be closed after processing the request'); } catch (e) { requestHandler.closeWithError(e); - } finally { - assert(requestHandler.isClosed, - 'RequestHandler should be closed after processing the request'); } }, onError: (_) {}, @@ -38,11 +43,60 @@ class LocalCacheServer { ); } + 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) { + 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}'; + } + + return sourceUrl.replace( + scheme: serverUri.scheme, + host: serverUri.host, + port: serverUri.port, + pathSegments: [sourceUrl.scheme, hostSegment, ...sourceUrl.pathSegments], + ); + } + Future ensureActive() => _httpServer.ensureActive(); Uri getCacheUrl(Uri sourceUrl) { - return sourceUrl.replace( - scheme: serverUri.scheme, host: serverUri.host, port: serverUri.port); + return sourceUrl.replaceOrigin(serverUri); } Future close({bool force = true}) { diff --git a/lib/src/cache_stream/cache_downloader/cache_downloader.dart b/lib/src/cache_stream/cache_downloader/cache_downloader.dart index e2b3475..6c21528 100644 --- a/lib/src/cache_stream/cache_downloader/cache_downloader.dart +++ b/lib/src/cache_stream/cache_downloader/cache_downloader.dart @@ -21,13 +21,15 @@ class CacheDownloader { 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; + CacheDownloader._( + final CacheMetadata cacheMetadata, + this.startPosition, + this._downloader, + this._sink, + ) : _cacheFiles = cacheMetadata.cacheFiles, + _cachedHeaders = startPosition > 0 ? cacheMetadata.headers : null; int _receivedBytes = 0; //Total bytes received from downloader - int _pendingStreamBytes = - 0; //Bytes received but not added to stream yet. These bytes will be added within the current event loop. + int _pendingStreamBytes = 0; //Bytes received but not added to stream yet. These bytes will be added within the current event loop. factory CacheDownloader.construct( final CacheMetadata cacheMetadata, @@ -67,17 +69,14 @@ class CacheDownloader { onHeaders: (cacheHttpHeaders) { if (downloadPosition > 0) { final prevHeaders = _cachedHeaders; - if (prevHeaders != null && - !CachedResponseHeaders.validateCacheResponse( - prevHeaders, cacheHttpHeaders)) { + if (prevHeaders != null && !CachedResponseHeaders.validateCacheResponse(prevHeaders, cacheHttpHeaders)) { throw CacheSourceChangedException(sourceUrl); } } _cachedHeaders = cacheHttpHeaders; onHeaders(cacheHttpHeaders); - onPosition( - downloadPosition); //Emit current position to update progress and process queued requests + onPosition(downloadPosition); //Emit current position to update progress and process queued requests }, onData: (data) { assert(data.isNotEmpty); @@ -86,19 +85,15 @@ class CacheDownloader { _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 + _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. + 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; }, ); @@ -110,8 +105,7 @@ class CacheDownloader { // Post-download — flush remaining data and verify cache integrity try { - await _sink.close( - flushBuffer: true); //Flushes all buffered data and closes the sink + await _sink.close(flushBuffer: true); //Flushes all buffered data and closes the sink } catch (e) { onError(e); } @@ -123,8 +117,7 @@ class CacheDownloader { downloadPosition, ); - final sourceLength = _cachedHeaders?.sourceLength ?? - (_downloader.isDone ? downloadPosition : null); + final sourceLength = _cachedHeaders?.sourceLength ?? (_downloader.isDone ? downloadPosition : null); if (partialCacheLength == sourceLength) { await onComplete(); } @@ -145,13 +138,26 @@ 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); return _completer.future; } + void pause() { + if (_paused) return; + _paused = true; + _downloader.pause(); + } + + void resume() { + if (!_paused) return; + _paused = false; + _downloader.resume(); + } + bool processRequest(final StreamRequest request) { + assert(!_paused); if (request.start > downloadPosition) { return false; } @@ -163,8 +169,7 @@ class CacheDownloader { 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)); + request.complete(() => StreamResponse.fromFile(request.range, _cacheFiles, cachedHeaders)); return true; } if (!_downloader.isActive) { @@ -203,15 +208,13 @@ class CacheDownloader { } ///Processes requests that start before the current download position by combining file and stream data - void _processCombinedRequests( - final StreamRequest request, final CachedResponseHeaders headers) async { + void _processCombinedRequests(final StreamRequest request, final CachedResponseHeaders headers) async { _processingRequests.add(request); if (_isProcessingRequests) return; _isProcessingRequests = true; try { - _downloader - .pause(); //Pause download. The download stream must begin where the file ends. + _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 if (_downloader.isClosed) { @@ -239,6 +242,7 @@ class CacheDownloader { } } + bool _paused = false; bool _isProcessingRequests = false; final List _processingRequests = []; int? get sourceLength => _cachedHeaders?.sourceLength; @@ -247,5 +251,6 @@ class CacheDownloader { int get filePosition => startPosition + _sink.flushedBytes; Uri get sourceUrl => _downloader.sourceUrl; bool get isClosed => _completer.isCompleted; + bool get isPaused => _paused; CachedResponseHeaders? _cachedHeaders; } diff --git a/lib/src/cache_stream/cache_downloader/downloader.dart b/lib/src/cache_stream/cache_downloader/downloader.dart index c0afe05..238dd96 100644 --- a/lib/src/cache_stream/cache_downloader/downloader.dart +++ b/lib/src/cache_stream/cache_downloader/downloader.dart @@ -3,7 +3,7 @@ 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 '../../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'; @@ -25,8 +25,7 @@ class Downloader { Future download({ required final IntRange Function() downloadRange, required final void Function(Object e) onError, - required final void Function(CachedResponseHeaders responseHeaders) - onHeaders, + required final void Function(CachedResponseHeaders responseHeaders) onHeaders, required final void Function(List data) onData, }) async { try { @@ -50,15 +49,11 @@ class Downloader { ); if (_pauseCounter.isPaused) { final readTimeout = streamConfig.readTimeout; - await _pauseCounter.onResume.timeout(readTimeout, - onTimeout: () => - throw ReadTimedOutException(sourceUrl, readTimeout)); + await _pauseCounter.onResume.timeout(readTimeout, onTimeout: () => throw ReadTimedOutException(sourceUrl, readTimeout)); } checkActive(); onHeaders(downloadStream.responseHeaders); - _done = await (_responseListener = DownloadResponseListener( - sourceUrl, downloadStream, onData, streamConfig)) - .done; + _done = await (_responseListener = DownloadResponseListener(sourceUrl, downloadStream, onData, streamConfig)).done; _responseListener = null; } catch (e) { _responseListener = null; @@ -69,8 +64,7 @@ class Downloader { break; } else { onError(e); - await Future.delayed( - const Duration(seconds: 5)); //Wait before retrying + await Future.delayed(const Duration(seconds: 5)); //Wait before retrying } } } @@ -79,12 +73,12 @@ 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)); } _pauseCounter.resume(force: true); //Break any pauses } diff --git a/lib/src/cache_stream/http_cache_stream.dart b/lib/src/cache_stream/http_cache_stream.dart index 72abee4..f349393 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -9,7 +9,9 @@ import 'package:http_cache_stream/src/models/metadata/cached_response_headers.da import 'package:http_cache_stream/src/models/stream_requests/int_range.dart'; import 'package:synchronized/synchronized.dart'; +import '../etc/counters/retain_counter.dart'; import '../etc/extensions/list_extensions.dart'; +import '../etc/future_runner.dart'; import '../models/exceptions/http_exceptions.dart'; import '../models/exceptions/invalid_cache_exceptions.dart'; import '../models/exceptions/state_errors.dart'; @@ -38,27 +40,42 @@ class HttpCacheStream { final List _queuedRequests = []; final _progressController = StreamController.broadcast(); - 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; + final _retainCounter = RetainCounter(); + CacheDownloader? _cacheDownloader; //The active cache downloader, if any. This can be used to cancel the download. + final _downloadFuture = FutureRunner(); + late final _downloadHeadersFuture = FutureRunner(); + final _validateCacheFuture = FutureRunner(); + final _initFuture = FutureRunner(); double? _lastProgress; //The last progress value emitted by the stream Object? _lastError; //The last error emitted by the stream + Timer? _lifeCycleTimer; //Timer for auto-disposing the stream after release late final _writeLock = Lock(); //Lock for modifying cache files - final _disposeCompleter = - Completer(); //Completer for the dispose future - CacheMetadata _cacheMetadata; //The metadata for the cache + final _disposeCompleter = Completer(); //Completer for the dispose future + 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 { + _calculateCacheProgress(); + 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. @@ -67,24 +84,19 @@ class HttpCacheStream { /// of the file respectively. Future request({final int? start, final int? end}) async { if (end != null && start == end) { - return head( - start: start, - end: end); //Requested range is empty, return only headers - } - if (_validateCacheFuture != null) { - await _validateCacheFuture!; + return head(start: start, end: end); //Requested range is empty, return only headers } + await _ensureInit(); _checkDisposed(); - final range = IntRange.validate(start, end, metadata.sourceLength); + + final range = IntRange.validate(start, end, headers?.sourceLength); if (isCached) { - return StreamResponse.fromFile(range, files, metadata.headers!); + return StreamResponse.fromFile(range, files, headers!); } final rangeThreshold = config.rangeRequestSplitThreshold; - if (rangeThreshold != null && - range.start >= rangeThreshold && - (range.start - cachePosition) >= rangeThreshold) { + if (rangeThreshold != null && range.start >= rangeThreshold && (range.start - cachePosition) >= rangeThreshold) { return StreamResponse.fromDownload(sourceUrl, range, config); } @@ -98,14 +110,12 @@ class HttpCacheStream { if (downloader != null && downloader.processRequest(streamRequest)) { return streamRequest.response; //Request was processed immediately } else { - _queuedRequests - .addSorted(streamRequest); //Add request to queue, sorted by range + _queuedRequests.addSorted(streamRequest); //Add request to queue, sorted by range final requestTimeout = config.requestTimeout; final timeoutTimer = Timer(requestTimeout, () { _queuedRequests.remove(streamRequest); - streamRequest - .completeError(StreamRequestTimedOutException(requestTimeout)); + streamRequest.completeError(StreamRequestTimedOutException(requestTimeout)); }); return streamRequest.response.whenComplete(timeoutTimer.cancel); @@ -119,19 +129,15 @@ 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 || !isCached) { + 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, @@ -139,9 +145,7 @@ class HttpCacheStream { requestHeaders: config.combinedRequestHeaders(), ).timeout(config.requestTimeout); - if (CachedResponseHeaders.validateCacheResponse( - currentHeaders, latestHeaders) == - true) { + if (CachedResponseHeaders.validateCacheResponse(currentHeaders, latestHeaders) == true) { _setCachedResponseHeaders(latestHeaders); return true; } else { @@ -154,20 +158,17 @@ class HttpCacheStream { _addError(e, closeRequests: false); rethrow; } finally { - _validateCacheFuture = null; _calculateCacheProgress(); } - }(); + }); } /// 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 ?? + final responseHeaders = _cachedResponseHeaders ?? await CachedResponseHeaders.fromUrl( sourceUrl, httpClient: config.httpClient, @@ -177,8 +178,7 @@ class HttpCacheStream { return headers; }).timeout( config.requestTimeout, - onTimeout: () => - throw StreamRequestTimedOutException(config.requestTimeout), + onTimeout: () => throw StreamRequestTimedOutException(config.requestTimeout), ); final range = IntRange.validate(start, end, responseHeaders.sourceLength); @@ -188,137 +188,143 @@ 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); + Future download() { + return _downloadFuture.run(() async { + await _ensureInit(); + _checkDisposed(); + + File? completedFile; + + bool isComplete() { + if (completedFile != null) return true; + if (_calculateCacheProgress() == 1.0) { + completedFile = cacheFile; + return true; + } + return false; } - 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 + while (isRetained && !isComplete()) { + try { + final downloader = _cacheDownloader = CacheDownloader.construct(metadata, config); + await downloader.download( + onPosition: (position) { + const double maxProgressBeforeCompletion = 0.99; + final int? sourceLength = downloader.sourceLength; + double? progress; + + if (sourceLength != null) { + progress = ((position / sourceLength * 100).round() / 100); + if (progress >= maxProgressBeforeCompletion) { + _updateProgressStream(maxProgressBeforeCompletion); + return; + } } - } - _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); - config.handleCacheCompletion(this, completedCacheFile); - }, - onHeaders: (responseHeaders) { - _setCachedResponseHeaders(responseHeaders); - }, - onError: (e) { - assert(e is! InvalidCacheException); + _updateProgressStream(progress); + while (_queuedRequests.isNotEmpty && downloader.processRequest(_queuedRequests.first)) { + _queuedRequests.removeAt(0); + } + }, + onComplete: () async { + completedFile = await files.partial.rename(files.complete.path); + final cachedHeaders = _cachedResponseHeaders!; + if (cachedHeaders.sourceLength != downloader.downloadPosition || !cachedHeaders.acceptsRangeRequests) { + _setCachedResponseHeaders(cachedHeaders.setSourceLength(downloader.downloadPosition)); + } + _updateProgressStream(1.0); + config.handleCacheCompletion(this, completedFile!); + }, + onHeaders: (responseHeaders) { + _setCachedResponseHeaders(responseHeaders); + }, + onError: (e) { + assert(e is! InvalidCacheException); + _addError(e, closeRequests: true); + }, + ); + } catch (e) { + _cacheDownloader = null; + if (e is InvalidCacheException) { + await _resetCache(e); + } else if (isRetained) { _addError(e, closeRequests: true); - }, - ); - } catch (e) { - _cacheDownloader = null; - if (e is InvalidCacheException) { - await _resetCache(e); - } else if (isRetained) { - _addError(e, closeRequests: true); - await Future.delayed(const Duration(seconds: 5)); + await Future.delayed(const Duration(seconds: 5)); + } } } - } - _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; + + _cacheDownloader = null; + + if (!isComplete()) { + final error = isRetained ? DownloadStoppedException(sourceUrl) : CacheStreamDisposedException(sourceUrl); + _addError(error, closeRequests: true); + throw error; + } + + return completedFile!; + }); + } + + Future downloadHeaders(bool resetCacheIfInvalid) { + return _downloadHeadersFuture.run(() async { + final latestHeaders = await CachedResponseHeaders.fromUrl( + sourceUrl, + httpClient: config.httpClient, + requestHeaders: config.combinedRequestHeaders(), + ).timeout(config.requestTimeout); + + final currentHeaders = _cachedResponseHeaders; + if (currentHeaders == null || CachedResponseHeaders.validateCacheResponse(currentHeaders, latestHeaders)) { + _setCachedResponseHeaders(latestHeaders); + } else if (resetCacheIfInvalid) { + await _resetCache(CacheSourceChangedException(sourceUrl)); + } + 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(); - } - } - }(); + _retainCounter.release(force: force); + _performDispose(); + return _disposeCompleter.future; + } + + void _performDispose() async { + if (isDisposed || isRetained) return; + _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 && progress != 1.0) { + await resetCache(); + } else if (!config.saveMetadata && progress == 1.0 && files.metadata.existsSync()) { + await _writeLock.synchronized(files.metadata.delete); + _cachedResponseHeaders = null; + } + } catch (e) { + _addError(e, closeRequests: false); + } finally { + if (!_disposeCompleter.isCompleted && !isRetained) { + _disposeCompleter.complete(); + if (_queuedRequests.isNotEmpty) { + _addError(CacheStreamDisposedException(sourceUrl), closeRequests: true); + } + _progressController.close().ignore(); } } - - return _disposeCompleter.future; } /// Resets the cache files used by this [HttpCacheStream], interrupting any ongoing download. @@ -327,13 +333,12 @@ class HttpCacheStream { Future _resetCache(final InvalidCacheException exception) { final downloader = _cacheDownloader; if (downloader != null && !downloader.isClosed) { - return downloader.cancel( - exception); //Close the ongoing download, which will rethrow the exception and reset the cache + 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) { + if (progress != null || _cachedResponseHeaders != null) { try { - _cacheMetadata = _cacheMetadata.setHeaders(null); + _cachedResponseHeaders = null; _updateProgressStream(null); if (exception is! CacheResetException) { _addError(exception, closeRequests: false); @@ -343,8 +348,7 @@ class HttpCacheStream { _addError(e, closeRequests: false); } finally { if (_queuedRequests.isNotEmpty && !isDownloading && isRetained) { - download() - .ignore(); //Restart download to fulfill pending requests + download().ignore(); //Restart download to fulfill pending requests } } } @@ -356,12 +360,12 @@ class HttpCacheStream { if (!config.saveAllHeaders) { headers = headers.essentialHeaders(); } - _cacheMetadata = _cacheMetadata.setHeaders(headers); + _cachedResponseHeaders = headers; _writeLock.synchronized(() async { try { await files.metadata.parent.create(recursive: true); - await files.metadata.writeAsString(jsonEncode(_cacheMetadata.toJson())); + await files.metadata.writeAsString(jsonEncode(metadata.toJson())); } catch (e) { _addError(e, closeRequests: false); } @@ -386,20 +390,16 @@ class HttpCacheStream { _progressController.add(progress); } } - if (progress == 1.0 && - _queuedRequests.isNotEmpty && - metadata.headers != null) { + if (progress == 1.0 && _queuedRequests.isNotEmpty && 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)) { + if (!_progressController.isClosed && (isRetained || _queuedRequests.isNotEmpty)) { _progressController.addError(error); } if (closeRequests) { @@ -422,9 +422,10 @@ class HttpCacheStream { Stream get progressStream => _progressController.stream; /// Returns true if the complete cache file and response headers exist. This indicates that the cache is fully available. + /// This method validates the existence of the cache file and metadata bool get isCached { if (progress == 1.0) { - if (metadata.headers != null && cacheFile.existsSync()) { + if (_cachedResponseHeaders != null && cacheFile.existsSync()) { return true; } _calculateCacheProgress(); //Cache file is missing or metadata is incomplete, recalculate progress @@ -436,7 +437,7 @@ class HttpCacheStream { 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. /// @@ -446,8 +447,8 @@ class HttpCacheStream { final downloadPosition = _cacheDownloader?.downloadPosition; if (downloadPosition != null) { return downloadPosition; - } else if (progress != null && metadata.sourceLength != null) { - return (progress! * metadata.sourceLength!).round(); + } else if (progress != null && headers?.sourceLength != null) { + return (progress! * headers!.sourceLength!).round(); } else { return files.cacheFileSize() ?? 0; } @@ -457,13 +458,11 @@ class HttpCacheStream { /// /// 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. /// @@ -474,7 +473,10 @@ class HttpCacheStream { Object? get lastErrorOrNull => _lastError; /// 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]. /// @@ -485,17 +487,45 @@ 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; + _retainCounter.retain(); + _lifeCycleTimer?.cancel(); + _lifeCycleTimer = null; + _cacheDownloader?.resume(); + } + + /// Releases this [HttpCacheStream] instance. + /// + /// Once released, [StreamLifecycleConfig.pauseDelay] determines how long to wait + /// before pausing an ongoing download. After [StreamLifecycleConfig.disposeDelay] + /// the stream will be disposed automatically. If [retain] is called before + /// disposal, the lifecycle timers are cancelled and the download resumes. + void release() { + if (!isRetained) return; + _retainCounter.release(); + _lifeCycleTimer?.cancel(); + + if (!isRetained) { + final lifecycleConfig = config.lifecycleConfig; + + _lifeCycleTimer = Timer(lifecycleConfig.pauseDelay, () { + final remainingDuration = lifecycleConfig.disposeDelay - lifecycleConfig.pauseDelay; + if (remainingDuration > Duration.zero) { + _lifeCycleTimer = Timer(remainingDuration, _performDispose); + _cacheDownloader?.pause(); + } else { + _performDispose(); + } + }); + } } /// Returns a future that completes when this [HttpCacheStream] is disposed. Future get future => _disposeCompleter.future; @override - String toString() => - 'HttpCacheStream{sourceUrl: $sourceUrl, cacheUrl: $cacheUrl, cacheFile: $cacheFile}'; + String toString() => 'HttpCacheStream{sourceUrl: $sourceUrl, cacheUrl: $cacheUrl, cacheFile: $cacheFile}'; } 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/uri_extensions.dart b/lib/src/etc/extensions/uri_extensions.dart index 4387296..ea6bfaa 100644 --- a/lib/src/etc/extensions/uri_extensions.dart +++ b/lib/src/etc/extensions/uri_extensions.dart @@ -1,7 +1,27 @@ 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.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/models/cache_config/cache_config.dart b/lib/src/models/cache_config/cache_config.dart index af143ea..ea4b6b0 100644 --- a/lib/src/models/cache_config/cache_config.dart +++ b/lib/src/models/cache_config/cache_config.dart @@ -84,6 +84,10 @@ 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); @@ -111,5 +115,4 @@ abstract interface class CacheConfiguration { } } -typedef CacheCompleteCallback = void Function( - HttpCacheStream stream, File completedCacheFile); +typedef CacheCompleteCallback = void Function(HttpCacheStream stream, File completedCacheFile); diff --git a/lib/src/models/cache_config/global_cache_config.dart b/lib/src/models/cache_config/global_cache_config.dart index 40fc710..96289a6 100644 --- a/lib/src/models/cache_config/global_cache_config.dart +++ b/lib/src/models/cache_config/global_cache_config.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import '../../cache_stream/http_cache_stream.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, @@ -33,12 +35,9 @@ class GlobalCacheConfig implements CacheConfiguration { }) : httpClient = customHttpClient ?? Client(), requestHeaders = requestHeaders ?? {}, responseHeaders = responseHeaders ?? {}, - _maxBufferSize = - CacheConfiguration.validateMaxBufferSize(maxBufferSize), + _maxBufferSize = CacheConfiguration.validateMaxBufferSize(maxBufferSize), _minChunkSize = CacheConfiguration.validateMinChunkSize(minChunkSize), - _rangeRequestSplitThreshold = - CacheConfiguration.validateRangeRequestSplitThreshold( - rangeRequestSplitThreshold); + _rangeRequestSplitThreshold = CacheConfiguration.validateRangeRequestSplitThreshold(rangeRequestSplitThreshold); /// The directory where the cache files will be stored. final Directory cacheDirectory; @@ -75,8 +74,7 @@ class GlobalCacheConfig implements CacheConfiguration { @override set rangeRequestSplitThreshold(int? value) { - _rangeRequestSplitThreshold = - CacheConfiguration.validateRangeRequestSplitThreshold(value); + _rangeRequestSplitThreshold = CacheConfiguration.validateRangeRequestSplitThreshold(value); } int _minChunkSize; @@ -108,6 +106,9 @@ 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. diff --git a/lib/src/models/cache_config/stream_cache_config.dart b/lib/src/models/cache_config/stream_cache_config.dart index c8d4c4f..fa733bc 100644 --- a/lib/src/models/cache_config/stream_cache_config.dart +++ b/lib/src/models/cache_config/stream_cache_config.dart @@ -51,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; @@ -117,8 +122,7 @@ class StreamCacheConfig implements CacheConfiguration { @override set rangeRequestSplitThreshold(int? value) { _useGlobalRangeRequestSplitThreshold = false; - _rangeRequestSplitThreshold = - CacheConfiguration.validateRangeRequestSplitThreshold(value); + _rangeRequestSplitThreshold = CacheConfiguration.validateRangeRequestSplitThreshold(value); } @override @@ -146,6 +150,11 @@ class StreamCacheConfig implements CacheConfiguration { _saveAllHeaders = value; } + @override + set lifecycleConfig(StreamLifecycleConfig config) { + _lifecycleConfig = config; + } + @override CacheCompleteCallback? onCacheDone; @@ -154,9 +163,7 @@ class StreamCacheConfig implements CacheConfiguration { return _combineHeaders( _global.requestHeaders, _requestHeaders, - defaultHeaders: const { - HttpHeaders.acceptEncodingHeader: 'identity' - }, // Avoid compressed responses + defaultHeaders: const {HttpHeaders.acceptEncodingHeader: 'identity'}, // Avoid compressed responses ); } @@ -199,6 +206,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..bcd4674 --- /dev/null +++ b/lib/src/models/cache_config/stream_lifecycle_config.dart @@ -0,0 +1,13 @@ +/// A configuration class that defines the lifecycle behavior of a `HttpCacheStream` instance. +class StreamLifecycleConfig { + ///The duration after which an inactive stream will pause the ongoing download, if any. + ///A paused connection will remain open for the duration of `readTimeout` to allow for resuming without needing to re-establish the connection. + final Duration pauseDelay; + + ///The duration after which an inactive stream will be disposed, if any. Once disposed, the stream cannot be resumed and must be recreated for new requests. + final Duration disposeDelay; + const StreamLifecycleConfig({ + this.pauseDelay = const Duration(seconds: 10), + this.disposeDelay = const Duration(minutes: 5), + }); +} diff --git a/lib/src/models/exceptions/state_errors.dart b/lib/src/models/exceptions/state_errors.dart index a28cdb3..62af488 100644 --- a/lib/src/models/exceptions/state_errors.dart +++ b/lib/src/models/exceptions/state_errors.dart @@ -1,5 +1,13 @@ class CacheStreamDisposedException extends StateError { final Uri sourceUrl; - CacheStreamDisposedException(this.sourceUrl) - : super('Attempted to use a disposed HttpCacheStream | $sourceUrl'); + CacheStreamDisposedException(this.sourceUrl) : super('Attempted to use a disposed HttpCacheStream | $sourceUrl'); +} + +class CacheManagerDisposedException extends StateError { + CacheManagerDisposedException() : super('Attempted to use a disposed HttpCacheManager'); +} + +class CacheServerClosedException extends StateError { + final Uri origin; + CacheServerClosedException(this.origin) : super('Attempted to use a closed HttpCacheServer | $origin'); } diff --git a/lib/src/models/metadata/cache_metadata.dart b/lib/src/models/metadata/cache_metadata.dart index e20355a..8244504 100644 --- a/lib/src/models/metadata/cache_metadata.dart +++ b/lib/src/models/metadata/cache_metadata.dart @@ -14,17 +14,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. @@ -35,15 +25,11 @@ class CacheMetadata { static CacheMetadata? fromCacheFiles(final CacheFiles cacheFiles) { 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._( + final metadataJson = jsonDecode(metadataFile.readAsStringSync()) as Map; + return CacheMetadata( cacheFiles, - sourceUrl, - headers: CachedResponseHeaders.fromJson(metadataJson['headers']), + Uri.parse(metadataJson['Url']), + CachedResponseHeaders.fromJson(metadataJson['headers']), ); } @@ -59,16 +45,13 @@ class CacheMetadata { if (partialCacheSize <= 0) { return 0.0; } else if (partialCacheSize == sourceLength) { - partialCacheFile.renameSync( - cacheFile.path); //Rename the partial cache to the complete cache + partialCacheFile.renameSync(cacheFile.path); //Rename the partial cache to the complete cache return 1.0; } else if (partialCacheSize > sourceLength) { - partialCacheFile - .deleteSync(); //Reset the cache if the partial cache is larger than the source + partialCacheFile.deleteSync(); //Reset the cache if the partial cache is larger than the source return 0.0; } else { - return ((partialCacheSize / sourceLength) * 100).floor() / - 100; //Round to 2 decimal places + return ((partialCacheSize / sourceLength) * 100).floor() / 100; //Round to 2 decimal places } } @@ -87,14 +70,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..41e9b30 100644 --- a/lib/src/models/metadata/cached_response_headers.dart +++ b/lib/src/models/metadata/cached_response_headers.dart @@ -17,8 +17,7 @@ class CachedResponseHeaders { ///Compares this [CachedResponseHeaders] to the given [next] [CachedResponseHeaders] to determine if the cache is outdated. ///CachedResponseHeaders.fromFile() supports validating against a HEAD request by comparing sourceLength and lastModified. - static bool validateCacheResponse( - final CachedResponseHeaders previous, final CachedResponseHeaders next) { + static bool validateCacheResponse(final CachedResponseHeaders previous, final CachedResponseHeaders next) { if (previous.eTag != null && next.eTag != null) { return previous.eTag == next.eTag; } @@ -26,8 +25,7 @@ class CachedResponseHeaders { final previousLastModified = previous.lastModified; if (previousLastModified != null) { final nextLastModified = next.lastModified; - if (nextLastModified != null && - nextLastModified.isAfter(previousLastModified)) { + if (nextLastModified != null && nextLastModified.isAfter(previousLastModified)) { return false; } } @@ -50,8 +48,7 @@ class CachedResponseHeaders { bool shouldRevalidate() { final expirationDateTime = cacheExpirationDateTime; - return expirationDateTime == null || - DateTime.now().isAfter(expirationDateTime); + return expirationDateTime == null || DateTime.now().isAfter(expirationDateTime); } DateTime? get cacheExpirationDateTime { @@ -74,9 +71,7 @@ class CachedResponseHeaders { ContentType? get contentType { final contentTypeHeader = get(HttpHeaders.contentTypeHeader); - return contentTypeHeader == null - ? null - : ContentType.parse(contentTypeHeader); + return contentTypeHeader == null ? null : ContentType.parse(contentTypeHeader); } String? get eTag => get(HttpHeaders.etagHeader); @@ -94,12 +89,10 @@ class CachedResponseHeaders { /// Returns true if the response is compressed or chunked. This means that the content length != source length, and the source length cannot be determined until the download is complete. bool get isCompressedOrChunked { - return equals(HttpHeaders.contentEncodingHeader, 'gzip') || - equals(HttpHeaders.transferEncodingHeader, 'chunked'); + return equals(HttpHeaders.contentEncodingHeader, 'gzip') || equals(HttpHeaders.transferEncodingHeader, 'chunked'); } - DateTime? get lastModified => - parseHeaderDateTime(HttpHeaders.lastModifiedHeader); + DateTime? get lastModified => parseHeaderDateTime(HttpHeaders.lastModifiedHeader); DateTime? get responseDate => parseHeaderDateTime(HttpHeaders.dateHeader); ///Attempts to parse [DateTime] from the given [httpHeader]. @@ -107,8 +100,7 @@ class CachedResponseHeaders { final value = get(httpHeader); if (value == null || value.isEmpty) return null; try { - return HttpDate.parse( - value); // Try to parse the date (not all servers return a valid date) + return HttpDate.parse(value); // Try to parse the date (not all servers return a valid date) } catch (e) { return null; } @@ -160,16 +152,13 @@ class CachedResponseHeaders { final Map headers = {...response.headers}; if (headers.remove(HttpHeaders.contentRangeHeader) != null) { - headers[HttpHeaders.acceptRangesHeader] = - 'bytes'; // Ensure accept-ranges is set to bytes for range responses. Not all servers do this. + headers[HttpHeaders.acceptRangesHeader] = 'bytes'; // Ensure accept-ranges is set to bytes for range responses. Not all servers do this. - final HttpRangeResponse? rangeResponse = - HttpRangeResponse.parse(response); + final HttpRangeResponse? rangeResponse = HttpRangeResponse.parse(response); if (rangeResponse != null) { final int? rangeSourceLength = rangeResponse.sourceLength; if (rangeSourceLength != null) { - headers[HttpHeaders.contentLengthHeader] = - rangeSourceLength.toString(); + headers[HttpHeaders.contentLengthHeader] = rangeSourceLength.toString(); } else if (!rangeResponse.isFull) { headers.remove(HttpHeaders.contentLengthHeader); } @@ -186,15 +175,10 @@ class CachedResponseHeaders { Map requestHeaders = const {}, }) async { if (!requestHeaders.containsKey(HttpHeaders.acceptEncodingHeader)) { - requestHeaders = { - ...requestHeaders, - HttpHeaders.acceptEncodingHeader: 'identity' - }; + requestHeaders = {...requestHeaders, HttpHeaders.acceptEncodingHeader: 'identity'}; } - final response = await (httpClient?.head(url, headers: requestHeaders) ?? - http.head(url, headers: requestHeaders)); - if (response.statusCode != HttpStatus.ok && - response.statusCode != HttpStatus.partialContent) { + final response = await (httpClient?.head(url, headers: requestHeaders) ?? http.head(url, headers: requestHeaders)); + if (response.statusCode != HttpStatus.ok && response.statusCode != HttpStatus.partialContent) { throw HttpStatusCodeException(url, HttpStatus.ok, response.statusCode); } return CachedResponseHeaders.fromBaseResponse(response); @@ -205,8 +189,7 @@ class CachedResponseHeaders { if (cacheFiles.metadata.existsSync()) { final json = jsonDecode(cacheFiles.metadata.readAsStringSync()); if (json is Map) { - final headersFromJson = - CachedResponseHeaders.fromJson(json['headers']); + final headersFromJson = CachedResponseHeaders.fromJson(json['headers']); if (headersFromJson != null) return headersFromJson; } } @@ -216,10 +199,25 @@ class CachedResponseHeaders { } } + static Future fromCacheFilesAsync(final CacheFiles cacheFiles) async { + try { + if (await cacheFiles.metadata.exists()) { + final json = jsonDecode(await cacheFiles.metadata.readAsString()); + 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; @@ -229,8 +227,7 @@ class CachedResponseHeaders { final headers = { HttpHeaders.contentLengthHeader: fileSize.toString(), HttpHeaders.acceptRangesHeader: 'bytes', - if (contentTypeFromPath != null) - HttpHeaders.contentTypeHeader: contentTypeFromPath, + if (contentTypeFromPath != null) HttpHeaders.contentTypeHeader: contentTypeFromPath, HttpHeaders.lastModifiedHeader: HttpDate.format(fileStat.modified), }; return CachedResponseHeaders._(headers); @@ -251,8 +248,7 @@ class CachedResponseHeaders { return _headers; } - void forEach(void Function(String, String) action) => - _headers.forEach(action); + void forEach(void Function(String, String) action) => _headers.forEach(action); Map get headerMap => {..._headers}; diff --git a/lib/src/request_handler/request_handler.dart b/lib/src/request_handler/request_handler.dart index 77a4c2f..e7b144e 100644 --- a/lib/src/request_handler/request_handler.dart +++ b/lib/src/request_handler/request_handler.dart @@ -19,19 +19,16 @@ class RequestHandler { SocketHandler? _socketHandler; Future stream(final HttpCacheStream cacheStream) async { - final timeoutTimer = Timer(cacheStream.config.requestTimeout, - () => close(HttpStatus.gatewayTimeout)); + final timeoutTimer = Timer(cacheStream.config.requestTimeout, () => close(HttpStatus.gatewayTimeout)); StreamResponse? streamResponse; try { final rangeRequest = HttpRangeRequest.parse(_request); switch (_request.method) { case 'GET': - streamResponse = await cacheStream.request( - start: rangeRequest?.start, end: rangeRequest?.endEx); + streamResponse = await cacheStream.request(start: rangeRequest?.start, end: rangeRequest?.endEx); case 'HEAD': - streamResponse = await cacheStream.head( - start: rangeRequest?.start, end: rangeRequest?.endEx); + streamResponse = await cacheStream.head(start: rangeRequest?.start, end: rangeRequest?.endEx); default: close(HttpStatus.methodNotAllowed); return; @@ -56,17 +53,14 @@ class RequestHandler { timeoutTimer.cancel(); _requestClosed = true; //Response is now being handled via socket - final socketHandler = _socketHandler = SocketHandler( - await _request.response.detachSocket(writeHeaders: true)); - await socketHandler.writeResponse( - streamResponse.stream, cacheStream.config.readTimeout); + final socketHandler = _socketHandler = SocketHandler(await _request.response.detachSocket(writeHeaders: true)); + await socketHandler.writeResponse(streamResponse.stream, cacheStream.config.readTimeout); _socketHandler = null; //Clear the socket handler after done. } catch (e) { closeWithError(e, cacheStream.metadata.headers); } finally { timeoutTimer.cancel(); - streamResponse - ?.cancel(); //Ensure we cancel the stream response to free resources. + streamResponse?.cancel(); //Ensure we cancel the stream response to free resources. streamResponse = null; } } @@ -88,15 +82,11 @@ class RequestHandler { } cacheConfig.combinedResponseHeaders().forEach(httpResponse.headers.set); - String? contentType = - httpResponse.headers.value(HttpHeaders.contentTypeHeader) ?? - cacheHeaders.get(HttpHeaders.contentTypeHeader); - if (contentType == null || - contentType.isEmpty || - contentType == MimeTypes.octetStream) { - contentType = - MimeTypes.fromPath(_request.uri.path) ?? MimeTypes.octetStream; + String? contentType = httpResponse.headers.value(HttpHeaders.contentTypeHeader) ?? cacheHeaders.get(HttpHeaders.contentTypeHeader); + if (contentType == null || contentType.isEmpty || contentType == MimeTypes.octetStream) { + contentType = MimeTypes.fromPath(_request.uri.path) ?? MimeTypes.octetStream; } + httpResponse.headers.set(HttpHeaders.contentTypeHeader, contentType); if (rangeRequest == null) { @@ -131,8 +121,7 @@ class RequestHandler { case RangeError() || HttpRangeException(): statusCode = HttpStatus.requestedRangeNotSatisfiable; if (headers?.sourceLength case final int sourceLength) { - _request.response.headers - .set(HttpHeaders.contentRangeHeader, 'bytes */$sourceLength'); + _request.response.headers.set(HttpHeaders.contentRangeHeader, 'bytes */$sourceLength'); } case TimeoutException(): statusCode = HttpStatus.gatewayTimeout; From f953bbe7e51dd186052d20ae9a0dece1b6b6c17b Mon Sep 17 00:00:00 2001 From: Colton Date: Sat, 20 Jun 2026 00:54:45 -0400 Subject: [PATCH 04/11] bump to 0.1.0 --- CHANGELOG.md | 56 +--- README.md | 78 ++--- example/ios/Flutter/AppFrameworkInfo.plist | 2 - example/ios/Podfile | 2 +- example/ios/Podfile.lock | 37 +-- example/ios/Runner.xcodeproj/project.pbxproj | 48 +-- .../xcshareddata/xcschemes/Runner.xcscheme | 20 ++ example/ios/Runner/AppDelegate.swift | 7 +- example/ios/Runner/Info.plist | 39 ++- example/lib/examples/audio_players.dart | 18 +- example/lib/examples/hls_video.dart | 10 +- example/lib/examples/just_audio.dart | 9 +- example/linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 4 +- example/pubspec.lock | 208 +++++++++---- .../windows/flutter/generated_plugins.cmake | 1 + lib/http_cache_stream.dart | 4 +- lib/src/cache_manager/http_cache_manager.dart | 157 +++------- lib/src/cache_server/http_cache_server.dart | 90 ------ .../keep_alive_server.dart | 0 lib/src/cache_server/local_cache_server.dart | 57 ++-- .../cache_downloader/buffered_io_sink.dart | 74 +++-- .../cache_downloader/cache_downloader.dart | 178 +++++------ .../download_response_listener.dart | 5 +- .../cache_downloader/downloader.dart | 18 +- lib/src/cache_stream/http_cache_stream.dart | 294 ++++++++---------- .../combined_data_stream.dart | 9 - .../response_streams/download_stream.dart | 9 +- lib/src/etc/extensions/file_extensions.dart | 9 + lib/src/etc/extensions/uri_extensions.dart | 5 +- .../{callback_helpers.dart => helpers.dart} | 9 + lib/src/models/cache_config/cache_config.dart | 3 +- .../cache_config/global_cache_config.dart | 21 +- .../cache_config/stream_cache_config.dart | 9 +- .../cache_config/stream_lifecycle_config.dart | 43 ++- lib/src/models/cache_state/cache_state.dart | 110 +++++++ .../models/exceptions/http_exceptions.dart | 4 +- lib/src/models/exceptions/state_errors.dart | 11 +- lib/src/models/metadata/cache_metadata.dart | 39 ++- .../metadata/cached_response_headers.dart | 8 +- .../stream_response/stream_response.dart | 37 +-- lib/src/request_handler/request_handler.dart | 32 +- pubspec.yaml | 3 +- 43 files changed, 921 insertions(+), 857 deletions(-) delete mode 100644 lib/src/cache_server/http_cache_server.dart rename lib/src/{etc => cache_server}/keep_alive_server.dart (100%) rename lib/src/etc/{callback_helpers.dart => helpers.dart} (56%) create mode 100644 lib/src/models/cache_state/cache_state.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index d719f34..508990e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,57 +1,27 @@ ## 0.1.0 -* Added `StreamLifecycleConfig` to cache configuration — controls pause and dispose - delays for inactive streams. +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. -* Added `release()` method to `HttpCacheStream`. Once released, the stream pauses - its ongoing download after `StreamLifecycleConfig.pauseDelay` and is fully disposed - after `StreamLifecycleConfig.disposeDelay`. Calling `retain()` before disposal - cancels the timers and resumes the download. - The `dispose()` method retains its original behaviour (immediate disposal, - bypassing lifecycle configuration). +### Breaking Changes -* `HttpCacheServer` is now the recommended approach for any scenario involving - multiple URLs from the same origin (HLS/DASH, playlists, CDN-hosted assets). - `HttpCacheManager.createServer` returns an existing server by default when called - with the same origin, so a single server instance can be shared application-wide. +- **`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. ---- +### New Features -BREAKING: `HttpCacheServer` changes: +**`HttpCacheManager`** -* `HttpCacheServer.source` renamed to `HttpCacheServer.origin`. The field has always - held only the origin (scheme+host+port), and the new name reflects that precisely. - URLs with the same origin (e.g. `https://cdn.example.com/1.mp3` and - `https://cdn.example.com/2.mp3`) are both handled by the same server. +- 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. -* `HttpCacheServer.dispose()` renamed to `HttpCacheServer.close()`. +**`HttpCacheStream`** -* `HttpCacheServer.isDisposed` renamed to `HttpCacheServer.isClosed`. +- 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. -* `HttpCacheManager.createServer` parameter renamed from `source` to `origin`. - `createServer` now returns an existing server for the same origin by default - (`returnExisting: true`). Pass `returnExisting: false` to force a new server. +### Improvements -* `HttpCacheManager.getExistingServer` parameter renamed from `sourceUrl` to `origin`. - ---- - -BREAKING: `HttpCacheManager.createServer` lifecycle changes: - -* Removed `autoDisposeDelay` parameter. Configure stream lifecycle via - `StreamLifecycleConfig` on the `StreamCacheConfig` passed to `createServer` instead: - - ```dart - // Before - await manager.createServer(source, autoDisposeDelay: Duration(seconds: 30)); - - // After - final config = manager.createStreamConfig(); - config.lifecycleConfig = StreamLifecycleConfig( - pauseDelay: Duration(seconds: 10), - disposeDelay: Duration(seconds: 30), - ); - await manager.createServer(Uri.parse('https://cdn.example.com'), config: config); +- Cache headers are now read using async I/O. ## 0.0.7 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 f3ebbe4..1322be6 100644 --- a/example/lib/examples/hls_video.dart +++ b/example/lib/examples/hls_video.dart @@ -23,10 +23,7 @@ class _VideoPlayerExampleState extends State { void _init() async { final sourceUrl = widget.sourceUrl; - final cacheServer = await HttpCacheManager.instance.createServer(sourceUrl); - if (!mounted) 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(); @@ -48,6 +45,9 @@ class _VideoPlayerExampleState extends State { if (controller == null) { return const CircularProgressIndicator(); } - return AspectRatio(aspectRatio: controller.value.aspectRatio, child: VideoPlayer(controller)); + return AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ); } } 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 f87ecdf..dc907d9 100644 --- a/lib/http_cache_stream.dart +++ b/lib/http_cache_stream.dart @@ -14,15 +14,15 @@ 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/cache_files/cache_file_resolver.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'; diff --git a/lib/src/cache_manager/http_cache_manager.dart b/lib/src/cache_manager/http_cache_manager.dart index 76d7434..cdecbdc 100644 --- a/lib/src/cache_manager/http_cache_manager.dart +++ b/lib/src/cache_manager/http_cache_manager.dart @@ -4,11 +4,10 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:http_cache_stream/src/cache_server/local_cache_server.dart'; import 'package:http_cache_stream/src/etc/extensions/uri_extensions.dart'; -import 'package:synchronized/synchronized.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. /// @@ -20,119 +19,70 @@ class HttpCacheManager { final GlobalCacheConfig config; final Map _streams = {}; - final List _cacheServers = []; + final Map _customCacheFiles = {}; HttpCacheManager._(this._server, this.config) { - _server.start((request) { - final Uri? sourceUrl = _server.decodeSourceUrl(request.uri); - if (sourceUrl != null) { - print('Creating cache stream for decoded source URL: $sourceUrl'); - return request.stream(createStream(sourceUrl)); - } - - final cacheStream = getExistingStream(request.uri); - if (cacheStream != null) { - return request.stream(cacheStream); - } else { - request.close(HttpStatus.serviceUnavailable); - return Future.value(); - } - }); + _server.start(createStream); } - Uri getCacheUrl(final Uri sourceUrl) { + /// 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, }) { _checkDisposed(); - final existingStream = getExistingStream(sourceUrl); + final requestKey = sourceUrl.requestKey; + + final existingStream = _streams[requestKey]; if (existingStream != null && !existingStream.isDisposed) { - existingStream.retain(); //Retain the stream to prevent it from being disposed + existingStream.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; + + ///Add to the stream map + _streams[requestKey] = cacheStream; ///Remove when stream is disposed cacheStream.future.onComplete(() { - _streams.remove(key); - print('Cache stream for URL $sourceUrl disposed and removed from manager'); + _streams.remove(requestKey); }); - ///Add to the stream map - _streams[key] = cacheStream; - if (_onStreamCreated case final streamCreatedCallback?) { fireUserCallback(() => streamCreatedCallback(cacheStream)); } - print('Created new cache stream for URL: $sourceUrl'); return cacheStream; } - /// Creates an [HttpCacheServer] for the given [origin], or returns an existing - /// one if [returnExisting] is true (the default). - /// - /// [origin] is matched by scheme, host, and port only — the path is ignored. - /// For example, `https://cdn.example.com/1.mp3` and - /// `https://cdn.example.com/2.mp3` both resolve to the same server. - /// - /// Use [StreamLifecycleConfig] on [config] to control the lifecycle of - /// [HttpCacheStream] instances created by the server. - /// Use [port] to bind to a specific port (random if omitted). - Future createServer( - final Uri origin, { - final StreamCacheConfig? config, - final bool returnExisting = true, - final int? port, - }) { - return _createServerLock.synchronized(() async { - _checkDisposed(); - - if (returnExisting) { - final existing = getExistingServer(origin, port: port); - if (existing != null) return existing; - } - - final cacheServer = HttpCacheServer( - origin.originUri, - await LocalCacheServer.init(port: port), - config ?? createStreamConfig(), - createStream, - ); - - if (_disposed) { - cacheServer.close(force: true).ignore(); - throw CacheManagerDisposedException(); - } - - _cacheServers.add(cacheServer); - cacheServer.future.onComplete(() => _cacheServers.remove(cacheServer)); - return cacheServer; - }); - } - - /// 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(); @@ -200,36 +150,29 @@ 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 origin, {int? port}) { - for (final cacheServer in _cacheServers) { - if (cacheServer.origin.originEquals(origin) && !cacheServer.isClosed) { - if (port == null || port == 0 || cacheServer.port == port) { - 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. @@ -240,16 +183,16 @@ class HttpCacheManager { if (_disposed) return; _disposed = true; HttpCacheManager._instance = null; + _onStreamCreated = null; try { - await Future.wait([ - _server.close(force: true), - ..._cacheServers.map((server) => server.close(force: true)), - ..._streams.values.map((stream) => stream.dispose(force: true)), - ]); + await _server.close(force: true); } finally { + _customCacheFiles.clear(); + for (final stream in _streams.values.toList()) { + stream.dispose().ignore(); + } _streams.clear(); - _cacheServers.clear(); if (config.customHttpClient == null) { config.httpClient.close(); // Close the default http client only } @@ -258,6 +201,7 @@ class HttpCacheManager { /// Set a callback to be fired when a new [HttpCacheStream] is created. set onStreamCreated(HttpCacheStreamCreatedCallback? callback) { + _checkDisposed(); _onStreamCreated = callback; } @@ -267,7 +211,6 @@ class HttpCacheManager { } } - late final _createServerLock = Lock(); HttpCacheStreamCreatedCallback? _onStreamCreated; Directory get cacheDir => config.cacheDirectory; Iterable get allStreams => _streams.values; 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 1a08009..0000000 --- a/lib/src/cache_server/http_cache_server.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; - -import 'package:http_cache_stream/src/cache_server/local_cache_server.dart'; - -import '../../http_cache_stream.dart'; -import '../etc/extensions/uri_extensions.dart'; - -/// A local proxy server that creates and manages [HttpCacheStream] instances -/// for a given origin, automatically routing requests and handling lifecycle. -/// -/// `HttpCacheServer` is the recommended approach when working with content that -/// spans multiple URLs sharing the same origin — for example, HLS/DASH manifests -/// that reference many segment files, or a media player managing a playlist of -/// tracks from the same CDN. -/// -/// Create an instance via [HttpCacheManager.createServer]. By default, the manager -/// returns an existing server when one already exists for the same [origin], so a -/// single `HttpCacheServer` can be shared across your application for a given host. -/// -/// Convert any URL on the same origin to a local cache URL using [getCacheUrl]: -/// ```dart -/// final server = await cacheManager.createServer( -/// Uri.parse('https://cdn.example.com'), -/// ); -/// // Both URLs below share the same origin and use the same server. -/// final track1 = server.getCacheUrl(Uri.parse('https://cdn.example.com/1.mp3')); -/// final track2 = server.getCacheUrl(Uri.parse('https://cdn.example.com/2.mp3')); -/// ``` -class HttpCacheServer { - /// The origin URI used to fulfill requests to this server. - /// Example: "https://cdn.example.com". - final Uri origin; - - final LocalCacheServer _localCacheServer; - - /// The configuration for each generated stream. - final StreamCacheConfig config; - final HttpCacheStream Function(Uri sourceUrl, {StreamCacheConfig? config}) _createCacheStream; - HttpCacheServer(this.origin, this._localCacheServer, this.config, this._createCacheStream) { - _localCacheServer.start((request) { - final sourceUrl = request.uri.replaceOrigin(origin); - final cacheStream = _createCacheStream(sourceUrl, config: config); - return request.stream(cacheStream).whenComplete(cacheStream.release); - }); - } - - /// Returns the local cache URL for the given [sourceUrl]. - /// - /// The [sourceUrl] must share the same [origin] as this server. - Uri getCacheUrl(Uri sourceUrl) { - _checkClosed(); - - if (sourceUrl.originEquals(origin)) { - return _localCacheServer.getCacheUrl(sourceUrl); - } else if (sourceUrl.originEquals(_localCacheServer.serverUri)) { - return sourceUrl; // Already a cache URL, return as is - } else { - throw ArgumentError('Invalid source URL: $sourceUrl does not match server origin: $origin'); - } - } - - /// Closes this [HttpCacheServer] and its underlying local server. - /// If [force] is true, active connections will be closed immediately. - Future close({bool force = false}) { - if (!_doneCompleter.isCompleted) { - _doneCompleter.complete(); - } - return _localCacheServer.close(force: force); - } - - void _checkClosed() { - if (isClosed) { - throw CacheServerClosedException(origin); - } - } - - final _doneCompleter = Completer(); - - /// The URI of the local cache server. - /// Requests to this URI will be redirected to the `origin` and fulfilled by this server. - Uri get uri => _localCacheServer.serverUri; - - int get port => _localCacheServer.serverUri.port; - - /// Whether the server has been closed. - bool get isClosed => _doneCompleter.isCompleted; - - /// A future that completes when the server is closed. - Future get future => _doneCompleter.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 2bfcab0..6396f24 100644 --- a/lib/src/cache_server/local_cache_server.dart +++ b/lib/src/cache_server/local_cache_server.dart @@ -1,10 +1,9 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; - +import '../../http_cache_stream.dart'; import '../etc/extensions/uri_extensions.dart'; -import '../etc/keep_alive_server.dart'; import '../request_handler/request_handler.dart'; +import 'keep_alive_server.dart'; class LocalCacheServer { final KeepAliveServer _httpServer; @@ -17,25 +16,32 @@ class LocalCacheServer { ); static Future init({int? port}) async { - final httpServer = await KeepAliveServer.bind(InternetAddress.loopbackIPv4, port ?? 0); + final httpServer = + 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 { - if (kDebugMode) { - final sourceUrl = decodeSourceUrl(request.uri); - print( - 'LocalCacheServer received request: ${request.uri} (requestedUri: ${request.requestedUri}) (sourceUrl: $sourceUrl, requestKey: ${request.uri.requestKey})'); - } - + HttpCacheStream? cacheStream; final requestHandler = RequestHandler(request); + try { - await processRequest(requestHandler); - assert(requestHandler.isClosed, 'RequestHandler should be closed after processing the request'); + 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: (_) {}, @@ -73,10 +79,19 @@ class LocalCacheServer { } 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 + } + final defaultPort = switch (sourceUrl.scheme) { 'https' => 443, 'http' => 80, - _ => throw ArgumentError('Unsupported URI scheme: ${sourceUrl.scheme}. Only http and https are supported.'), + _ => throw ArgumentError( + 'Unsupported URI scheme: ${sourceUrl.scheme}. Only http and https are supported.'), }; String hostSegment = sourceUrl.host; @@ -85,20 +100,24 @@ class LocalCacheServer { hostSegment += ':${sourceUrl.port}'; } - return sourceUrl.replace( + 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; } - Future ensureActive() => _httpServer.ensureActive(); - - Uri getCacheUrl(Uri sourceUrl) { - return sourceUrl.replaceOrigin(serverUri); + 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..3beadcd 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,17 @@ +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 +27,65 @@ 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 +98,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 6c21528..31893e3 100644 --- a/lib/src/cache_stream/cache_downloader/cache_downloader.dart +++ b/lib/src/cache_stream/cache_downloader/cache_downloader.dart @@ -1,7 +1,7 @@ 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/exceptions/http_exceptions.dart'; @@ -15,21 +15,23 @@ 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(); + 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, - this.startPosition, + final int startPosition, this._downloader, - this._sink, ) : _cacheFiles = cacheMetadata.cacheFiles, + _position = startPosition, + _sink = BufferedIOSink(cacheMetadata.partialCacheFile, startPosition), _cachedHeaders = startPosition > 0 ? cacheMetadata.headers : null; - int _receivedBytes = 0; //Total bytes received from downloader - int _pendingStreamBytes = 0; //Bytes received but not added to stream yet. These bytes will be added within the current event loop. factory CacheDownloader.construct( final CacheMetadata cacheMetadata, @@ -38,7 +40,7 @@ class CacheDownloader { final partialCacheFile = cacheMetadata.partialCacheFile; int startPosition = 0; - if (cacheMetadata.headers?.canResumeDownload() == true) { + if (cacheMetadata.headers?.canResumeDownload() ?? false) { startPosition = partialCacheFile.lengthSyncOrNull() ?? 0; } @@ -46,7 +48,6 @@ class CacheDownloader { cacheMetadata, startPosition, Downloader(cacheMetadata.sourceUrl, cacheConfig), - BufferedIOSink(partialCacheFile, startPosition), ); } @@ -54,7 +55,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; @@ -67,11 +68,9 @@ 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; @@ -79,22 +78,28 @@ class CacheDownloader { onPosition(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); + _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: cancel); //Resume upstream after flushing + _sink.flush().then( + (_) { + _downloader.resume(); + }, + onError: (e) { + cancel(e); + }, + ); } else if (!_sink.isFlushing) { - _sink.flush().catchError(cancel); + _sink.flush().catchError((e) { + cancel(e); + }); } - - _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; }, ); } on InvalidCacheException { @@ -118,8 +123,8 @@ class CacheDownloader { ); final sourceLength = _cachedHeaders?.sourceLength ?? (_downloader.isDone ? downloadPosition : null); - if (partialCacheLength == sourceLength) { - await onComplete(); + if (sourceLength != null && partialCacheLength == sourceLength) { + await onComplete(sourceLength); } } finally { if (!_completer.isCompleted) { @@ -141,11 +146,12 @@ class CacheDownloader { /// 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; } void pause() { - if (_paused) return; + if (_paused || !_downloader.isActive) return; _paused = true; _downloader.pause(); } @@ -158,99 +164,55 @@ class CacheDownloader { bool processRequest(final StreamRequest request) { assert(!_paused); - if (request.start > downloadPosition) { - return false; - } - final cachedHeaders = _cachedHeaders; - if (cachedHeaders == null) { - return false; //Headers required to process request - } - - 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( + 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, + headers, _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( - request.range, - cachedHeaders, - _cacheFiles, - _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 _paused = false; - 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; bool get isPaused => _paused; - CachedResponseHeaders? _cachedHeaders; } 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 492f0e7..e8e79d9 100644 --- a/lib/src/cache_stream/cache_downloader/download_response_listener.dart +++ b/lib/src/cache_stream/cache_downloader/download_response_listener.dart @@ -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 238dd96..be3f302 100644 --- a/lib/src/cache_stream/cache_downloader/downloader.dart +++ b/lib/src/cache_stream/cache_downloader/downloader.dart @@ -53,10 +53,14 @@ 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; @@ -64,7 +68,7 @@ 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))); } } } @@ -78,15 +82,15 @@ class Downloader { final responseListener = _responseListener; if (responseListener != null) { _responseListener = null; - responseListener.cancel(exception ?? 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 f349393..fe95fae 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:http_cache_stream/src/cache_stream/cache_downloader/cache_downloader.dart'; @@ -7,11 +6,14 @@ import 'package:http_cache_stream/src/models/cache_config/stream_cache_config.da 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 'package:http_cache_stream/src/models/stream_requests/int_range.dart'; +import 'package:rxdart/subjects.dart'; import 'package:synchronized/synchronized.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'; @@ -28,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. @@ -39,17 +41,15 @@ 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. final _downloadFuture = FutureRunner(); late final _downloadHeadersFuture = FutureRunner(); final _validateCacheFuture = FutureRunner(); final _initFuture = FutureRunner(); - double? _lastProgress; //The last progress value emitted by the stream - Object? _lastError; //The last error emitted by the stream Timer? _lifeCycleTimer; //Timer for auto-disposing the stream after release - late final _writeLock = Lock(); //Lock for modifying cache files + late final _fileLock = Lock(); //Lock for modifying cache files final _disposeCompleter = Completer(); //Completer for the dispose future CachedResponseHeaders? _cachedResponseHeaders; //The cached response headers, if any @@ -65,7 +65,7 @@ class HttpCacheStream { } catch (e) { _addError(e, closeRequests: false); } finally { - _calculateCacheProgress(); + await refreshCacheState(); if (config.validateOutdatedCache) { validateCache(force: false, resetInvalid: true).ignore(); } @@ -89,10 +89,14 @@ class HttpCacheStream { await _ensureInit(); _checkDisposed(); - final range = IntRange.validate(start, end, headers?.sourceLength); + final responseHeaders = _cachedResponseHeaders; + final range = IntRange.validate(start, end, responseHeaders?.sourceLength); - if (isCached) { - return StreamResponse.fromFile(range, files, headers!); + if (responseHeaders != null && cacheState.isComplete) { + final verifiedCacheState = await refreshCacheState(); + if (verifiedCacheState.isComplete) { + return StreamResponse.fromFile(range, files, responseHeaders); + } } final rangeThreshold = config.rangeRequestSplitThreshold; @@ -133,18 +137,13 @@ class HttpCacheStream { return _validateCacheFuture.run(() async { await _initFuture(); _checkDisposed(); - if (isDownloading || !isCached) { + 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) { _setCachedResponseHeaders(latestHeaders); return true; @@ -158,7 +157,7 @@ class HttpCacheStream { _addError(e, closeRequests: false); rethrow; } finally { - _calculateCacheProgress(); + await refreshCacheState(); } }); } @@ -168,19 +167,7 @@ class HttpCacheStream { await _ensureInit(); _checkDisposed(); - final responseHeaders = _cachedResponseHeaders ?? - 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); } @@ -193,47 +180,30 @@ class HttpCacheStream { await _ensureInit(); _checkDisposed(); - File? completedFile; - - bool isComplete() { - if (completedFile != null) return true; - if (_calculateCacheProgress() == 1.0) { - completedFile = cacheFile; - return true; + while (true) { + if ((await refreshCacheState()).isComplete) { + return files.complete; + } + if (!isRetained) { + throw DownloadStoppedException(sourceUrl); } - return false; - } - - while (isRetained && !isComplete()) { try { final downloader = _cacheDownloader = CacheDownloader.construct(metadata, config); await downloader.download( onPosition: (position) { - const double maxProgressBeforeCompletion = 0.99; - final int? sourceLength = downloader.sourceLength; - double? progress; - - if (sourceLength != null) { - progress = ((position / sourceLength * 100).round() / 100); - if (progress >= maxProgressBeforeCompletion) { - _updateProgressStream(maxProgressBeforeCompletion); - return; - } - } - - _updateProgressStream(progress); + _updateCacheState(CacheState.incomplete(position, downloader.sourceLength)); while (_queuedRequests.isNotEmpty && downloader.processRequest(_queuedRequests.first)) { _queuedRequests.removeAt(0); } }, - onComplete: () async { - completedFile = await files.partial.rename(files.complete.path); + onComplete: (sourceLength) async { + await _fileLock.synchronized(() => files.partial.rename(files.complete.path)); final cachedHeaders = _cachedResponseHeaders!; - if (cachedHeaders.sourceLength != downloader.downloadPosition || !cachedHeaders.acceptsRangeRequests) { - _setCachedResponseHeaders(cachedHeaders.setSourceLength(downloader.downloadPosition)); + if (cachedHeaders.sourceLength != sourceLength || !cachedHeaders.acceptsRangeRequests) { + _setCachedResponseHeaders(cachedHeaders.setSourceLength(sourceLength)); } - _updateProgressStream(1.0); - config.handleCacheCompletion(this, completedFile!); + _updateCacheState(CacheState.complete(sourceLength)); + config.handleCacheCompletion(this, files.complete); }, onHeaders: (responseHeaders) { _setCachedResponseHeaders(responseHeaders); @@ -244,42 +214,37 @@ class HttpCacheStream { }, ); } catch (e) { - _cacheDownloader = null; if (e is InvalidCacheException) { await _resetCache(e); - } else if (isRetained) { + } else { _addError(e, closeRequests: true); - await Future.delayed(const Duration(seconds: 5)); } + if (!isRetained) rethrow; + await Future.delayed(const Duration(seconds: 5)); + } finally { + _cacheDownloader = null; } } - - _cacheDownloader = null; - - if (!isComplete()) { - final error = isRetained ? DownloadStoppedException(sourceUrl) : CacheStreamDisposedException(sourceUrl); - _addError(error, closeRequests: true); - throw error; - } - - return completedFile!; }); } - Future downloadHeaders(bool resetCacheIfInvalid) { + Future downloadHeaders({bool save = true}) { return _downloadHeadersFuture.run(() async { final latestHeaders = await CachedResponseHeaders.fromUrl( sourceUrl, httpClient: config.httpClient, requestHeaders: config.combinedRequestHeaders(), - ).timeout(config.requestTimeout); - - final currentHeaders = _cachedResponseHeaders; - if (currentHeaders == null || CachedResponseHeaders.validateCacheResponse(currentHeaders, latestHeaders)) { + ).timeout( + config.requestTimeout, + onTimeout: () => throw RequestTimedOutException( + sourceUrl, + config.requestTimeout, + ), + ); + if (save && !isDisposed) { _setCachedResponseHeaders(latestHeaders); - } else if (resetCacheIfInvalid) { - await _resetCache(CacheSourceChangedException(sourceUrl)); } + return latestHeaders; }); } @@ -295,8 +260,11 @@ class HttpCacheStream { return _disposeCompleter.future; } + bool _disposing = false; void _performDispose() async { - if (isDisposed || isRetained) return; + if (isDisposed || isRetained || _disposing) return; + _disposing = true; + _lifeCycleTimer?.cancel(); _lifeCycleTimer = null; @@ -308,21 +276,26 @@ class HttpCacheStream { return; //Stream was retained again during download cancellation } } - if (!config.savePartialCache && progress != 1.0) { + if (!config.savePartialCache && !cacheState.isComplete) { await resetCache(); - } else if (!config.saveMetadata && progress == 1.0 && files.metadata.existsSync()) { - await _writeLock.synchronized(files.metadata.delete); + } 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); } - _progressController.close().ignore(); + _stateController.close().ignore(); } } } @@ -335,21 +308,19 @@ class HttpCacheStream { if (downloader != null && !downloader.isClosed) { 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 || _cachedResponseHeaders != null) { - try { - _cachedResponseHeaders = 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 } } }); @@ -362,35 +333,38 @@ class HttpCacheStream { } _cachedResponseHeaders = headers; - _writeLock.synchronized(() async { + _fileLock.synchronized(() async { try { await files.metadata.parent.create(recursive: true); - await files.metadata.writeAsString(jsonEncode(metadata.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 && _queuedRequests.isNotEmpty && headers != null) { + + if (cacheState.isComplete && _queuedRequests.isNotEmpty && headers != null) { _queuedRequests.processAndRemove((request) { request.complete(() => StreamResponse.fromFile(request.range, files, headers!)); }); @@ -398,9 +372,8 @@ class HttpCacheStream { } 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) { @@ -415,23 +388,16 @@ 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. - /// This method validates the existence of the cache file and metadata - bool get isCached { - if (progress == 1.0) { - if (_cachedResponseHeaders != 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; @@ -439,20 +405,9 @@ class HttpCacheStream { /// If this [HttpCacheStream] is actively downloading data to cache file. 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 && headers?.sourceLength != null) { - return (progress! * headers!.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. /// @@ -464,13 +419,14 @@ class HttpCacheStream { /// 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(files, sourceUrl, _cachedResponseHeaders); @@ -499,10 +455,7 @@ class HttpCacheStream { /// Releases this [HttpCacheStream] instance. /// - /// Once released, [StreamLifecycleConfig.pauseDelay] determines how long to wait - /// before pausing an ongoing download. After [StreamLifecycleConfig.disposeDelay] - /// the stream will be disposed automatically. If [retain] is called before - /// disposal, the lifecycle timers are cancelled and the download resumes. + /// 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(); @@ -511,14 +464,27 @@ class HttpCacheStream { if (!isRetained) { final lifecycleConfig = config.lifecycleConfig; - _lifeCycleTimer = Timer(lifecycleConfig.pauseDelay, () { - final remainingDuration = lifecycleConfig.disposeDelay - lifecycleConfig.pauseDelay; - if (remainingDuration > Duration.zero) { - _lifeCycleTimer = Timer(remainingDuration, _performDispose); - _cacheDownloader?.pause(); - } else { + _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(); + } + }); }); } } 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 5489ae8..9fec3ea 100644 --- a/lib/src/cache_stream/response_streams/combined_data_stream.dart +++ b/lib/src/cache_stream/response_streams/combined_data_stream.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 bba2430..d0a6bb8 100644 --- a/lib/src/cache_stream/response_streams/download_stream.dart +++ b/lib/src/cache_stream/response_streams/download_stream.dart @@ -27,14 +27,12 @@ class DownloadStream extends Stream> { request.headers[HttpHeaders.rangeHeader] = rangeRequest.header; } - final streamedResponse = - await config.httpClient.sendWithTimeout(request, config.requestTimeout); + final streamedResponse = await config.httpClient.sendWithTimeout(request, config.requestTimeout); try { if (rangeRequest == null) { HttpStatusCodeException.validateCompleteResponse(url, streamedResponse); } else { - HttpRangeException.validate( - url, rangeRequest, HttpRangeResponse.parse(streamedResponse)); + HttpRangeException.validate(url, rangeRequest, HttpRangeResponse.parse(streamedResponse)); } return DownloadStream(streamedResponse); } catch (e) { @@ -67,8 +65,7 @@ class DownloadStream extends Stream> { bool _listened = false; BaseResponse get baseResponse => _streamedResponse; - HttpRangeResponse? get responseRange => - HttpRangeResponse.parse(_streamedResponse); + HttpRangeResponse? get responseRange => HttpRangeResponse.parse(_streamedResponse); int? get sourceLength { if (baseResponse.headers.containsKey(HttpHeaders.contentRangeHeader)) { 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 ea6bfaa..c994a39 100644 --- a/lib/src/etc/extensions/uri_extensions.dart +++ b/lib/src/etc/extensions/uri_extensions.dart @@ -1,6 +1,4 @@ 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 RequestKey get requestKey { return RequestKey(this); } @@ -23,5 +21,6 @@ extension UriExtensions on Uri { } extension type const RequestKey._(String _value) implements String { - factory RequestKey(Uri uri) => RequestKey._('${uri.path}?${uri.query}'); + factory RequestKey(Uri uri) => + RequestKey._('${uri.host}${uri.path}?${uri.query}'); } 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/cache_config/cache_config.dart b/lib/src/models/cache_config/cache_config.dart index ea4b6b0..f5c958a 100644 --- a/lib/src/models/cache_config/cache_config.dart +++ b/lib/src/models/cache_config/cache_config.dart @@ -115,4 +115,5 @@ abstract interface class CacheConfiguration { } } -typedef CacheCompleteCallback = void Function(HttpCacheStream stream, File completedCacheFile); +typedef CacheCompleteCallback = void Function( + HttpCacheStream stream, File completedCacheFile); diff --git a/lib/src/models/cache_config/global_cache_config.dart b/lib/src/models/cache_config/global_cache_config.dart index 96289a6..5940497 100644 --- a/lib/src/models/cache_config/global_cache_config.dart +++ b/lib/src/models/cache_config/global_cache_config.dart @@ -35,9 +35,12 @@ class GlobalCacheConfig implements CacheConfiguration { }) : httpClient = customHttpClient ?? Client(), requestHeaders = requestHeaders ?? {}, responseHeaders = responseHeaders ?? {}, - _maxBufferSize = CacheConfiguration.validateMaxBufferSize(maxBufferSize), + _maxBufferSize = + CacheConfiguration.validateMaxBufferSize(maxBufferSize), _minChunkSize = CacheConfiguration.validateMinChunkSize(minChunkSize), - _rangeRequestSplitThreshold = CacheConfiguration.validateRangeRequestSplitThreshold(rangeRequestSplitThreshold); + _rangeRequestSplitThreshold = + CacheConfiguration.validateRangeRequestSplitThreshold( + rangeRequestSplitThreshold); /// The directory where the cache files will be stored. final Directory cacheDirectory; @@ -74,7 +77,8 @@ class GlobalCacheConfig implements CacheConfiguration { @override set rangeRequestSplitThreshold(int? value) { - _rangeRequestSplitThreshold = CacheConfiguration.validateRangeRequestSplitThreshold(value); + _rangeRequestSplitThreshold = + CacheConfiguration.validateRangeRequestSplitThreshold(value); } int _minChunkSize; @@ -124,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/cache_config/stream_cache_config.dart b/lib/src/models/cache_config/stream_cache_config.dart index fa733bc..1d91e14 100644 --- a/lib/src/models/cache_config/stream_cache_config.dart +++ b/lib/src/models/cache_config/stream_cache_config.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:http_cache_stream/http_cache_stream.dart'; -import '../../etc/callback_helpers.dart'; +import '../../etc/helpers.dart'; /// Cache configuration for a single [HttpCacheStream]. /// @@ -122,7 +122,8 @@ class StreamCacheConfig implements CacheConfiguration { @override set rangeRequestSplitThreshold(int? value) { _useGlobalRangeRequestSplitThreshold = false; - _rangeRequestSplitThreshold = CacheConfiguration.validateRangeRequestSplitThreshold(value); + _rangeRequestSplitThreshold = + CacheConfiguration.validateRangeRequestSplitThreshold(value); } @override @@ -163,7 +164,9 @@ class StreamCacheConfig implements CacheConfiguration { return _combineHeaders( _global.requestHeaders, _requestHeaders, - defaultHeaders: const {HttpHeaders.acceptEncodingHeader: 'identity'}, // Avoid compressed responses + defaultHeaders: const { + HttpHeaders.acceptEncodingHeader: 'identity' + }, // Avoid compressed responses ); } diff --git a/lib/src/models/cache_config/stream_lifecycle_config.dart b/lib/src/models/cache_config/stream_lifecycle_config.dart index bcd4674..8c46dd7 100644 --- a/lib/src/models/cache_config/stream_lifecycle_config.dart +++ b/lib/src/models/cache_config/stream_lifecycle_config.dart @@ -1,13 +1,40 @@ -/// A configuration class that defines the lifecycle behavior of a `HttpCacheStream` instance. +/// 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 will pause the ongoing download, if any. - ///A paused connection will remain open for the duration of `readTimeout` to allow for resuming without needing to re-establish the connection. - final Duration pauseDelay; + /// 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; - ///The duration after which an inactive stream will be disposed, if any. Once disposed, the stream cannot be resumed and must be recreated for new requests. - final Duration disposeDelay; const StreamLifecycleConfig({ - this.pauseDelay = const Duration(seconds: 10), - this.disposeDelay = const Duration(minutes: 5), + this.pauseAfter = const Duration(seconds: 10), + this.disposeAfter = const Duration(minutes: 5), }); } 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/state_errors.dart b/lib/src/models/exceptions/state_errors.dart index 62af488..f6ac2b7 100644 --- a/lib/src/models/exceptions/state_errors.dart +++ b/lib/src/models/exceptions/state_errors.dart @@ -1,13 +1,10 @@ class CacheStreamDisposedException extends StateError { final Uri sourceUrl; - CacheStreamDisposedException(this.sourceUrl) : super('Attempted to use a disposed HttpCacheStream | $sourceUrl'); + CacheStreamDisposedException(this.sourceUrl) + : super('Attempted to use a disposed HttpCacheStream | $sourceUrl'); } class CacheManagerDisposedException extends StateError { - CacheManagerDisposedException() : super('Attempted to use a disposed HttpCacheManager'); -} - -class CacheServerClosedException extends StateError { - final Uri origin; - CacheServerClosedException(this.origin) : super('Attempted to use a closed HttpCacheServer | $origin'); + 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 8244504..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. @@ -25,7 +29,8 @@ class CacheMetadata { static CacheMetadata? fromCacheFiles(final CacheFiles cacheFiles) { final metadataFile = cacheFiles.metadata; if (!metadataFile.existsSync()) return null; - final metadataJson = jsonDecode(metadataFile.readAsStringSync()) as Map; + final metadataJson = + jsonDecodeBytes(metadataFile.readAsBytesSync()) as Map; return CacheMetadata( cacheFiles, Uri.parse(metadataJson['Url']), @@ -33,25 +38,29 @@ class CacheMetadata { ); } - ///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(cacheFile.path); //Rename the partial cache to the complete cache - return 1.0; + await partialCacheFile.rename( + cacheFile.path); //Rename the partial cache to the complete cache + 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); } } diff --git a/lib/src/models/metadata/cached_response_headers.dart b/lib/src/models/metadata/cached_response_headers.dart index 41e9b30..947506e 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'; @@ -138,7 +138,7 @@ class CachedResponseHeaders { for (final header in essentialHeaders) { final value = _headers[header]; - if (value != null) { + if (value != null && value.isNotEmpty) { retainedHeaders[header] = value; } } @@ -187,7 +187,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']); if (headersFromJson != null) return headersFromJson; @@ -202,7 +202,7 @@ class CachedResponseHeaders { static Future fromCacheFilesAsync(final CacheFiles cacheFiles) async { try { if (await cacheFiles.metadata.exists()) { - final json = jsonDecode(await cacheFiles.metadata.readAsString()); + final json = jsonDecodeBytes(await cacheFiles.metadata.readAsBytes()); if (json is Map) { final headersFromJson = CachedResponseHeaders.fromJson(json['headers']); if (headersFromJson != null) return headersFromJson; diff --git a/lib/src/models/stream_response/stream_response.dart b/lib/src/models/stream_response/stream_response.dart index 6eeb8d4..2961a6c 100644 --- a/lib/src/models/stream_response/stream_response.dart +++ b/lib/src/models/stream_response/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 e7b144e..938de50 100644 --- a/lib/src/request_handler/request_handler.dart +++ b/lib/src/request_handler/request_handler.dart @@ -19,16 +19,19 @@ class RequestHandler { SocketHandler? _socketHandler; Future stream(final HttpCacheStream cacheStream) async { - final timeoutTimer = Timer(cacheStream.config.requestTimeout, () => close(HttpStatus.gatewayTimeout)); + final timeoutTimer = Timer(cacheStream.config.requestTimeout, + () => close(HttpStatus.gatewayTimeout)); StreamResponse? streamResponse; try { final rangeRequest = HttpRangeRequest.parse(_request); switch (_request.method) { case 'GET': - streamResponse = await cacheStream.request(start: rangeRequest?.start, end: rangeRequest?.endEx); + streamResponse = await cacheStream.request( + start: rangeRequest?.start, end: rangeRequest?.endEx); case 'HEAD': - streamResponse = await cacheStream.head(start: rangeRequest?.start, end: rangeRequest?.endEx); + streamResponse = await cacheStream.head( + start: rangeRequest?.start, end: rangeRequest?.endEx); default: close(HttpStatus.methodNotAllowed); return; @@ -53,14 +56,17 @@ class RequestHandler { timeoutTimer.cancel(); _requestClosed = true; //Response is now being handled via socket - final socketHandler = _socketHandler = SocketHandler(await _request.response.detachSocket(writeHeaders: true)); - await socketHandler.writeResponse(streamResponse.stream, cacheStream.config.readTimeout); + final socketHandler = _socketHandler = SocketHandler( + await _request.response.detachSocket(writeHeaders: true)); + await socketHandler.writeResponse( + streamResponse.stream, cacheStream.config.readTimeout); _socketHandler = null; //Clear the socket handler after done. } catch (e) { closeWithError(e, cacheStream.metadata.headers); } finally { timeoutTimer.cancel(); - streamResponse?.cancel(); //Ensure we cancel the stream response to free resources. + streamResponse + ?.cancel(); //Ensure we cancel the stream response to free resources. streamResponse = null; } } @@ -82,9 +88,14 @@ class RequestHandler { } cacheConfig.combinedResponseHeaders().forEach(httpResponse.headers.set); - String? contentType = httpResponse.headers.value(HttpHeaders.contentTypeHeader) ?? cacheHeaders.get(HttpHeaders.contentTypeHeader); - if (contentType == null || contentType.isEmpty || contentType == MimeTypes.octetStream) { - contentType = MimeTypes.fromPath(_request.uri.path) ?? MimeTypes.octetStream; + String? contentType = + httpResponse.headers.value(HttpHeaders.contentTypeHeader) ?? + cacheHeaders.get(HttpHeaders.contentTypeHeader); + if (contentType == null || + contentType.isEmpty || + contentType == MimeTypes.octetStream) { + contentType = + MimeTypes.fromPath(_request.uri.path) ?? MimeTypes.octetStream; } httpResponse.headers.set(HttpHeaders.contentTypeHeader, contentType); @@ -121,7 +132,8 @@ class RequestHandler { case RangeError() || HttpRangeException(): statusCode = HttpStatus.requestedRangeNotSatisfiable; if (headers?.sourceLength case final int sourceLength) { - _request.response.headers.set(HttpHeaders.contentRangeHeader, 'bytes */$sourceLength'); + _request.response.headers + .set(HttpHeaders.contentRangeHeader, 'bytes */$sourceLength'); } case TimeoutException(): statusCode = HttpStatus.gatewayTimeout; diff --git a/pubspec.yaml b/pubspec.yaml index bb2b842..74849b1 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,6 +31,7 @@ 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 From 8eaa6daa5a75e9b82a95f26ced7574bbd5300910 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:02:28 +0000 Subject: [PATCH 05/11] Add test suite covering data integrity, headers, and streaming Introduces a layered test suite for the 0.1.0 release. The package previously shipped with no tests. - support/: a configurable local origin HttpServer (TestOrigin), a deterministic seeded payload generator + SHA-256 helpers, and a manager/temp-dir harness. - unit/: pure tests for CachedResponseHeaders parsing/validation, HTTP range parsing/formatting, IntRange, CacheState, cache-URL encode/decode round-trips, CacheFiles, RetainCounter, and FutureRunner. - io/: BufferedIOSink byte-for-byte integrity (ordering, append/resume, waitForPosition) and the CacheMetadata size-guard. - e2e/: full-download, streamed-read, range, open-ended range, concurrent overlapping requests, partial-cache resume, and cache-hit integrity (asserted by SHA-256), plus served-header correctness and the retain/release stream lifecycle. Adds flutter_test as a dev dependency; the suite runs with `flutter test`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HknTbNeWNVfLLA3MXwnnrQ --- pubspec.yaml | 2 + test/e2e/e2e_headers_test.dart | 86 +++++++++ test/e2e/e2e_integrity_test.dart | 140 ++++++++++++++ test/e2e/lifecycle_test.dart | 99 ++++++++++ test/io/buffered_io_sink_test.dart | 106 +++++++++++ test/io/cache_metadata_io_test.dart | 108 +++++++++++ test/support/harness.dart | 78 ++++++++ test/support/payload.dart | 32 ++++ test/support/test_origin.dart | 184 ++++++++++++++++++ test/unit/cache_files_test.dart | 72 +++++++ test/unit/cache_state_test.dart | 72 +++++++ test/unit/cached_response_headers_test.dart | 201 ++++++++++++++++++++ test/unit/future_runner_test.dart | 47 +++++ test/unit/http_range_test.dart | 97 ++++++++++ test/unit/int_range_test.dart | 60 ++++++ test/unit/retain_counter_test.dart | 41 ++++ test/unit/url_codec_test.dart | 65 +++++++ 17 files changed, 1490 insertions(+) create mode 100644 test/e2e/e2e_headers_test.dart create mode 100644 test/e2e/e2e_integrity_test.dart create mode 100644 test/e2e/lifecycle_test.dart create mode 100644 test/io/buffered_io_sink_test.dart create mode 100644 test/io/cache_metadata_io_test.dart create mode 100644 test/support/harness.dart create mode 100644 test/support/payload.dart create mode 100644 test/support/test_origin.dart create mode 100644 test/unit/cache_files_test.dart create mode 100644 test/unit/cache_state_test.dart create mode 100644 test/unit/cached_response_headers_test.dart create mode 100644 test/unit/future_runner_test.dart create mode 100644 test/unit/http_range_test.dart create mode 100644 test/unit/int_range_test.dart create mode 100644 test/unit/retain_counter_test.dart create mode 100644 test/unit/url_codec_test.dart diff --git a/pubspec.yaml b/pubspec.yaml index 74849b1..48f3478 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,5 +35,7 @@ dependencies: dev_dependencies: flutter_lints: ^5.0.0 + flutter_test: + sdk: flutter flutter: diff --git a/test/e2e/e2e_headers_test.dart b/test/e2e/e2e_headers_test.dart new file mode 100644 index 0000000..d79eb91 --- /dev/null +++ b/test/e2e/e2e_headers_test.dart @@ -0,0 +1,86 @@ +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()); + + test('a full response carries content-type, length and accept-ranges', + () async { + final source = h.origin.url('/media/clip.mp3'); + final stream = h.manager.createStream(source); + await stream.download(); + + final res = await h.fetch(h.manager.getCacheUrl(source)); + 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'); + + await stream.dispose(); + }); + + test('a range response carries content-range and the correct length', + () async { + 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=10-109'); + expect(res.statusCode, 206); + expect(res.header('content-range'), 'bytes 10-109/$total'); + expect(int.parse(res.header('content-length')!), 100); + + await stream.dispose(); + }); + + test('an out-of-range request returns 416', () async { + 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 source = h.origin.url('/media/clip.mp3'); + final stream = h.manager.createStream(source); + await stream.download(); + + final res = await h.fetch(h.manager.getCacheUrl(source), method: 'HEAD'); + expect(res.body, isEmpty); + expect(int.parse(res.header('content-length')!), h.origin.payload.length); + + await stream.dispose(); + }); + + test('content-type falls back to the path when the origin omits it', + () async { + h.origin.contentType = null; + final source = h.origin.url('/media/clip.mp3'); + final stream = h.manager.createStream(source); + await stream.download(); + + final res = await h.fetch(h.manager.getCacheUrl(source)); + expect(res.header('content-type'), MimeTypes.fromPath('clip.mp3')); + expect(res.header('content-type'), isNot(MimeTypes.octetStream)); + + await stream.dispose(); + }); +} diff --git a/test/e2e/e2e_integrity_test.dart b/test/e2e/e2e_integrity_test.dart new file mode 100644 index 0000000..2ed2221 --- /dev/null +++ b/test/e2e/e2e_integrity_test.dart @@ -0,0 +1,140 @@ +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 fully cached file is served from disk without re-hitting the origin', + () async { + final source = h.origin.url('/media/file.mp3'); + final stream = h.manager.createStream(source); + await stream.download(); + + final originRequestsAfterDownload = h.origin.requestCount; + + final res = await h.fetch(h.manager.getCacheUrl(source)); + expect(Payload.hash(res.body), h.payloadHash); + expect(h.origin.requestCount, originRequestsAfterDownload, + reason: 'cache hit should not 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..d29e628 --- /dev/null +++ b/test/io/buffered_io_sink_test.dart @@ -0,0 +1,106 @@ +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)); + final f = sink.waitForPosition(10 * 1024 * 1024); + await sink.close(); + await expectLater(f, throwsA(isA())); + }); + + 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..48320b8 --- /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..03d95db --- /dev/null +++ b/test/support/harness.dart @@ -0,0 +1,78 @@ +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); + if (range != null) { + request.headers.set(HttpHeaders.rangeHeader, range); + } + final response = await request.close(); + final builder = BytesBuilder(copy: false); + await for (final chunk in response) { + builder.add(chunk); + } + 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..4d5d7ba --- /dev/null +++ b/test/support/test_origin.dart @@ -0,0 +1,184 @@ +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 = []; + + Uri get baseUri => + Uri(scheme: 'http', host: _server.address.host, 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..8d840e7 --- /dev/null +++ b/test/unit/cached_response_headers_test.dart @@ -0,0 +1,201 @@ +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..977a6c7 --- /dev/null +++ b/test/unit/future_runner_test.dart @@ -0,0 +1,47 @@ +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..1384976 --- /dev/null +++ b/test/unit/http_range_test.dart @@ -0,0 +1,97 @@ +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..58cbffb --- /dev/null +++ b/test/unit/int_range_test.dart @@ -0,0 +1,60 @@ +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..452c2b8 --- /dev/null +++ b/test/unit/url_codec_test.dart @@ -0,0 +1,65 @@ +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); + }); + + 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); + }); + }); +} From 9d94837a5dbf1c51191e16cdf5272a8222d20eaf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:10:27 +0000 Subject: [PATCH 06/11] Add GitHub Actions workflow to run flutter test Runs `flutter test` on every push and pull request. Static analysis runs as a non-blocking step so style lints don't mask test results. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HknTbNeWNVfLLA3MXwnnrQ --- .github/workflows/test.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/test.yml 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 From 293da81318fd3a250048d28393fc436d082d0653 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:18:43 +0000 Subject: [PATCH 07/11] Fix test failures surfaced by CI - TestOrigin now reports host `localhost` instead of the bound `127.0.0.1`, so source URLs are distinguishable from the cache server's loopback host (otherwise encodeSourceUrl rejects them as already-encoded cache URLs). This unblocks all e2e/lifecycle/header tests. - url_codec_test attaches a server listener via start() so the keep-alive stream's buffered done event can flush on close(); without a subscriber, teardown hung and the tests timed out. - BufferedIOSink close test attaches the matcher before close() so the waiter's synchronous error is observed instead of crashing the test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HknTbNeWNVfLLA3MXwnnrQ --- test/io/buffered_io_sink_test.dart | 7 +++++-- test/support/test_origin.dart | 7 +++++-- test/unit/url_codec_test.dart | 4 ++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/test/io/buffered_io_sink_test.dart b/test/io/buffered_io_sink_test.dart index d29e628..915dc8f 100644 --- a/test/io/buffered_io_sink_test.dart +++ b/test/io/buffered_io_sink_test.dart @@ -92,9 +92,12 @@ void main() { test('waitForPosition fails if the sink closes before reaching it', () async { final sink = BufferedIOSink(tmp('closed.bin'), 0); sink.add(Payload.generate(1024)); - final f = sink.waitForPosition(10 * 1024 * 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 expectLater(f, throwsA(isA())); + await expectation; }); test('adding to a closed sink throws', () async { diff --git a/test/support/test_origin.dart b/test/support/test_origin.dart index 4d5d7ba..bf041f8 100644 --- a/test/support/test_origin.dart +++ b/test/support/test_origin.dart @@ -53,8 +53,11 @@ class TestOrigin { String? lastRangeHeader; final List rangeHeaders = []; - Uri get baseUri => - Uri(scheme: 'http', host: _server.address.host, port: _server.port); + // 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); diff --git a/test/unit/url_codec_test.dart b/test/unit/url_codec_test.dart index 452c2b8..64ed3a4 100644 --- a/test/unit/url_codec_test.dart +++ b/test/unit/url_codec_test.dart @@ -6,6 +6,10 @@ void main() { 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 { From f1c31412319a4b4fa68c47c6576d582fd1430a82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 03:16:55 +0000 Subject: [PATCH 08/11] Disable keep-alive in test fetch helper to fix completed-cache hangs The cache server ends a response by destroying the socket (abortive close). With a pooled keep-alive HttpClient, fetching a body served instantly from a completed cache file raced that close and hung the client (4 e2e timeouts). Use a non-persistent connection (read until close) and add a short per-fetch timeout so any future stall fails fast instead of hitting the 30s framework timeout. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HknTbNeWNVfLLA3MXwnnrQ --- test/support/harness.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/support/harness.dart b/test/support/harness.dart index 03d95db..e51e917 100644 --- a/test/support/harness.dart +++ b/test/support/harness.dart @@ -61,14 +61,19 @@ class CacheTestHarness { 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); - await for (final chunk in response) { - builder.add(chunk); - } + // 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); } From 26b3a6ac620f29ac3bc485082c4fcc7a0208315b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 03:26:03 +0000 Subject: [PATCH 09/11] Make completed-cache e2e tests robust; consolidate CHANGELOG Serving a body from an already-complete cache file over the local server hangs the Dart test HttpClient (the server's Socket.close() is immediately followed by Socket.destroy(), and the RST races delivery of an instant response). Lazy/streamed serving and no-body responses are unaffected. - e2e_headers: assert served headers via lazy fetches (same RequestHandler header logic, served while downloading) instead of pre-downloading. - e2e_integrity: assert the cache-hit / no-refetch invariant at the API level via a second download() rather than an HTTP body fetch of the completed file. - CHANGELOG: fold the InvalidCacheLengthException -> InvalidCacheSizeException rename into 0.1.0 Breaking Changes and drop the never-published 0.0.7 entry. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HknTbNeWNVfLLA3MXwnnrQ --- CHANGELOG.md | 5 +--- test/e2e/e2e_headers_test.dart | 43 +++++++++++--------------------- test/e2e/e2e_integrity_test.dart | 14 ++++++----- 3 files changed, 23 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 508990e..b029465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This release significantly simplifies cache management. A new `getCacheUrl` API ### 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 @@ -23,10 +24,6 @@ This release significantly simplifies cache management. A new `getCacheUrl` API - Cache headers are now read using async I/O. -## 0.0.7 - -* Renamed `InvalidCacheLengthException` to `InvalidCacheSizeException` and exposed expected/actual size. - ## 0.0.6 * Add `onStreamCreated` callback to `HttpCacheManager`. diff --git a/test/e2e/e2e_headers_test.dart b/test/e2e/e2e_headers_test.dart index d79eb91..eb6a6ec 100644 --- a/test/e2e/e2e_headers_test.dart +++ b/test/e2e/e2e_headers_test.dart @@ -14,38 +14,33 @@ void main() { 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 source = h.origin.url('/media/clip.mp3'); - final stream = h.manager.createStream(source); - await stream.download(); - - final res = await h.fetch(h.manager.getCacheUrl(source)); + 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'); - - await stream.dispose(); }); test('a range response carries content-range and the correct length', () async { - 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=10-109'); + 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); - - await stream.dispose(); }); 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(); @@ -59,28 +54,18 @@ void main() { }); test('HEAD returns headers with an empty body', () async { - final source = h.origin.url('/media/clip.mp3'); - final stream = h.manager.createStream(source); - await stream.download(); - - final res = await h.fetch(h.manager.getCacheUrl(source), method: 'HEAD'); + 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); - - await stream.dispose(); }); test('content-type falls back to the path when the origin omits it', () async { h.origin.contentType = null; - final source = h.origin.url('/media/clip.mp3'); - final stream = h.manager.createStream(source); - await stream.download(); - - final res = await h.fetch(h.manager.getCacheUrl(source)); + 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)); - - await stream.dispose(); }); } diff --git a/test/e2e/e2e_integrity_test.dart b/test/e2e/e2e_integrity_test.dart index 2ed2221..65b8781 100644 --- a/test/e2e/e2e_integrity_test.dart +++ b/test/e2e/e2e_integrity_test.dart @@ -122,18 +122,20 @@ void main() { await stream.dispose(); }); - test('a fully cached file is served from disk without re-hitting the origin', - () async { + 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); - await stream.download(); + final file1 = await stream.download(); + expect(Payload.hash(await file1.readAsBytes()), h.payloadHash); final originRequestsAfterDownload = h.origin.requestCount; - final res = await h.fetch(h.manager.getCacheUrl(source)); - expect(Payload.hash(res.body), h.payloadHash); + // 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: 'cache hit should not contact the origin'); + reason: 'a completed cache must not re-contact the origin'); await stream.dispose(); }); From cf2df9678edf1f0be9c7fa17001336013dd25caa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 07:16:07 +0000 Subject: [PATCH 10/11] Honor a pending dispose() on the final release() dispose() called while a stream is transiently retained (e.g. by an in-flight cache-server request) left the dispose future pending: _performDispose bailed on isRetained, and the later release() took the lifecycle path, deferring teardown by the full disposeAfter (default 5 min) instead of completing the dispose. Track _disposeRequested; when the retain count reaches zero in release(), complete the dispose immediately if requested. retain() clears the flag so a new holder still resurrects the stream per the existing release/retain contract. Adds a regression test under the default lifecycle config. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HknTbNeWNVfLLA3MXwnnrQ --- lib/src/cache_stream/http_cache_stream.dart | 8 ++++ test/e2e/dispose_test.dart | 46 +++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 test/e2e/dispose_test.dart diff --git a/lib/src/cache_stream/http_cache_stream.dart b/lib/src/cache_stream/http_cache_stream.dart index fe95fae..79f28f4 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -255,11 +255,13 @@ class HttpCacheStream { /// 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}) { + _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; @@ -447,6 +449,7 @@ class HttpCacheStream { /// as this method. void retain() { _checkDisposed(); + _disposeRequested = false; //A new holder resurrects the stream _retainCounter.retain(); _lifeCycleTimer?.cancel(); _lifeCycleTimer = null; @@ -462,6 +465,11 @@ class HttpCacheStream { _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, () { diff --git a/test/e2e/dispose_test.dart b/test/e2e/dispose_test.dart new file mode 100644 index 0000000..f85e89e --- /dev/null +++ b/test/e2e/dispose_test.dart @@ -0,0 +1,46 @@ +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); + }); +} From d171741429bcf995267aea8a2772b166370479ac Mon Sep 17 00:00:00 2001 From: Colton Date: Sun, 21 Jun 2026 23:08:14 -0400 Subject: [PATCH 11/11] formatter --- lib/src/cache_manager/http_cache_manager.dart | 15 ++-- .../cache_downloader/buffered_io_sink.dart | 13 ++- .../cache_downloader/cache_downloader.dart | 26 ++++-- .../cache_downloader/downloader.dart | 17 ++-- lib/src/cache_stream/http_cache_stream.dart | 85 +++++++++++++------ .../response_streams/download_stream.dart | 9 +- .../metadata/cached_response_headers.dart | 63 +++++++++----- test/e2e/dispose_test.dart | 3 +- test/io/cache_metadata_io_test.dart | 4 +- test/support/test_origin.dart | 4 +- test/unit/cached_response_headers_test.dart | 6 +- test/unit/future_runner_test.dart | 3 +- test/unit/http_range_test.dart | 3 +- test/unit/int_range_test.dart | 3 +- test/unit/url_codec_test.dart | 10 ++- 15 files changed, 178 insertions(+), 86 deletions(-) diff --git a/lib/src/cache_manager/http_cache_manager.dart b/lib/src/cache_manager/http_cache_manager.dart index cdecbdc..1384cbf 100644 --- a/lib/src/cache_manager/http_cache_manager.dart +++ b/lib/src/cache_manager/http_cache_manager.dart @@ -47,7 +47,8 @@ class HttpCacheManager { final existingStream = _streams[requestKey]; if (existingStream != null && !existingStream.isDisposed) { - existingStream.retain(); //Retain the stream to prevent it from being disposed while in use + existingStream + .retain(); //Retain the stream to prevent it from being disposed while in use return existingStream; } @@ -121,7 +122,8 @@ class HttpCacheManager { for (final stream in allStreams) { activeFilePaths.addAll(stream.metadata.cacheFiles.paths); } - await for (final entry in cacheDir.list(recursive: true, followLinks: false)) { + await for (final entry + in cacheDir.list(recursive: true, followLinks: false)) { if (entry is File && !activeFilePaths.contains(entry.path)) { yield entry; } @@ -151,7 +153,8 @@ class HttpCacheManager { ///Get the [CacheMetadata] for the given URL or input [cacheFile]. Returns null if the metadata does not exist. CacheMetadata? getCacheMetadata(Uri url, [File? cacheFile]) { - return getExistingStream(url)?.metadata ?? CacheMetadata.fromCacheFiles(_resolveCacheFiles(url, 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. @@ -170,7 +173,8 @@ class HttpCacheManager { CacheFiles _resolveCacheFiles(Uri sourceUrl, [File? cacheFile]) { if (cacheFile == null) { sourceUrl = _server.decodeSourceUrl(sourceUrl) ?? sourceUrl; - cacheFile = _customCacheFiles[sourceUrl.requestKey] ?? config.cacheFileResolver(config.cacheDirectory, sourceUrl); + cacheFile = _customCacheFiles[sourceUrl.requestKey] ?? + config.cacheFileResolver(config.cacheDirectory, sourceUrl); } return CacheFiles.fromFile(cacheFile); } @@ -239,7 +243,8 @@ class HttpCacheManager { try { final cacheConfig = config ?? GlobalCacheConfig( - cacheDirectory: cacheDir ?? await GlobalCacheConfig.defaultCacheDirectory(), + cacheDirectory: + cacheDir ?? await GlobalCacheConfig.defaultCacheDirectory(), customHttpClient: customHttpClient, ); final httpCacheServer = await LocalCacheServer.init(port: port); 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 3beadcd..37e5ab1 100644 --- a/lib/src/cache_stream/cache_downloader/buffered_io_sink.dart +++ b/lib/src/cache_stream/cache_downloader/buffered_io_sink.dart @@ -5,7 +5,8 @@ import 'dart:typed_data'; /// An IO sink that supports adding data while flushing to disk asynchronously. class BufferedIOSink { final File file; - BufferedIOSink(this.file, int initialPosition) : _flushedBytes = initialPosition; + BufferedIOSink(this.file, int initialPosition) + : _flushedBytes = initialPosition; int _flushedBytes; final _buffer = BytesBuilder(copy: false); RandomAccessFile? _openedRAF; @@ -58,16 +59,20 @@ class BufferedIOSink { /// 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)]) { + 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')); + 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); + throw TimeoutException( + 'Timeout while waiting for flushedBytes to reach $minFlushedBytes', + timeout); }); } diff --git a/lib/src/cache_stream/cache_downloader/cache_downloader.dart b/lib/src/cache_stream/cache_downloader/cache_downloader.dart index 31893e3..74160f4 100644 --- a/lib/src/cache_stream/cache_downloader/cache_downloader.dart +++ b/lib/src/cache_stream/cache_downloader/cache_downloader.dart @@ -21,7 +21,8 @@ class CacheDownloader { final _streamController = StreamController>.broadcast(sync: true); final _completer = Completer(); int _position; - int _pendingStreamBytes = 0; //Bytes received but not added to stream yet. These bytes will be added within the current event loop. + 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._( @@ -69,24 +70,31 @@ class CacheDownloader { }, onHeaders: (cacheHttpHeaders) { final prevHeaders = _cachedHeaders; - if (prevHeaders != null && downloadPosition > 0 && !CachedResponseHeaders.validateCacheResponse(prevHeaders, cacheHttpHeaders)) { + if (prevHeaders != null && + downloadPosition > 0 && + !CachedResponseHeaders.validateCacheResponse( + prevHeaders, cacheHttpHeaders)) { throw CacheSourceChangedException(sourceUrl); } _cachedHeaders = cacheHttpHeaders; onHeaders(cacheHttpHeaders); - onPosition(downloadPosition); //Emit current position to update progress and process queued requests + onPosition( + downloadPosition); //Emit current position to update progress and process queued requests }, onData: (data) { _position += data.length; _sink.add(data); _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. + 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 + _downloader + .pause(); //Pause upstream if we are receiving more data than we can write _sink.flush().then( (_) { _downloader.resume(); @@ -110,7 +118,8 @@ class CacheDownloader { // Post-download — flush remaining data and verify cache integrity try { - await _sink.close(flushBuffer: true); //Flushes all buffered data and closes the sink + await _sink.close( + flushBuffer: true); //Flushes all buffered data and closes the sink } catch (e) { onError(e); } @@ -122,7 +131,8 @@ class CacheDownloader { downloadPosition, ); - final sourceLength = _cachedHeaders?.sourceLength ?? (_downloader.isDone ? downloadPosition : null); + final sourceLength = _cachedHeaders?.sourceLength ?? + (_downloader.isDone ? downloadPosition : null); if (sourceLength != null && partialCacheLength == sourceLength) { await onComplete(sourceLength); } diff --git a/lib/src/cache_stream/cache_downloader/downloader.dart b/lib/src/cache_stream/cache_downloader/downloader.dart index be3f302..28d5752 100644 --- a/lib/src/cache_stream/cache_downloader/downloader.dart +++ b/lib/src/cache_stream/cache_downloader/downloader.dart @@ -25,7 +25,8 @@ class Downloader { Future download({ required final IntRange Function() downloadRange, required final void Function(Object e) onError, - required final void Function(CachedResponseHeaders responseHeaders) onHeaders, + required final void Function(CachedResponseHeaders responseHeaders) + onHeaders, required final void Function(List data) onData, }) async { try { @@ -49,11 +50,14 @@ class Downloader { ); if (_pauseCounter.isPaused) { final readTimeout = streamConfig.readTimeout; - await _pauseCounter.onResume.timeout(readTimeout, onTimeout: () => throw ReadTimedOutException(sourceUrl, readTimeout)); + await _pauseCounter.onResume.timeout(readTimeout, + onTimeout: () => + throw ReadTimedOutException(sourceUrl, readTimeout)); } checkActive(); onHeaders(downloadStream.responseHeaders); - final responseListener = DownloadResponseListener(sourceUrl, downloadStream, onData, streamConfig); + final responseListener = DownloadResponseListener( + sourceUrl, downloadStream, onData, streamConfig); _responseListener = responseListener; try { _done = await responseListener.done; @@ -68,7 +72,9 @@ class Downloader { break; } else { onError(e); - await (_pauseCounter.isPaused ? _pauseCounter.onResume : Future.delayed(const Duration(seconds: 5))); + await (_pauseCounter.isPaused + ? _pauseCounter.onResume + : Future.delayed(const Duration(seconds: 5))); } } } @@ -82,7 +88,8 @@ class Downloader { final responseListener = _responseListener; if (responseListener != null) { _responseListener = null; - responseListener.cancel(exception ?? DownloadStoppedException(sourceUrl), flushBuffer: exception is! InvalidCacheException); + responseListener.cancel(exception ?? DownloadStoppedException(sourceUrl), + flushBuffer: exception is! InvalidCacheException); } _pauseCounter.resume(force: true); //Break any pauses } diff --git a/lib/src/cache_stream/http_cache_stream.dart b/lib/src/cache_stream/http_cache_stream.dart index 79f28f4..0c95e76 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -43,15 +43,18 @@ class HttpCacheStream { final _stateController = BehaviorSubject(); final _retainCounter = RetainCounter(); - CacheDownloader? _cacheDownloader; //The active cache downloader, if any. This can be used to cancel the download. + CacheDownloader? + _cacheDownloader; //The active cache downloader, if any. This can be used to cancel the download. 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 - CachedResponseHeaders? _cachedResponseHeaders; //The cached response headers, if any + final _disposeCompleter = + Completer(); //Completer for the dispose future + CachedResponseHeaders? + _cachedResponseHeaders; //The cached response headers, if any HttpCacheStream({ required this.sourceUrl, @@ -61,7 +64,8 @@ class HttpCacheStream { }) { _initFuture.run(() async { try { - _cachedResponseHeaders = await CachedResponseHeaders.fromCacheFilesAsync(files); + _cachedResponseHeaders = + await CachedResponseHeaders.fromCacheFilesAsync(files); } catch (e) { _addError(e, closeRequests: false); } finally { @@ -84,7 +88,9 @@ class HttpCacheStream { /// of the file respectively. Future request({final int? start, final int? end}) async { if (end != null && start == end) { - return head(start: start, end: end); //Requested range is empty, return only headers + return head( + start: start, + end: end); //Requested range is empty, return only headers } await _ensureInit(); _checkDisposed(); @@ -100,7 +106,9 @@ class HttpCacheStream { } final rangeThreshold = config.rangeRequestSplitThreshold; - if (rangeThreshold != null && range.start >= rangeThreshold && (range.start - cachePosition) >= rangeThreshold) { + if (rangeThreshold != null && + range.start >= rangeThreshold && + (range.start - cachePosition) >= rangeThreshold) { return StreamResponse.fromDownload(sourceUrl, range, config); } @@ -114,12 +122,14 @@ class HttpCacheStream { if (downloader != null && downloader.processRequest(streamRequest)) { return streamRequest.response; //Request was processed immediately } else { - _queuedRequests.addSorted(streamRequest); //Add request to queue, sorted by range + _queuedRequests + .addSorted(streamRequest); //Add request to queue, sorted by range final requestTimeout = config.requestTimeout; final timeoutTimer = Timer(requestTimeout, () { _queuedRequests.remove(streamRequest); - streamRequest.completeError(StreamRequestTimedOutException(requestTimeout)); + streamRequest + .completeError(StreamRequestTimedOutException(requestTimeout)); }); return streamRequest.response.whenComplete(timeoutTimer.cancel); @@ -140,11 +150,14 @@ class HttpCacheStream { if (isDownloading || !cacheState.isComplete) { return null; //Cache does not exist or is downloading } - final currentHeaders = _cachedResponseHeaders ??= CachedResponseHeaders.fromFile(cacheFile)!; + final currentHeaders = + _cachedResponseHeaders ??= CachedResponseHeaders.fromFile(cacheFile)!; if (!force && currentHeaders.shouldRevalidate() == false) return true; try { final latestHeaders = await downloadHeaders(save: false); - if (CachedResponseHeaders.validateCacheResponse(currentHeaders, latestHeaders) == true) { + if (CachedResponseHeaders.validateCacheResponse( + currentHeaders, latestHeaders) == + true) { _setCachedResponseHeaders(latestHeaders); return true; } else { @@ -167,7 +180,8 @@ class HttpCacheStream { await _ensureInit(); _checkDisposed(); - final responseHeaders = _cachedResponseHeaders ?? await downloadHeaders(save: true); + final responseHeaders = + _cachedResponseHeaders ?? await downloadHeaders(save: true); final range = IntRange.validate(start, end, responseHeaders.sourceLength); return HeaderStreamResponse(range, responseHeaders); } @@ -188,19 +202,25 @@ class HttpCacheStream { throw DownloadStoppedException(sourceUrl); } try { - final downloader = _cacheDownloader = CacheDownloader.construct(metadata, config); + 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)) { + _updateCacheState( + CacheState.incomplete(position, downloader.sourceLength)); + while (_queuedRequests.isNotEmpty && + downloader.processRequest(_queuedRequests.first)) { _queuedRequests.removeAt(0); } }, onComplete: (sourceLength) async { - await _fileLock.synchronized(() => files.partial.rename(files.complete.path)); + await _fileLock.synchronized( + () => files.partial.rename(files.complete.path)); final cachedHeaders = _cachedResponseHeaders!; - if (cachedHeaders.sourceLength != sourceLength || !cachedHeaders.acceptsRangeRequests) { - _setCachedResponseHeaders(cachedHeaders.setSourceLength(sourceLength)); + if (cachedHeaders.sourceLength != sourceLength || + !cachedHeaders.acceptsRangeRequests) { + _setCachedResponseHeaders( + cachedHeaders.setSourceLength(sourceLength)); } _updateCacheState(CacheState.complete(sourceLength)); config.handleCacheCompletion(this, files.complete); @@ -295,7 +315,8 @@ class HttpCacheStream { if (!_disposeCompleter.isCompleted && !isRetained) { _disposeCompleter.complete(); if (_queuedRequests.isNotEmpty) { - _addError(CacheStreamDisposedException(sourceUrl), closeRequests: true); + _addError(CacheStreamDisposedException(sourceUrl), + closeRequests: true); } _stateController.close().ignore(); } @@ -308,7 +329,8 @@ class HttpCacheStream { Future _resetCache(final InvalidCacheException exception) { final downloader = _cacheDownloader; if (downloader != null && !downloader.isClosed) { - return downloader.cancel(exception); //Close the ongoing download, which will rethrow the exception and reset the cache + return downloader.cancel( + exception); //Close the ongoing download, which will rethrow the exception and reset the cache } else { return _fileLock.synchronized(() async { try { @@ -366,9 +388,12 @@ class HttpCacheStream { _stateController.add(cacheState); } - if (cacheState.isComplete && _queuedRequests.isNotEmpty && headers != null) { + if (cacheState.isComplete && + _queuedRequests.isNotEmpty && + headers != null) { _queuedRequests.processAndRemove((request) { - request.complete(() => StreamResponse.fromFile(request.range, files, headers!)); + request.complete( + () => StreamResponse.fromFile(request.range, files, headers!)); }); } } @@ -392,7 +417,8 @@ class HttpCacheStream { /// 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) { + 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; @@ -409,7 +435,8 @@ class HttpCacheStream { /// 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; + int get cachePosition => + _cacheDownloader?.downloadPosition ?? cacheState.position; /// If this [HttpCacheStream] is retained. /// @@ -425,13 +452,15 @@ class HttpCacheStream { /// Returns null if the source length is unknown. Returns 1.0 only if the cache file exists. double? get progress => cacheState.progress; - CacheState get cacheState => _stateController.valueOrNull ?? const CacheState.zero(); + 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 => _stateController.errorOrNull; /// The current [CacheMetadata] for this [HttpCacheStream]. - CacheMetadata get metadata => CacheMetadata(files, sourceUrl, _cachedResponseHeaders); + CacheMetadata get metadata => + CacheMetadata(files, sourceUrl, _cachedResponseHeaders); /// The cached response headers for this [HttpCacheStream], if available. CachedResponseHeaders? get headers => _cachedResponseHeaders; @@ -473,7 +502,8 @@ class HttpCacheStream { final lifecycleConfig = config.lifecycleConfig; _lifeCycleTimer = Timer(lifecycleConfig.pauseAfter, () { - final remainingAfterPause = lifecycleConfig.disposeAfter - lifecycleConfig.pauseAfter; + final remainingAfterPause = + lifecycleConfig.disposeAfter - lifecycleConfig.pauseAfter; if (remainingAfterPause <= Duration.zero) { _performDispose(); return; @@ -501,5 +531,6 @@ class HttpCacheStream { Future get future => _disposeCompleter.future; @override - String toString() => 'HttpCacheStream{sourceUrl: $sourceUrl, cacheUrl: $cacheUrl, cacheFile: $cacheFile}'; + String toString() => + 'HttpCacheStream{sourceUrl: $sourceUrl, cacheUrl: $cacheUrl, cacheFile: $cacheFile}'; } diff --git a/lib/src/cache_stream/response_streams/download_stream.dart b/lib/src/cache_stream/response_streams/download_stream.dart index d0a6bb8..bba2430 100644 --- a/lib/src/cache_stream/response_streams/download_stream.dart +++ b/lib/src/cache_stream/response_streams/download_stream.dart @@ -27,12 +27,14 @@ class DownloadStream extends Stream> { request.headers[HttpHeaders.rangeHeader] = rangeRequest.header; } - final streamedResponse = await config.httpClient.sendWithTimeout(request, config.requestTimeout); + final streamedResponse = + await config.httpClient.sendWithTimeout(request, config.requestTimeout); try { if (rangeRequest == null) { HttpStatusCodeException.validateCompleteResponse(url, streamedResponse); } else { - HttpRangeException.validate(url, rangeRequest, HttpRangeResponse.parse(streamedResponse)); + HttpRangeException.validate( + url, rangeRequest, HttpRangeResponse.parse(streamedResponse)); } return DownloadStream(streamedResponse); } catch (e) { @@ -65,7 +67,8 @@ class DownloadStream extends Stream> { bool _listened = false; BaseResponse get baseResponse => _streamedResponse; - HttpRangeResponse? get responseRange => HttpRangeResponse.parse(_streamedResponse); + HttpRangeResponse? get responseRange => + HttpRangeResponse.parse(_streamedResponse); int? get sourceLength { if (baseResponse.headers.containsKey(HttpHeaders.contentRangeHeader)) { diff --git a/lib/src/models/metadata/cached_response_headers.dart b/lib/src/models/metadata/cached_response_headers.dart index 947506e..628f0f5 100644 --- a/lib/src/models/metadata/cached_response_headers.dart +++ b/lib/src/models/metadata/cached_response_headers.dart @@ -17,7 +17,8 @@ class CachedResponseHeaders { ///Compares this [CachedResponseHeaders] to the given [next] [CachedResponseHeaders] to determine if the cache is outdated. ///CachedResponseHeaders.fromFile() supports validating against a HEAD request by comparing sourceLength and lastModified. - static bool validateCacheResponse(final CachedResponseHeaders previous, final CachedResponseHeaders next) { + static bool validateCacheResponse( + final CachedResponseHeaders previous, final CachedResponseHeaders next) { if (previous.eTag != null && next.eTag != null) { return previous.eTag == next.eTag; } @@ -25,7 +26,8 @@ class CachedResponseHeaders { final previousLastModified = previous.lastModified; if (previousLastModified != null) { final nextLastModified = next.lastModified; - if (nextLastModified != null && nextLastModified.isAfter(previousLastModified)) { + if (nextLastModified != null && + nextLastModified.isAfter(previousLastModified)) { return false; } } @@ -48,7 +50,8 @@ class CachedResponseHeaders { bool shouldRevalidate() { final expirationDateTime = cacheExpirationDateTime; - return expirationDateTime == null || DateTime.now().isAfter(expirationDateTime); + return expirationDateTime == null || + DateTime.now().isAfter(expirationDateTime); } DateTime? get cacheExpirationDateTime { @@ -71,7 +74,9 @@ class CachedResponseHeaders { ContentType? get contentType { final contentTypeHeader = get(HttpHeaders.contentTypeHeader); - return contentTypeHeader == null ? null : ContentType.parse(contentTypeHeader); + return contentTypeHeader == null + ? null + : ContentType.parse(contentTypeHeader); } String? get eTag => get(HttpHeaders.etagHeader); @@ -89,10 +94,12 @@ class CachedResponseHeaders { /// Returns true if the response is compressed or chunked. This means that the content length != source length, and the source length cannot be determined until the download is complete. bool get isCompressedOrChunked { - return equals(HttpHeaders.contentEncodingHeader, 'gzip') || equals(HttpHeaders.transferEncodingHeader, 'chunked'); + return equals(HttpHeaders.contentEncodingHeader, 'gzip') || + equals(HttpHeaders.transferEncodingHeader, 'chunked'); } - DateTime? get lastModified => parseHeaderDateTime(HttpHeaders.lastModifiedHeader); + DateTime? get lastModified => + parseHeaderDateTime(HttpHeaders.lastModifiedHeader); DateTime? get responseDate => parseHeaderDateTime(HttpHeaders.dateHeader); ///Attempts to parse [DateTime] from the given [httpHeader]. @@ -100,7 +107,8 @@ class CachedResponseHeaders { final value = get(httpHeader); if (value == null || value.isEmpty) return null; try { - return HttpDate.parse(value); // Try to parse the date (not all servers return a valid date) + return HttpDate.parse( + value); // Try to parse the date (not all servers return a valid date) } catch (e) { return null; } @@ -152,13 +160,16 @@ class CachedResponseHeaders { final Map headers = {...response.headers}; if (headers.remove(HttpHeaders.contentRangeHeader) != null) { - headers[HttpHeaders.acceptRangesHeader] = 'bytes'; // Ensure accept-ranges is set to bytes for range responses. Not all servers do this. + headers[HttpHeaders.acceptRangesHeader] = + 'bytes'; // Ensure accept-ranges is set to bytes for range responses. Not all servers do this. - final HttpRangeResponse? rangeResponse = HttpRangeResponse.parse(response); + final HttpRangeResponse? rangeResponse = + HttpRangeResponse.parse(response); if (rangeResponse != null) { final int? rangeSourceLength = rangeResponse.sourceLength; if (rangeSourceLength != null) { - headers[HttpHeaders.contentLengthHeader] = rangeSourceLength.toString(); + headers[HttpHeaders.contentLengthHeader] = + rangeSourceLength.toString(); } else if (!rangeResponse.isFull) { headers.remove(HttpHeaders.contentLengthHeader); } @@ -175,10 +186,15 @@ class CachedResponseHeaders { Map requestHeaders = const {}, }) async { if (!requestHeaders.containsKey(HttpHeaders.acceptEncodingHeader)) { - requestHeaders = {...requestHeaders, HttpHeaders.acceptEncodingHeader: 'identity'}; + requestHeaders = { + ...requestHeaders, + HttpHeaders.acceptEncodingHeader: 'identity' + }; } - final response = await (httpClient?.head(url, headers: requestHeaders) ?? http.head(url, headers: requestHeaders)); - if (response.statusCode != HttpStatus.ok && response.statusCode != HttpStatus.partialContent) { + final response = await (httpClient?.head(url, headers: requestHeaders) ?? + http.head(url, headers: requestHeaders)); + if (response.statusCode != HttpStatus.ok && + response.statusCode != HttpStatus.partialContent) { throw HttpStatusCodeException(url, HttpStatus.ok, response.statusCode); } return CachedResponseHeaders.fromBaseResponse(response); @@ -189,7 +205,8 @@ class CachedResponseHeaders { if (cacheFiles.metadata.existsSync()) { final json = jsonDecodeBytes(cacheFiles.metadata.readAsBytesSync()); if (json is Map) { - final headersFromJson = CachedResponseHeaders.fromJson(json['headers']); + final headersFromJson = + CachedResponseHeaders.fromJson(json['headers']); if (headersFromJson != null) return headersFromJson; } } @@ -199,16 +216,19 @@ class CachedResponseHeaders { } } - static Future fromCacheFilesAsync(final CacheFiles cacheFiles) async { + 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']); + final headersFromJson = + CachedResponseHeaders.fromJson(json['headers']); if (headersFromJson != null) return headersFromJson; } } - return CachedResponseHeaders.fromFile(cacheFiles.complete, await cacheFiles.complete.stat()); + return CachedResponseHeaders.fromFile( + cacheFiles.complete, await cacheFiles.complete.stat()); } catch (_) { return null; } @@ -216,7 +236,8 @@ class CachedResponseHeaders { ///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, [FileStat? fileStat]) { + static CachedResponseHeaders? fromFile(final File file, + [FileStat? fileStat]) { fileStat ??= file.statSync(); final fileSize = fileStat.size; if (fileStat.type != FileSystemEntityType.file || fileSize <= 0) { @@ -227,7 +248,8 @@ class CachedResponseHeaders { final headers = { HttpHeaders.contentLengthHeader: fileSize.toString(), HttpHeaders.acceptRangesHeader: 'bytes', - if (contentTypeFromPath != null) HttpHeaders.contentTypeHeader: contentTypeFromPath, + if (contentTypeFromPath != null) + HttpHeaders.contentTypeHeader: contentTypeFromPath, HttpHeaders.lastModifiedHeader: HttpDate.format(fileStat.modified), }; return CachedResponseHeaders._(headers); @@ -248,7 +270,8 @@ class CachedResponseHeaders { return _headers; } - void forEach(void Function(String, String) action) => _headers.forEach(action); + void forEach(void Function(String, String) action) => + _headers.forEach(action); Map get headerMap => {..._headers}; diff --git a/test/e2e/dispose_test.dart b/test/e2e/dispose_test.dart index f85e89e..b1dff8c 100644 --- a/test/e2e/dispose_test.dart +++ b/test/e2e/dispose_test.dart @@ -20,7 +20,8 @@ void main() { 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 + final disposing = + stream.dispose(); // count 1: still retained, completes later expect(stream.isDisposed, isFalse); stream.release(); // count 0: must honor the pending dispose now diff --git a/test/io/cache_metadata_io_test.dart b/test/io/cache_metadata_io_test.dart index 48320b8..e1fd2ea 100644 --- a/test/io/cache_metadata_io_test.dart +++ b/test/io/cache_metadata_io_test.dart @@ -98,8 +98,8 @@ void main() { }); test('returns null for a missing or empty file', () async { - expect(CachedResponseHeaders.fromFile(File('${dir.path}/nope.bin')), - isNull); + 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/test_origin.dart b/test/support/test_origin.dart index bf041f8..00e1797 100644 --- a/test/support/test_origin.dart +++ b/test/support/test_origin.dart @@ -153,8 +153,8 @@ class TestOrigin { response.headers.set(HttpHeaders.etagHeader, etag!); } if (lastModified != null) { - response.headers.set( - HttpHeaders.lastModifiedHeader, HttpDate.format(lastModified!)); + response.headers + .set(HttpHeaders.lastModifiedHeader, HttpDate.format(lastModified!)); } if (cacheControl != null) { response.headers.set(HttpHeaders.cacheControlHeader, cacheControl!); diff --git a/test/unit/cached_response_headers_test.dart b/test/unit/cached_response_headers_test.dart index 8d840e7..22fa1a1 100644 --- a/test/unit/cached_response_headers_test.dart +++ b/test/unit/cached_response_headers_test.dart @@ -7,12 +7,14 @@ 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)); + return CachedResponseHeaders.fromBaseResponse( + http.Response('', status, headers: map)); } void main() { group('essential header extraction', () { - test('content-length, content-type, accept-ranges, etag, last-modified', () { + test('content-length, content-type, accept-ranges, etag, last-modified', + () { final lm = DateTime.utc(2024, 1, 1, 12); final h = headers({ 'content-length': '500', diff --git a/test/unit/future_runner_test.dart b/test/unit/future_runner_test.dart index 977a6c7..6b4bab6 100644 --- a/test/unit/future_runner_test.dart +++ b/test/unit/future_runner_test.dart @@ -27,7 +27,8 @@ void main() { expect(calls, 1); }); - test('clears the in-flight future after success so it can run again', () async { + 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); diff --git a/test/unit/http_range_test.dart b/test/unit/http_range_test.dart index 1384976..1e96ef5 100644 --- a/test/unit/http_range_test.dart +++ b/test/unit/http_range_test.dart @@ -36,7 +36,8 @@ void main() { group('HttpRangeResponse formatting', () { test('header round-trips with a known total', () { - expect(HttpRangeResponse(0, 99, sourceLength: 500).header, 'bytes 0-99/500'); + expect( + HttpRangeResponse(0, 99, sourceLength: 500).header, 'bytes 0-99/500'); }); test('header uses * for unknown total', () { diff --git a/test/unit/int_range_test.dart b/test/unit/int_range_test.dart index 58cbffb..a412da7 100644 --- a/test/unit/int_range_test.dart +++ b/test/unit/int_range_test.dart @@ -52,7 +52,8 @@ void main() { ]); }); - test('an open-ended range sorts after a closed one with the same start', () { + 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/url_codec_test.dart b/test/unit/url_codec_test.dart index 64ed3a4..d178705 100644 --- a/test/unit/url_codec_test.dart +++ b/test/unit/url_codec_test.dart @@ -38,7 +38,8 @@ void main() { }); test('query string is preserved', () { - expectRoundTrip(Uri.parse('https://cdn.example.com/v.m3u8?token=abc&x=1')); + expectRoundTrip( + Uri.parse('https://cdn.example.com/v.m3u8?token=abc&x=1')); }); test('multi-segment path is preserved', () { @@ -55,15 +56,16 @@ void main() { 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')), + 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); + expect( + server.decodeSourceUrl(server.serverUri.replace(path: '/x')), isNull); }); }); }