Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4e03901
1
myafer Dec 4, 2024
74e71ca
Update file_service_compat.dart
myafer Dec 4, 2024
b6105f7
Update image_cache_manager.dart
myafer Dec 4, 2024
6028618
fix: fix method name
myafer Dec 4, 2024
4ec2651
Update build.gradle
myafer Dec 5, 2024
b8ebe93
Update build.gradle
myafer Dec 5, 2024
1d73d2e
1
myafer Dec 5, 2024
64ff206
Update build.gradle
myafer Dec 5, 2024
1a55b01
Update build.gradle
myafer Dec 5, 2024
b0ed040
Merge main into develop
rickdijk Sep 29, 2026
2132fb4
fix(image): detect resizable images by content instead of url extension
myafer Oct 19, 2026
3a498ba
fix(image): also detect bmp by signature and wbmp by extension
myafer Oct 19, 2026
a7dac2a
fix(image): match jpeg on its two byte signature
myafer Oct 19, 2026
780d662
fix(image): store the resized image with a png extension
myafer Oct 19, 2026
e9eca3a
fix(image): keep an animated webp out of the resizer
myafer Oct 19, 2026
fe77da7
docs(image): explain why ico and cur overlap with tga
myafer Oct 19, 2026
02775e7
refactor(image): let the engine decide whether a file can be resized
myafer Oct 27, 2026
cd492ed
Merge remote-tracking branch 'upstream/main' into develop
myafer Oct 27, 2026
2b58ba8
fix(image): read the size from the first frame and prefer the file path
myafer Oct 29, 2026
fea6c33
test(image): embed the fixture images so the tests also run on web
myafer Oct 29, 2026
e077cfa
Merge branch 'main' into develop
myafer Oct 31, 2026
4853ea1
Merge remote-tracking branch 'upstream/main' into develop
myafer Nov 4, 2026
c0462d9
refactor(image): reuse the probed descriptor and remember non-resizab…
myafer Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions flutter_cache_manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## [3.5.0] - 2026-11-04

* **DEPRECATION** Deprecates `supportedFileNames`: whether an image is resized is now decided by the image engine from the file's content instead of from the file extension derived from the `Content-Type` header of the response ([#475](https://github.com/Baseflow/flutter_cache_manager/pull/475))
* Resizes every format the engine can decode, including webp and bmp ([#353](https://github.com/Baseflow/flutter_cache_manager/issues/353)), and images served with a generic `Content-Type` such as `application/octet-stream`; files that are not images are returned untouched
* Passes animated images (gif, apng, animated webp) through untouched, since the resized image is always encoded as png and would lose its animation
* Stores resized images with a `.png` extension, matching the encoding that is used, instead of reusing the extension derived from the `Content-Type` header
* Returns the original file with a warning log through `cacheLogger` when an image cannot be decoded or resized, instead of erroring the stream. This reverses the decode error propagation introduced in 3.4.5 for [#400](https://github.com/Baseflow/flutter_cache_manager/issues/400)

## [3.4.8] - 2026-11-02

* Uses the URL's file extension when a download's content type is missing or generic, such as `application/octet-stream` or `binary/octet-stream` ([#489](https://github.com/Baseflow/flutter_cache_manager/issues/489)).
Expand Down
291 changes: 200 additions & 91 deletions flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,22 @@ import 'dart:math';
import 'dart:ui' as ui;

import 'package:file/file.dart';
import 'package:flutter/widgets.dart';
import 'package:file/local.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';

/// Formats that used to be recognized by the extension of the file.
///
/// Whether a file can be resized is now decided by the image engine, based on
/// the content of the file, so this list no longer reflects which files are
/// resized. It is kept only for backwards compatibility.
@Deprecated(
'Image formats are detected from the file content instead of the file '
'extension. This list is no longer used and will be removed in a future '
'version.',
)
const supportedFileNames = ['jpg', 'jpeg', 'png', 'tga', 'cur', 'ico'];

mixin ImageCacheManager on BaseCacheManager {
/// Returns a resized image file to fit within maxHeight and maxWidth. It
/// tries to keep the aspect ratio. It stores the resized image by adding
Expand Down Expand Up @@ -67,60 +79,15 @@ mixin ImageCacheManager on BaseCacheManager {

final Map<String, Stream<FileResponse>> _runningResizes = {};

Future<FileInfo> _resizeImageFile(
FileInfo originalFile,
String key,
int? maxWidth,
int? maxHeight,
) async {
final originalFileName = originalFile.file.path;
final fileExtension = originalFileName.split('.').last;
if (!supportedFileNames.contains(fileExtension)) {
return originalFile;
}

final image = await _decodeImage(originalFile.file);

final shouldResize = maxWidth != null
? image.width > maxWidth
: false || maxHeight != null
? image.height > maxHeight
: false;
if (!shouldResize) return originalFile;
if (maxWidth != null && maxHeight != null) {
final resizeFactorWidth = image.width / maxWidth;
final resizeFactorHeight = image.height / maxHeight;
final resizeFactor = max(resizeFactorHeight, resizeFactorWidth);

maxWidth = (image.width / resizeFactor).round();
maxHeight = (image.height / resizeFactor).round();
}

final resized = await _decodeImage(
originalFile.file,
width: maxWidth,
height: maxHeight,
);
final resizedFile = (await resized.toByteData(
format: ui.ImageByteFormat.png,
))!.buffer.asUint8List();
final maxAge = originalFile.validTill.difference(DateTime.now());

final file = await putFile(
originalFile.originalUrl,
resizedFile,
key: key,
maxAge: maxAge,
fileExtension: fileExtension,
);

return FileInfo(
file,
originalFile.source,
originalFile.validTill,
originalFile.originalUrl,
);
}
/// Keys whose original file is known not to be resizable: it is not an
/// image the engine can decode, it is animated, or it already fits within
/// the requested size. Probing a file reads it and decodes a frame, so the
/// outcome is remembered to avoid repeating that work on every call.
///
/// The entries are not invalidated when the original file is re-downloaded
/// with different content; they are reset when a resize does succeed or
/// when the manager instance is recreated.
final Set<String> _notResizableKeys = {};

Stream<FileResponse> _fetchedResizedFile(
String url,
Expand All @@ -141,49 +108,191 @@ mixin ImageCacheManager on BaseCacheManager {
yield response;
}
if (response is FileInfo) {
yield await _resizeImageFile(response, resizedKey, maxWidth, maxHeight);
if (_notResizableKeys.contains(resizedKey)) {
yield response;
} else {
yield await _resizeImageFile(
response,
resizedKey,
maxWidth: maxWidth,
maxHeight: maxHeight,
);
}
}
}
}

Future<FileInfo> _resizeImageFile(
FileInfo originalFile,
String resizedKey, {
int? maxWidth,
int? maxHeight,
}) async {
final file = originalFile.file;
final probe = await _probeImage(file);
if (probe == null) {
return _notResizable(originalFile, resizedKey);
}
final (descriptor, frameCount, imageWidth, imageHeight) = probe;

// Resizing re-encodes the image as png, which keeps a single frame, so
// animated images (gif, apng, animated webp, ...) are passed through
// untouched.
if (frameCount > 1) {
descriptor.dispose();
return _notResizable(originalFile, resizedKey);
}

final shouldResize =
(maxWidth != null && imageWidth > maxWidth) ||
(maxHeight != null && imageHeight > maxHeight);
if (!shouldResize) {
descriptor.dispose();
return _notResizable(originalFile, resizedKey);
}

if (maxWidth != null && maxHeight != null) {
final resizeFactorWidth = imageWidth / maxWidth;
final resizeFactorHeight = imageHeight / maxHeight;
final resizeFactor = max(resizeFactorHeight, resizeFactorWidth);

maxWidth = (imageWidth / resizeFactor).round();
maxHeight = (imageHeight / resizeFactor).round();
}

try {
final resizedFile = await _decodeResized(
descriptor,
width: maxWidth,
height: maxHeight,
);
final maxAge = originalFile.validTill.difference(DateTime.now());

final resized = await putFile(
originalFile.originalUrl,
resizedFile,
key: resizedKey,
maxAge: maxAge,
// The resized image is always encoded as png, so it is stored with a
// png extension. The extension of the original file cannot be reused:
// it comes from the Content-Type header of the response, which can be
// something generic like `application/octet-stream` instead of the
// format the image is actually encoded in.
fileExtension: 'png',
);

_notResizableKeys.remove(resizedKey);
return FileInfo(
resized,
originalFile.source,
originalFile.validTill,
originalFile.originalUrl,
);
} on Object catch (error) {
_logResizeWarning(file, error);
return _notResizable(originalFile, resizedKey);
} finally {
descriptor.dispose();
}
}

/// Remembers that the file of [originalFile] does not need to be probed
/// again for [resizedKey] and returns it untouched.
FileInfo _notResizable(FileInfo originalFile, String resizedKey) {
if (_notResizableKeys.length > 512) _notResizableKeys.clear();
_notResizableKeys.add(resizedKey);
return originalFile;
}
}

/// Probes [file] with the image engine.
///
/// Returns the encoded image descriptor together with its frame count and
/// size, or `null` when the engine cannot decode the file. Whether a file is
/// a resizable image is decided entirely by the engine: it knows every format
/// Flutter can decode, including platform specific ones like heif, so there
/// is no list of signatures or extensions to maintain.
///
/// The returned descriptor is not disposed and has to be disposed by the
/// caller; it can be used to decode a resized frame without reading the file
/// again. When `null` is returned everything is cleaned up here.
Future<(ui.ImageDescriptor, int frameCount, int width, int height)?>
_probeImage(File file) async {
ui.ImmutableBuffer? buffer;
ui.ImageDescriptor? descriptor;
ui.Codec? codec;
try {
buffer = await _bufferFor(file);
descriptor = await ui.ImageDescriptor.encoded(buffer);
codec = await descriptor.instantiateCodec();
final frameCount = codec.frameCount;
int width;
int height;
if (kIsWeb) {
// `descriptor.width` and `descriptor.height` are not supported on web
// (they throw), so the size is taken from the first frame. This fully
// decodes that frame, which is only paid on web.
final frame = await codec.getNextFrame();
width = frame.image.width;
height = frame.image.height;
frame.image.dispose();
} else {
width = descriptor.width;
height = descriptor.height;
}
return (descriptor, frameCount, width, height);
} on Object catch (error) {
descriptor?.dispose();
_logResizeWarning(file, error);
return null;
} finally {
codec?.dispose();
buffer?.dispose();
}
}

Future<ui.Image> _decodeImage(
File file, {
/// Creates the buffer for [file].
///
/// The path is only used when the file is known to be backed by the real file
/// system: `ImmutableBuffer.fromFilePath` reads `file.path` directly from
/// disk, which would bypass a custom `Config.fileSystem` whose bytes might
/// not be readable at their path (encrypted or in-memory file systems). On
/// web there is no file path to read from either.
Future<ui.ImmutableBuffer> _bufferFor(File file) async {
if (!kIsWeb && file.fileSystem is LocalFileSystem) {
return ui.ImmutableBuffer.fromFilePath(file.path);
}
return ui.ImmutableBuffer.fromUint8List(await file.readAsBytes());
}

/// Decodes the image described by [descriptor] into a png-encoded
/// [Uint8List] that fits within [width] and [height].
///
/// Decoding with a target size straight from the descriptor reads the file
/// once; no separate resize provider is needed.
Future<Uint8List> _decodeResized(
ui.ImageDescriptor descriptor, {
int? width,
int? height,
bool allowUpscaling = false,
}) {
final shouldResize = width != null || height != null;
final fileImage = FileImage(file);
final image = shouldResize
? ResizeImage(
fileImage,
width: width,
height: height,
allowUpscaling: allowUpscaling,
)
: fileImage as ImageProvider;

final completer = Completer<ui.Image>();
final stream = image.resolve(ImageConfiguration.empty);

late final ImageStreamListener listener;
listener = ImageStreamListener(
(info, _) {
if (completer.isCompleted) return;

stream.removeListener(listener);
image.evict();
completer.complete(info.image);
},
onError: (Object error, StackTrace? stackTrace) {
if (completer.isCompleted) return;

stream.removeListener(listener);
completer.completeError(error, stackTrace);
},
}) async {
final codec = await descriptor.instantiateCodec(
targetWidth: width,
targetHeight: height,
);
final frame = await codec.getNextFrame();
try {
final data = await frame.image.toByteData(format: ui.ImageByteFormat.png);
return data!.buffer.asUint8List();
} finally {
frame.image.dispose();
codec.dispose();
}
}

stream.addListener(listener);
return completer.future;
void _logResizeWarning(File file, Object error) {
cacheLogger.log(
'ImageCacheManager: could not resize "${file.path}", '
'returning the original file ($error)',
CacheManagerLogLevel.warning,
);
}
2 changes: 1 addition & 1 deletion flutter_cache_manager/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: flutter_cache_manager
description: Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite.
version: 3.4.8
version: 3.5.0
homepage: https://github.com/Baseflow/flutter_cache_manager
topics:
- cache
Expand Down
5 changes: 3 additions & 2 deletions flutter_cache_manager/test/helpers/mock_file_service.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import 'dart:io';
import 'dart:convert';

import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_cache_manager/src/web/file_service.dart';
import 'package:mockito/mockito.dart';

import '../mock.mocks.dart';
import '../test_images.dart';

class MockFileService extends MockFileServiceBase {
MockFileService._();
Expand All @@ -26,7 +27,7 @@ class MockFileService extends MockFileServiceBase {
class TestResponse extends FileServiceResponse {
@override
Stream<List<int>> get content async* {
var bytes = await File('test/images/image-120.png').readAsBytes();
var bytes = base64Decode(imagePng120);
var length = bytes.length;
var firstPart = (length / 2).floor();
yield bytes.sublist(0, firstPart);
Expand Down
Loading
Loading