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
6 changes: 6 additions & 0 deletions flutter_cache_manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [3.4.6] - 2026-10-19

* Detects whether a downloaded file can be resized from its content instead of from the extension of its url, so images served from extension-less urls or with a non-image `Content-Type` are resized, and responses that are no longer images are returned untouched ([#475](https://github.com/Baseflow/flutter_cache_manager/pull/475))
* Stores resized images with a `.png` extension, matching the encoding that is used, instead of reusing the extension of the url — which broke resizing for urls without an extension
* Adds webp, bmp and wbmp to the formats that are resized ([#353](https://github.com/Baseflow/flutter_cache_manager/issues/353)). An animated webp is passed through untouched, since the resized image is always encoded as png and would lose its animation, the same reason gif is not resized

## [3.4.5] - 2026-09-18

* Prevents image resize decoding from completing more than once and propagates decoding errors ([#400](https://github.com/Baseflow/flutter_cache_manager/issues/400)).
Expand Down
101 changes: 97 additions & 4 deletions flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';

const supportedFileNames = ['jpg', 'jpeg', 'png', 'tga', 'cur', 'ico'];

/// Signatures (magic numbers) of the image formats that can be recognized by
/// the first bytes of the file, instead of by its extension.
///
/// Gif is deliberately missing: the resizer re-encodes to png, which would turn
/// an animation into a single frame.
///
/// The ico and cur values overlap with tga, which starts with the same four
/// bytes when it has no id field and no colour map (byte 2 is then the image
/// type, 1 for a colour mapped and 2 for a true colour image). Telling them
/// apart takes more than four bytes, but it does not matter here: all of them
/// are resizable, and only the answer to "is this an image" is used.
const _imageSignatures = <List<int>>[
[0xFF, 0xD8], // jpeg
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], // png
[0x42, 0x4D], // bmp
[0x00, 0x00, 0x01, 0x00], // ico
[0x00, 0x00, 0x02, 0x00], // cur
];

/// Extensions of the formats that have no usable signature at the start of the
/// file, so the only way to recognize them is by their extension. Tga only
/// stores a signature in its footer, and wbmp starts with a two byte header
/// (`00 00`) that is too generic to match on.
const _extensionOnlyFormats = ['tga', 'wbmp'];

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 @@ -65,6 +91,33 @@ mixin ImageCacheManager on BaseCacheManager {
_runningResizes.remove(resizedKey);
}

/// Whether [file] holds an image that can be decoded and resized, based on
/// the content of the file rather than on the extension of its url. A
/// response can be served without a file extension, or with an extension
/// that does not match the actual content, for example when it is served
/// with an incorrect `Content-Type` header.
///
/// Images that would lose something when they are re-encoded as png, such as
/// an animated gif or an animated webp, are not considered an image here, so
/// they are passed through untouched.
Future<bool> _isImage(File file) async {
if (!await file.exists()) {
return false;
}

final header = await _readHeader(file);
if (_imageSignatures.any((signature) => _matches(header, signature))) {
return true;
}
// WebP is a RIFF container: "RIFF" + 4 bytes of length + "WEBP".
if (_matches(header, 'RIFF'.codeUnits) &&
_matches(header, 'WEBP'.codeUnits, 8)) {
return !_isAnimatedWebp(header);
}
final extension = file.path.split('.').last.toLowerCase();
return _extensionOnlyFormats.contains(extension);
}

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

Future<FileInfo> _resizeImageFile(
Expand All @@ -73,9 +126,7 @@ mixin ImageCacheManager on BaseCacheManager {
int? maxWidth,
int? maxHeight,
) async {
final originalFileName = originalFile.file.path;
final fileExtension = originalFileName.split('.').last;
if (!supportedFileNames.contains(fileExtension)) {
if (!await _isImage(originalFile.file)) {
return originalFile;
}

Expand Down Expand Up @@ -111,7 +162,11 @@ mixin ImageCacheManager on BaseCacheManager {
resizedFile,
key: key,
maxAge: maxAge,
fileExtension: fileExtension,
// The resized image is always encoded as png. The extension of the
// original file cannot be used: it is derived from the url, so it can
// be missing, or be the last segment of the path when the url has no
// extension at all.
fileExtension: 'png',
);

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

/// Reads the first bytes of [file], up to [length] bytes, which is enough to
/// hold the longest signature that is checked, including the webp flags.
Future<List<int>> _readHeader(File file, {int length = 30}) async {
final raf = await file.open();
try {
return await raf.read(length);
} finally {
await raf.close();
}
}

/// Whether [bytes] holds [signature] at [offset].
bool _matches(List<int> bytes, List<int> signature, [int offset = 0]) {
if (bytes.length < offset + signature.length) {
return false;
}
for (var i = 0; i < signature.length; i++) {
if (bytes[offset + i] != signature[i]) {
return false;
}
}
return true;
}

/// Whether [header] belongs to a webp that contains an animation.
///
/// An extended webp starts with a `VP8X` chunk, whose flags byte marks the file
/// as animated. Resizing re-encodes the image to png, which keeps a single
/// frame, so an animated webp is left as it is, the same way gif is.
bool _isAnimatedWebp(List<int> header) {
// "RIFF" + size + "WEBP" + "VP8X" + chunk size, the flags follow at 20.
const flagsOffset = 20;
const animationFlag = 0x02;
return header.length > flagsOffset &&
_matches(header, 'VP8X'.codeUnits, 12) &&
(header[flagsOffset] & animationFlag) != 0;
}

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.5
version: 3.4.6
homepage: https://github.com/Baseflow/flutter_cache_manager
topics:
- cache
Expand Down
66 changes: 66 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,57 @@ void main() {
expect(progress, isNotEmpty);
});
});

group('Test image detection', () {
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('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 webp should not be resized', () async {
const urlOfAnimatedWebp = 'baseflow.com/animation.webp';
var payload = animatedWebpHeader();
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);
});
});
}

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

/// The header of an animated webp: a `VP8X` chunk with the animation flag set.
/// Only the header is needed, the file is never decoded because it is not
/// resized.
Uint8List animatedWebpHeader() {
return Uint8List.fromList(<int>[
...'RIFF'.codeUnits, 0x1A, 0x00, 0x00, 0x00, //
...'WEBP'.codeUnits, //
...'VP8X'.codeUnits, 0x0A, 0x00, 0x00, 0x00, //
0x02, // flags: animation
0x00, 0x00, 0x00, // canvas width - 1
0x00, 0x00, 0x00, // canvas height - 1
]);
}