diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 275c791b..8d0d85b9 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -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)). diff --git a/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart b/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart index 5851a7ab..c59135c1 100644 --- a/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart +++ b/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart @@ -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 @@ -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( @@ -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( @@ -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 _decodeImage( File file, { int? width, diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 8456c4b5..c3d22cd7 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -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 diff --git a/flutter_cache_manager/test/image_cache_manager_test.dart b/flutter_cache_manager/test/image_cache_manager_test.dart index ce8ec599..6d6867bc 100644 --- a/flutter_cache_manager/test/image_cache_manager_test.dart +++ b/flutter_cache_manager/test/image_cache_manager_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'dart:ui'; @@ -127,6 +128,143 @@ 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('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 setupCacheManager() async { @@ -157,3 +295,11 @@ Future verifySize( Future getExampleImage() { return File('test/images/image-120.png').readAsBytes(); } + +Future getExampleWebp() { + return File('test/images/image-120.webp').readAsBytes(); +} + +Future getExampleBmp() { + return File('test/images/image-120.bmp').readAsBytes(); +} diff --git a/flutter_cache_manager/test/images/animated-120.apng b/flutter_cache_manager/test/images/animated-120.apng new file mode 100644 index 00000000..e4016a7e Binary files /dev/null and b/flutter_cache_manager/test/images/animated-120.apng differ diff --git a/flutter_cache_manager/test/images/animated-120.gif b/flutter_cache_manager/test/images/animated-120.gif new file mode 100644 index 00000000..3d03d817 Binary files /dev/null and b/flutter_cache_manager/test/images/animated-120.gif differ diff --git a/flutter_cache_manager/test/images/animated-120.webp b/flutter_cache_manager/test/images/animated-120.webp new file mode 100644 index 00000000..f5b57aa7 Binary files /dev/null and b/flutter_cache_manager/test/images/animated-120.webp differ diff --git a/flutter_cache_manager/test/images/image-120.bmp b/flutter_cache_manager/test/images/image-120.bmp new file mode 100644 index 00000000..1b93207e Binary files /dev/null and b/flutter_cache_manager/test/images/image-120.bmp differ diff --git a/flutter_cache_manager/test/images/image-120.webp b/flutter_cache_manager/test/images/image-120.webp new file mode 100644 index 00000000..338af89d Binary files /dev/null and b/flutter_cache_manager/test/images/image-120.webp differ