From c8ac64f87b3a504f7e5170bf11a62457b8026a81 Mon Sep 17 00:00:00 2001 From: Guillaume LE MARTRET Date: Fri, 20 Dec 2024 16:07:21 +0100 Subject: [PATCH 1/3] feat: add progressive JPEG decoding --- .../lib/src/result/interlaced_progress.dart | 10 +++ .../lib/src/result/result.dart | 1 + .../interlaced/interlaced_transformer.dart | 76 +++++++++++++++++++ .../interlaced/progressive_jpeg_decoder.dart | 73 ++++++++++++++++++ .../lib/src/web/web_helper.dart | 59 +++++++++----- 5 files changed, 201 insertions(+), 18 deletions(-) create mode 100644 flutter_cache_manager/lib/src/result/interlaced_progress.dart create mode 100644 flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart create mode 100644 flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart diff --git a/flutter_cache_manager/lib/src/result/interlaced_progress.dart b/flutter_cache_manager/lib/src/result/interlaced_progress.dart new file mode 100644 index 00000000..3c85ce12 --- /dev/null +++ b/flutter_cache_manager/lib/src/result/interlaced_progress.dart @@ -0,0 +1,10 @@ +import 'dart:typed_data'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +class InterlacedProgress extends DownloadProgress { + InterlacedProgress( + super.originalUrl, super.totalSize, super.downloaded, this.data); + + final Uint8List data; +} diff --git a/flutter_cache_manager/lib/src/result/result.dart b/flutter_cache_manager/lib/src/result/result.dart index fba37a7f..60dc84c3 100644 --- a/flutter_cache_manager/lib/src/result/result.dart +++ b/flutter_cache_manager/lib/src/result/result.dart @@ -1,3 +1,4 @@ export 'download_progress.dart'; export 'file_info.dart'; export 'file_response.dart'; +export 'interlaced_progress.dart'; diff --git a/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart b/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart new file mode 100644 index 00000000..1a7622d3 --- /dev/null +++ b/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_cache_manager/src/web/interlaced/progressive_jpeg_decoder.dart'; + +class InterlacedData { + final Uint8List data; + InterlacedData(this.data); +} + +class InterlacedConverter extends Converter, InterlacedData> { + const InterlacedConverter(); + + @override + InterlacedData convert(List input) => + InterlacedData(Uint8List.fromList(input)); + + @override + Sink startChunkedConversion(Sink sink) => + InterlacedByteConversionSink(sink); +} + +class InterlacedByteConversionSink implements ChunkedConversionSink { + final Sink _output; + + // Buffer to accumulate chunks + BytesBuilder? _buffer = BytesBuilder(); + + InterlacedDecoder? _decoder; + + InterlacedByteConversionSink(this._output); + + @override + void add(List chunk) { + // Ensure buffer is not null (should not happen in normal flow) + final buffer = _buffer; + if (buffer == null) { + throw StateError('Sink has been closed and cannot accept new data.'); + } + + _decoder ??= resolveDecoder(); + + if (_decoder == null) { + return _buffer!.add(chunk); + } + + final interlacedData = _decoder?.addChunk(chunk); + if (interlacedData != null) { + _output.add(interlacedData); + } + } + + @override + void close() { + _buffer?.clear(); + _buffer = null; + _decoder = null; + _output.close(); + } + + InterlacedDecoder? resolveDecoder() { + if (ProgressiveJPEGDecoder.isProgressiveJPEG(_buffer)) { + return ProgressiveJPEGDecoder(_buffer!); + } + return null; + } +} + +// Base class for interlaced format decoders +abstract class InterlacedDecoder { + final BytesBuilder buffer; + + InterlacedDecoder(this.buffer); + + InterlacedData? addChunk(List chunk); +} diff --git a/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart b/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart new file mode 100644 index 00000000..b73795ab --- /dev/null +++ b/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart @@ -0,0 +1,73 @@ +import 'dart:typed_data'; + +import 'package:flutter_cache_manager/src/web/interlaced/interlaced_transformer.dart'; + +// Decoder for progressive JPEG images +class ProgressiveJPEGDecoder extends InterlacedDecoder { + static bool isProgressiveJPEG(BytesBuilder? buffer) { + if (buffer == null) return false; + + final data = buffer.toBytes(); + + if (data.length < 4) return false; + + // Check for the SOI (Start of Image) + if (data[0] == 0xFF && data[1] == 0xD8) { + // Check for the first SOF marker + for (int i = 2; i < data.length - 1; i++) { + if (data[i] == 0xFF && data[i + 1] >= 0xC0 && data[i + 1] <= 0xCF) { + return data[i + 1] == 0xC2; + } + } + } + + return false; + } + + // List of valid offsets + final List _validOffsets = []; + + ProgressiveJPEGDecoder(super.buffer); + + @override + InterlacedData? addChunk(List chunk) { + // Calculate startOffset before adding the new chunk + int startOffset = + (buffer.length - 1).clamp(0, buffer.length); // Ensure valid bounds + + // Add the new chunk to the buffer + buffer.add(chunk); + + _updateOffsets(buffer.toBytes(), startOffset); + + return _getBestData(); + } + + InterlacedData? _getBestData() { + // Get the best valid data using the latest valid offset + if (_validOffsets.isNotEmpty) { + final data = + Uint8List.sublistView(buffer.toBytes(), 0, _validOffsets.last); + + data[data.length - 1] = 0xd9; // Fix the last byte as EOI + return InterlacedData(data); + } + + return null; + } + + void _updateOffsets(Uint8List data, int startOffset) { + // Iterate through data starting from the adjusted offset + for (int i = startOffset; i < data.length - 1; i++) { + if (data[i] == 0xFF && _isMarker(data[i + 1])) { + // Add offset of the marker's end + _validOffsets.add(i + 2); + } + } + } + + bool _isMarker(int byte) { + // Check for JPEG markers: 0xDA (Start of Scan) or 0xD9 (End of Image) + return byte == 0xDA || byte == 0xD9; + } +} diff --git a/flutter_cache_manager/lib/src/web/web_helper.dart b/flutter_cache_manager/lib/src/web/web_helper.dart index d5b77146..8bb32cc4 100644 --- a/flutter_cache_manager/lib/src/web/web_helper.dart +++ b/flutter_cache_manager/lib/src/web/web_helper.dart @@ -6,6 +6,7 @@ import 'package:clock/clock.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/src/cache_store.dart'; +import 'package:flutter_cache_manager/src/web/interlaced/interlaced_transformer.dart'; import 'package:flutter_cache_manager/src/web/queue_item.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; @@ -145,14 +146,37 @@ class WebHelper { var newCacheObject = _setDataFromHeaders(cacheObject, response); if (statusCodesNewFile.contains(response.statusCode)) { var savedBytes = 0; - await for (final progress in _saveFile(newCacheObject, response)) { - savedBytes = progress; - yield DownloadProgress( - cacheObject.url, - response.contentLength, - progress, - ); + final chunkStream = + _saveFile(newCacheObject, response).asBroadcastStream(); + + final stream = MergeStream([ + chunkStream, + chunkStream + .transform(const InterlacedConverter()) + .distinctUnique( + equals: (a, b) => a.data.length == b.data.length, + hashCode: (e) => e.data.length, + ), + ]); + + await for (final progress in stream) { + if (progress is InterlacedData) { + yield InterlacedProgress( + cacheObject.url, + response.contentLength, + progress.data.length, + progress.data, + ); + } else if (progress is List) { + savedBytes += progress.length; + yield DownloadProgress( + cacheObject.url, + response.contentLength, + savedBytes, + ); + } } + newCacheObject = newCacheObject.copyWith(length: savedBytes); } @@ -195,8 +219,11 @@ class WebHelper { ); } - Stream _saveFile(CacheObject cacheObject, FileServiceResponse response) { - final receivedBytesResultController = StreamController(); + Stream> _saveFile( + CacheObject cacheObject, + FileServiceResponse response, + ) { + final receivedBytesResultController = StreamController>(); _saveFileAndPostUpdates( receivedBytesResultController, cacheObject, @@ -206,22 +233,18 @@ class WebHelper { } Future _saveFileAndPostUpdates( - StreamController receivedBytesResultController, + StreamController> receivedBytesResultController, CacheObject cacheObject, FileServiceResponse response, ) async { final file = await _store.fileSystem.createFile(cacheObject.relativePath); try { - var receivedBytes = 0; final sink = file.openWrite(); - await response.content - .map((s) { - receivedBytes += s.length; - receivedBytesResultController.add(receivedBytes); - return s; - }) - .pipe(sink); + await response.content.map((s) { + receivedBytesResultController.add(s); + return s; + }).pipe(sink); } on Object catch (e, stacktrace) { receivedBytesResultController.addError(e, stacktrace); } From 9dde7cba9fe1e6501c5c8de59bda84afd86b7aac Mon Sep 17 00:00:00 2001 From: Guillaume LE MARTRET Date: Wed, 30 Apr 2025 11:12:28 +0200 Subject: [PATCH 2/3] fix: avoid useless decoder resolving --- .../interlaced/interlaced_transformer.dart | 44 ++++++++++++++++++- .../interlaced/progressive_jpeg_decoder.dart | 25 ++++++----- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart b/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart index 1a7622d3..8e127c72 100644 --- a/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart +++ b/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart @@ -20,6 +20,17 @@ class InterlacedConverter extends Converter, InterlacedData> { InterlacedByteConversionSink(sink); } +/// Represents a decoder check function and its corresponding decoder constructor +class DecoderCheck { + final bool? Function(BytesBuilder) check; + final InterlacedDecoder Function(BytesBuilder) createDecoder; + + const DecoderCheck({ + required this.check, + required this.createDecoder, + }); +} + class InterlacedByteConversionSink implements ChunkedConversionSink { final Sink _output; @@ -28,6 +39,13 @@ class InterlacedByteConversionSink implements ChunkedConversionSink { InterlacedDecoder? _decoder; + static final _decoderChecks = [ + DecoderCheck( + check: ProgressiveJPEGDecoder.isProgressiveJPEG, + createDecoder: (buffer) => ProgressiveJPEGDecoder(buffer), + ), + ]; + InterlacedByteConversionSink(this._output); @override @@ -59,9 +77,31 @@ class InterlacedByteConversionSink implements ChunkedConversionSink { } InterlacedDecoder? resolveDecoder() { - if (ProgressiveJPEGDecoder.isProgressiveJPEG(_buffer)) { - return ProgressiveJPEGDecoder(_buffer!); + // Try each decoder check + for (final decoderCheck in _decoderChecks) { + final result = decoderCheck.check(_buffer!); + if (result == true) { + return decoderCheck.createDecoder(_buffer!); + } } + + // Check if all decoders returned false + if (_decoderChecks.every( + (check) => check.check(_buffer!) == false, + )) { + return DumbDecoder(_buffer!); + } + + return null; + } +} + +class DumbDecoder extends InterlacedDecoder { + DumbDecoder(super.buffer); + + @override + InterlacedData? addChunk(List chunk) { + buffer.add(chunk); return null; } } diff --git a/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart b/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart index b73795ab..200a5a6d 100644 --- a/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart +++ b/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart @@ -4,24 +4,29 @@ import 'package:flutter_cache_manager/src/web/interlaced/interlaced_transformer. // Decoder for progressive JPEG images class ProgressiveJPEGDecoder extends InterlacedDecoder { - static bool isProgressiveJPEG(BytesBuilder? buffer) { - if (buffer == null) return false; + /// Returns true if the buffer is a progressive JPEG image + /// Returns false if the buffer is not a progressive JPEG image + /// Returns null if the buffer is not enough to determine if it is a progressive JPEG image + static bool? isProgressiveJPEG(BytesBuilder? buffer) { + if (buffer == null) return null; final data = buffer.toBytes(); - if (data.length < 4) return false; + if (data.length < 4) return null; // Check for the SOI (Start of Image) - if (data[0] == 0xFF && data[1] == 0xD8) { - // Check for the first SOF marker - for (int i = 2; i < data.length - 1; i++) { - if (data[i] == 0xFF && data[i + 1] >= 0xC0 && data[i + 1] <= 0xCF) { - return data[i + 1] == 0xC2; - } + if (data[0] != 0xFF || data[1] != 0xD8) { + return false; + } + + // Check for the first SOF marker + for (int i = 2; i < data.length - 1; i++) { + if (data[i] == 0xFF && data[i + 1] >= 0xC0 && data[i + 1] <= 0xCF) { + return data[i + 1] == 0xC2; } } - return false; + return null; } // List of valid offsets From 219e1a9c4b1a39d4ec99d0840d395983701f48db Mon Sep 17 00:00:00 2001 From: Guillaume LE MARTRET Date: Mon, 21 Sep 2026 15:10:17 +0200 Subject: [PATCH 3/3] chore: rebase on 3.4.5 --- .vscode/launch.json | 28 +++++++++++++++++++ .vscode/settings.json | 11 ++++++++ flutter_cache_manager/analysis_options.yaml | 3 ++ .../example/analysis_options.yaml | 9 ++++++ flutter_cache_manager/example/lib/main.dart | 4 ++- .../plugin_example/progress_indicator.dart | 2 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 ++ flutter_cache_manager/pubspec.yaml | 2 +- .../analysis_options.yaml | 3 ++ 9 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..98cf289a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "example", + "cwd": "flutter_cache_manager/example", + "request": "launch", + "type": "dart" + }, + { + "name": "example (profile mode)", + "cwd": "flutter_cache_manager/example", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "cwd": "flutter_cache_manager/example", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..bffaa747 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "multiCommand.commands": [ + { + "command": "multiCommand.dartSave", + "interval": 100, + "sequence": [ + "workbench.action.files.save" + ] + } + ], +} \ No newline at end of file diff --git a/flutter_cache_manager/analysis_options.yaml b/flutter_cache_manager/analysis_options.yaml index f9b30346..801d885f 100644 --- a/flutter_cache_manager/analysis_options.yaml +++ b/flutter_cache_manager/analysis_options.yaml @@ -1 +1,4 @@ +analyzer: + exclude: + - build/** include: package:flutter_lints/flutter.yaml diff --git a/flutter_cache_manager/example/analysis_options.yaml b/flutter_cache_manager/example/analysis_options.yaml index f9b30346..743e05ad 100644 --- a/flutter_cache_manager/example/analysis_options.yaml +++ b/flutter_cache_manager/example/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/flutter_cache_manager/example/lib/main.dart b/flutter_cache_manager/example/lib/main.dart index ee34ca00..e5e6fc29 100644 --- a/flutter_cache_manager/example/lib/main.dart +++ b/flutter_cache_manager/example/lib/main.dart @@ -17,7 +17,9 @@ void main() { CacheManager.logLevel = CacheManagerLogLevel.verbose; } -const url = 'https://picsum.photos/200/300'; +const url = + // 'https://images.pexels.com/photos/1624496/pexels-photo-1624496.jpeg'; + 'https://images.pexels.com/photos/1037992/pexels-photo-1037992.jpeg'; /// Example [Widget] showing the functionalities of flutter_cache_manager class CacheManagerPage extends StatefulWidget { diff --git a/flutter_cache_manager/example/lib/plugin_example/progress_indicator.dart b/flutter_cache_manager/example/lib/plugin_example/progress_indicator.dart index 549ff670..c3710222 100644 --- a/flutter_cache_manager/example/lib/plugin_example/progress_indicator.dart +++ b/flutter_cache_manager/example/lib/plugin_example/progress_indicator.dart @@ -17,7 +17,7 @@ class ProgressIndicator extends StatelessWidget { SizedBox( width: 50, height: 50, - child: CircularProgressIndicator.adaptive( + child: CircularProgressIndicator( value: progress?.progress, ), ), diff --git a/flutter_cache_manager/example/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter_cache_manager/example/macos/Flutter/GeneratedPluginRegistrant.swift index 645e27da..368554e0 100644 --- a/flutter_cache_manager/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter_cache_manager/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,12 @@ import FlutterMacOS import Foundation +import path_provider_foundation import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 3f0c9ce3..17d12c24 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -6,7 +6,7 @@ topics: - cache - cache-manager environment: - sdk: '>=3.8.0 <4.0.0' + sdk: ">=3.8.0 <4.0.0" dependencies: clock: ^1.1.2 diff --git a/flutter_cache_manager_firebase/analysis_options.yaml b/flutter_cache_manager_firebase/analysis_options.yaml index f9b30346..801d885f 100644 --- a/flutter_cache_manager_firebase/analysis_options.yaml +++ b/flutter_cache_manager_firebase/analysis_options.yaml @@ -1 +1,4 @@ +analyzer: + exclude: + - build/** include: package:flutter_lints/flutter.yaml