diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 9513fb80..53534429 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -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)). 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..0dbe6a78 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 @@ -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 = >[ + [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 @@ -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 _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> _runningResizes = {}; Future _resizeImageFile( @@ -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; } @@ -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( @@ -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> _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 bytes, List 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 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 _decodeImage( File file, { int? width, diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 3f0c9ce3..8456c4b5 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.5 +version: 3.4.6 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..3884f292 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,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 setupCacheManager() async { @@ -157,3 +209,17 @@ Future verifySize( Future 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([ + ...'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 + ]); +}