Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions flutter_cache_manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## [3.4.7] - 2026-10-27

* Detects whether a downloaded file can be resized by letting the image engine decode it, instead of from the extension of the url. Images served with a generic `Content-Type` such as `application/octet-stream` are now resized, and files that are not images are returned untouched ([#475](https://github.com/Baseflow/flutter_cache_manager/pull/475))
* Detects the image format from the file content, so every format the engine supports is resized, including webp and bmp ([#353](https://github.com/Baseflow/flutter_cache_manager/issues/353)). Animated images (gif, apng, animated webp) are passed 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
* Deprecates `supportedFileNames`, which is no longer used now that detection is content based

## [3.4.6] - 2026-10-27

* Catches unhandled asynchronous errors when updating cache-hit metadata and during scheduled cleanup in `CacheStore` ([#535](https://github.com/Baseflow/flutter_cache_manager/issues/535)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ import 'package:file/file.dart';
import 'package:flutter/widgets.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 @@ -73,27 +84,33 @@ mixin ImageCacheManager on BaseCacheManager {
int? maxWidth,
int? maxHeight,
) async {
final originalFileName = originalFile.file.path;
final fileExtension = originalFileName.split('.').last;
if (!supportedFileNames.contains(fileExtension)) {
final info = await _imageInfo(originalFile.file);
if (info == null) {
// The engine cannot decode this file, so it is not a resizable image.
// The original file is returned untouched instead of erroring the
// stream.
return originalFile;
}
final (frameCount, imageWidth, imageHeight) = info;

final image = await _decodeImage(originalFile.file);
// 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) {
return originalFile;
}

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

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

final resized = await _decodeImage(
Expand All @@ -111,7 +128,12 @@ mixin ImageCacheManager on BaseCacheManager {
resizedFile,
key: key,
maxAge: maxAge,
fileExtension: fileExtension,
// 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',
);

return FileInfo(
Expand Down Expand Up @@ -147,6 +169,34 @@ mixin ImageCacheManager on BaseCacheManager {
}
}

/// Describes the image in [file], or `null` when the engine cannot decode it.
///
/// 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 record holds the frame count of the image, which is how
/// animated images are recognized, and its width and height.
Future<(int frameCount, int width, int height)?> _imageInfo(File file) async {
if (!await file.exists()) {
return null;
}
final bytes = await file.readAsBytes();
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
ui.ImageDescriptor? descriptor;
ui.Codec? codec;
try {
descriptor = await ui.ImageDescriptor.encoded(buffer);
codec = await descriptor.instantiateCodec();
return (codec.frameCount, descriptor.width, descriptor.height);
} on Object {
return null;
} finally {
codec?.dispose();
descriptor?.dispose();
buffer.dispose();
}
}

Future<ui.Image> _decodeImage(
File file, {
int? width,
Expand Down
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.6
version: 3.4.7
homepage: https://github.com/Baseflow/flutter_cache_manager
topics:
- cache
Expand Down
146 changes: 146 additions & 0 deletions flutter_cache_manager/test/image_cache_manager_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';
Expand Down Expand Up @@ -127,6 +128,143 @@ void main() {
expect(progress, isNotEmpty);
});
});

group('Test image detection', () {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add tests for the new formats too: resizing a static webp and a bmp, checking that the resized file is stored with a .png extension, and that a file that fails to decode is returned untouched? Also note that the 'without file extension' case sets the file name directly, so it doesn't go through the Content-Type path where the real issue occurs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added: a static webp is resized, a bmp is resized, the resized file is asserted to be stored with a .png extension, and a file that fails to decode (a json payload served as .png) is returned untouched. Also added tests for animated gif, apng and animated webp being passed through, and for the Content-Type case (png payload served as .bin) you mentioned, since the extension-less test indeed bypasses that path.

test('Image without file extension should be resized', () async {
const urlWithoutExtension = 'baseflow.com/avatar/123456';
var config = createTestConfig();
await config.returnsFile('avatar', data: await getExampleImage());
config.returnsCacheObject(urlWithoutExtension, 'avatar', validTill);

var cacheManager = TestCacheManager(config);
var result =
await cacheManager
.getImageFile(urlWithoutExtension, maxWidth: 100)
.last
as FileInfo;

var image = await result.file.readAsBytes();
await verifySize(image, 100, 100);
});

test('Image with a non-image extension should be resized', () async {
// The stored file's extension does not come from the url, but from the
// Content-Type header of the response. A generic type such as
// application/octet-stream stores the file as .bin, even when the
// payload is an image.
const urlWithBinaryExtension = 'baseflow.com/image.bin';
var config = createTestConfig();
await config.returnsFile('image.bin', data: await getExampleImage());
config.returnsCacheObject(urlWithBinaryExtension, 'image.bin', validTill);

var cacheManager = TestCacheManager(config);
var result =
await cacheManager
.getImageFile(urlWithBinaryExtension, maxWidth: 100)
.last
as FileInfo;

var image = await result.file.readAsBytes();
await verifySize(image, 100, 100);
expect(result.file.path.endsWith('.png'), isTrue);
});

test('Static webp should be resized', () async {
const urlWithWebpExtension = 'baseflow.com/image.webp';
var config = createTestConfig();
await config.returnsFile('image.webp', data: await getExampleWebp());
config.returnsCacheObject(urlWithWebpExtension, 'image.webp', validTill);

var cacheManager = TestCacheManager(config);
var result =
await cacheManager
.getImageFile(urlWithWebpExtension, maxWidth: 100)
.last
as FileInfo;

var image = await result.file.readAsBytes();
await verifySize(image, 100, 100);
});

test('Bmp should be resized', () async {
const urlWithBmpExtension = 'baseflow.com/image.bmp';
var config = createTestConfig();
await config.returnsFile('image.bmp', data: await getExampleBmp());
config.returnsCacheObject(urlWithBmpExtension, 'image.bmp', validTill);

var cacheManager = TestCacheManager(config);
var result =
await cacheManager
.getImageFile(urlWithBmpExtension, maxWidth: 100)
.last
as FileInfo;

var image = await result.file.readAsBytes();
await verifySize(image, 100, 100);
});

test('Non image should not be resized', () async {
const urlWithImageExtension = 'baseflow.com/image.png';
var payload = utf8.encode('{"message":"not an image"}');
var config = createTestConfig();
await config.returnsFile('image.png', data: payload);
config.returnsCacheObject(urlWithImageExtension, 'image.png', validTill);

var cacheManager = TestCacheManager(config);
var result =
await cacheManager
.getImageFile(urlWithImageExtension, maxWidth: 100)
.last
as FileInfo;

expect(await result.file.readAsBytes(), payload);
});

test('Animated gif should not be resized', () async {
const urlOfAnimatedGif = 'baseflow.com/animation.gif';
var payload = await File('test/images/animated-120.gif').readAsBytes();
var config = createTestConfig();
await config.returnsFile('animation.gif', data: payload);
config.returnsCacheObject(urlOfAnimatedGif, 'animation.gif', validTill);

var cacheManager = TestCacheManager(config);
var result =
await cacheManager.getImageFile(urlOfAnimatedGif, maxWidth: 100).last
as FileInfo;

expect(await result.file.readAsBytes(), payload);
});

test('Animated webp should not be resized', () async {
const urlOfAnimatedWebp = 'baseflow.com/animation.webp';
var payload = await File('test/images/animated-120.webp').readAsBytes();
var config = createTestConfig();
await config.returnsFile('animation.webp', data: payload);
config.returnsCacheObject(urlOfAnimatedWebp, 'animation.webp', validTill);

var cacheManager = TestCacheManager(config);
var result =
await cacheManager.getImageFile(urlOfAnimatedWebp, maxWidth: 100).last
as FileInfo;

expect(await result.file.readAsBytes(), payload);
});

test('Animated png should not be resized', () async {
const urlOfAnimatedPng = 'baseflow.com/animation.png';
var payload = await File('test/images/animated-120.apng').readAsBytes();
var config = createTestConfig();
await config.returnsFile('animation.png', data: payload);
config.returnsCacheObject(urlOfAnimatedPng, 'animation.png', validTill);

var cacheManager = TestCacheManager(config);
var result =
await cacheManager.getImageFile(urlOfAnimatedPng, maxWidth: 100).last
as FileInfo;

expect(await result.file.readAsBytes(), payload);
});
});
}

Future<TestCacheManager> setupCacheManager() async {
Expand Down Expand Up @@ -157,3 +295,11 @@ Future verifySize(
Future<Uint8List> getExampleImage() {
return File('test/images/image-120.png').readAsBytes();
}

Future<Uint8List> getExampleWebp() {
return File('test/images/image-120.webp').readAsBytes();
}

Future<Uint8List> getExampleBmp() {
return File('test/images/image-120.bmp').readAsBytes();
}
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added flutter_cache_manager/test/images/image-120.bmp
Binary file not shown.
Binary file added flutter_cache_manager/test/images/image-120.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.